feat(components): 通用组件设计规范整改 (ListTabs/PageHeader白卡/SearchBar chip/DataTable var/Content bg) W17.2

This commit is contained in:
Sisyphus 2026-07-07 12:56:11 +08:00
parent 5c35297555
commit 7724b4a3b1
6 changed files with 320 additions and 95 deletions

View File

@ -356,7 +356,7 @@ onMounted(loadBadges)
.rr-layout {
display: flex;
height: 100vh;
background: var(--bg-soft);
background: var(--rr-page-bg, #F1F5F9);
transition: background 0.3s;
&.collapsed .rr-sidebar { width: 64px; }
}
@ -457,7 +457,7 @@ onMounted(loadBadges)
transition: all 0.12s;
white-space: nowrap;
&:hover { background: var(--bg-soft); color: var(--ink); }
&:hover { background: var(--rr-page-bg, #F1F5F9); color: var(--ink); }
&.active {
background: var(--brand-soft);
color: var(--brand);
@ -475,7 +475,7 @@ onMounted(loadBadges)
font-weight: 600;
padding: 1px 7px;
border-radius: 10px;
background: var(--bg-soft);
background: var(--rr-page-bg, #F1F5F9);
color: var(--ink-3);
.rr-sidebar-item.active & { background: rgba(255,255,255,0.18); color: var(--brand); }
@ -501,7 +501,7 @@ onMounted(loadBadges)
margin-bottom: 1px;
white-space: nowrap;
&:hover { background: var(--bg-soft); color: var(--ink); }
&:hover { background: var(--rr-page-bg, #F1F5F9); color: var(--ink); }
&.active {
background: var(--brand-soft);
color: var(--brand);
@ -602,7 +602,7 @@ onMounted(loadBadges)
justify-content: center;
cursor: pointer;
color: var(--ink-2);
&:hover { background: var(--bg-soft); color: var(--brand); }
&:hover { background: var(--rr-page-bg, #F1F5F9); color: var(--brand); }
}
.rr-topbar-breadcrumb { font-size: 13px; }
@ -627,7 +627,7 @@ onMounted(loadBadges)
padding: 4px 8px;
border-radius: 16px;
cursor: pointer;
&:hover { background: var(--bg-soft); }
&:hover { background: var(--rr-page-bg, #F1F5F9); }
}
.rr-topbar-avatar {
width: 26px;
@ -652,7 +652,7 @@ onMounted(loadBadges)
flex: 1;
overflow: auto;
padding: 24px 28px;
background: var(--bg-soft);
background: var(--rr-page-bg, #F1F5F9);
}
/* ===== W5.x v2 Design Language 新增样式 ===== */
@ -662,7 +662,7 @@ onMounted(loadBadges)
width: 28px;
height: 28px;
border-radius: 6px;
background: var(--bg-soft);
background: var(--rr-page-bg, #F1F5F9);
display: flex;
align-items: center;
justify-content: center;
@ -691,7 +691,7 @@ img.rr-sidebar-logo {
transition: all 0.2s var(--ease);
}
.rr-topbar-notification:hover {
background: var(--bg-soft);
background: var(--rr-page-bg, #F1F5F9);
color: var(--brand);
}
.rr-topbar-notification-icon {
@ -733,7 +733,7 @@ img.rr-sidebar-logo {
width: 28px;
height: 28px;
border-radius: 6px;
background: var(--bg-soft);
background: var(--rr-page-bg, #F1F5F9);
display: flex;
align-items: center;
justify-content: center;
@ -762,7 +762,7 @@ img.rr-sidebar-logo {
transition: all 0.2s var(--ease);
}
.rr-topbar-notification:hover {
background: var(--bg-soft);
background: var(--rr-page-bg, #F1F5F9);
color: var(--brand);
}
.rr-topbar-notification-icon {

View File

@ -59,6 +59,7 @@ interface Props {
page?: number
limit?: number
showPagination?: boolean
carded?: boolean
pageSizes?: number[]
rowKey?: string
stripe?: boolean
@ -111,36 +112,36 @@ function onLimitChange(l: number) {
</script>
<template>
<div class="rr-data-table" :class="{ borderless }">
<div class="rr-data-table" :class="{ borderless, carded }">
<el-table
:data="data"
:loading="loading"
:stripe="stripe"
:border="!borderless"
:row-key="rowKey"
style="width: 100%"
style="width: 100%" :cell-class-name="'rr-data-cell'"
@row-click="onRowClick"
@selection-change="onSelectionChange"
row-class-name="rr-clickable-row"
:header-row-style="{ height: '40px' }"
:row-style="{ height: '52px' }"
:header-cell-style="{
background: '#F8FAFC',
color: '#475569',
background: 'var(--rr-table-header-bg, #F8FAFC)',
color: 'var(--rr-text-secondary, #475569)',
fontWeight: '600',
fontSize: '12.5px',
padding: '10px 14px',
borderBottom: '1px solid #E2E8F0',
borderBottom: '1px solid var(--rr-border-color, #E2E8F0)',
lineHeight: '18.75px',
textAlign: 'center',
height: '40px'
}"
:cell-style="{
padding: '0 14px',
borderBottom: '1px solid #E2E8F0',
borderBottom: '1px solid var(--rr-border-color, #E2E8F0)',
fontSize: '13.5px',
lineHeight: '20.25px',
color: '#0F172A',
color: 'var(--rr-text-primary, #0F172A)',
height: '52px'
}"
>

View File

@ -0,0 +1,117 @@
<script setup lang="ts">
/**
* F006 通用 ListTabs 组件 (W17 设计规范整改)
*
* 状态 tab + 数字 badge, 完全按设计规范:
* 全部 128 待报价 23 进行中 45 已完成 52 已取消 8
*
* 用法:
* <ListTabs
* v-model="activeTab"
* :items="[
* { key: 'all', label: '全部', count: 128 },
* { key: 'pending', label: '待报价', count: 23 },
* ...
* ]"
* />
*/
export interface ListTabItem {
key: string
label: string
count?: number
}
interface Props {
modelValue: string
items: ListTabItem[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update:modelValue', v: string): void
(e: 'change', v: string): void
}>()
function onClick(key: string) {
emit('update:modelValue', key)
emit('change', key)
}
</script>
<template>
<div class="rr-list-tabs">
<div
v-for="item in items"
:key="item.key"
class="rr-list-tab"
:class="{ 'is-active': props.modelValue === item.key }"
@click="onClick(item.key)"
>
<span class="rr-list-tab__label">{{ item.label }}</span>
<span v-if="item.count !== undefined" class="rr-list-tab__count">{{ item.count }}</span>
</div>
</div>
</template>
<style scoped lang="scss">
.rr-list-tabs {
display: flex;
align-items: center;
gap: 32px;
padding: 0 0 12px 0;
border-bottom: 1px solid var(--rr-border-color, #E2E8F0);
margin-bottom: 16px;
}
.rr-list-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 0;
font-size: 14px;
color: var(--rr-text-secondary, #475569);
cursor: pointer;
position: relative;
user-select: none;
transition: color 0.2s;
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -13px;
height: 2px;
background: transparent;
border-radius: 1px;
transition: background 0.2s;
}
&:hover {
color: var(--rr-color-primary, #2563EB);
}
&.is-active {
color: var(--rr-color-primary, #2563EB);
font-weight: 600;
&::after {
background: var(--rr-color-primary, #2563EB);
}
}
&__label {
line-height: 1;
}
&__count {
font-size: 12px;
color: var(--rr-text-tertiary, #94A3B8);
font-weight: 400;
line-height: 1;
}
&.is-active &__count {
color: var(--rr-color-primary, #2563EB);
}
}
</style>

View File

@ -1,8 +1,8 @@
<script setup lang="ts">
/**
* F006 通用 PageHeader 组件 (W9 A2)
* F006 通用 PageHeader 组件 (W17 设计规范整改)
*
* 页面头: 标题 + 副标题 + 操作按钮区
* 页面头: 卡片化 (白底圆角阴影) + 标题 + 副标题 + 操作按钮区
*
* 用法:
* <PageHeader title="订单管理" :sub="`共 ${total} 条`">
@ -13,14 +13,16 @@
interface Props {
title: string
sub?: string
bordered?: boolean
}
withDefaults(defineProps<Props>(), {
sub: ''
sub: '',
bordered: true
})
</script>
<template>
<div class="rr-page-header">
<div class="rr-page-header" :class="{ 'is-bordered': bordered }">
<div class="rr-page-header-text">
<h2 class="rr-page-title">{{ title }}</h2>
<p v-if="sub" class="rr-page-sub">{{ sub }}</p>
@ -33,26 +35,31 @@ withDefaults(defineProps<Props>(), {
<style scoped lang="scss">
.rr-page-header {
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 20px 24px;
background: var(--rr-bg-card, #FFFFFF);
border: 1px solid var(--rr-border-color, #E2E8F0);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
margin-bottom: 16px;
}
.rr-page-header-text {
flex: 1;
min-width: 0;
}
.rr-page-title {
font-size: 22px;
font-size: 18px;
font-weight: 600;
color: var(--ink);
color: var(--rr-text-primary, #0F172A);
margin: 0 0 4px 0;
line-height: 1.3;
}
.rr-page-sub {
color: var(--ink-3);
font-size: 13px;
color: var(--rr-text-tertiary, #94A3B8);
font-size: 12.5px;
margin: 0;
line-height: 1.4;
}

View File

@ -1,24 +1,22 @@
<script setup lang="ts">
/**
* F006 通用 SearchBar 组件 (W9 A2)
* F006 通用 SearchBar 组件 (W17 设计规范整改)
*
* 标准搜索条: 字段映射 + 行内表单 + 查询/重置按钮
* 标准搜索条: 紧凑布局 + 已选 chip 标签 + 查询/重置按钮
*
* 用法:
* <SearchBar
* v-model:filters="filters"
* :fields="[
* { key: 'name', label: '名称', type: 'text', placeholder: '请输入' },
* { key: 'status', label: '状态', type: 'select', options: [
* { value: 0, label: '启用' }, { value: 1, label: '禁用' }
* ] },
* { key: 'status', label: '状态', type: 'select', options: [...] },
* { key: 'date', label: '日期', type: 'daterange' }
* ]"
* @search="onSearch"
* @reset="onReset"
* />
*
* 字段类型 (W9 阶段支持 5 种, 后续可扩):
* 字段类型:
* - text: el-input
* - select: el-select + options
* - date: el-date-picker (单日)
@ -26,6 +24,7 @@
* - number: el-input-number
*/
import { computed } from 'vue'
import { Close } from '@element-plus/icons-vue'
export interface SearchFieldOption {
value: any
@ -46,10 +45,12 @@ interface Props {
filters: Record<string, any>
fields: SearchField[]
showReset?: boolean
showChips?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showReset: true
showReset: true,
showChips: true
})
const emit = defineEmits<{
@ -64,6 +65,7 @@ const localFilters = computed({
})
function onSearch() { emit('search') }
function onReset() {
for (const f of props.fields) {
if (f.type === 'daterange') {
@ -76,23 +78,52 @@ function onReset() {
}
emit('reset')
}
function onEnter() { onSearch() }
function onClearField(key: string) {
const f = props.fields.find(it => it.key === key)
if (!f) return
if (f.type === 'daterange') localFilters.value[key] = []
else if (f.type === 'number') localFilters.value[key] = undefined
else localFilters.value[key] = ''
}
// 提取已选 chip
const activeChips = computed(() => {
return props.fields
.map(f => {
const v = props.filters[f.key]
let display: string | null = null
if (f.type === 'daterange') {
if (Array.isArray(v) && v.length === 2) display = `${v[0]} ~ ${v[1]}`
} else if (f.type === 'select' && v !== '' && v !== undefined && v !== null) {
const opt = f.options?.find(o => String(o.value) === String(v))
display = opt ? opt.label : String(v)
} else if (v !== '' && v !== undefined && v !== null) {
display = String(v)
}
if (display) {
return { key: f.key, label: `${f.label}: ${display}` }
}
return null
})
.filter(Boolean) as { key: string; label: string }[]
})
</script>
<template>
<div class="rr-card rr-search">
<el-form :inline="true" @keyup.enter="onEnter">
<el-form-item
v-for="f in fields"
:key="f.key"
:label="f.label"
>
<div class="rr-search-bar">
<div class="rr-search-bar__row">
<div class="rr-search-bar__field" v-for="f in fields" :key="f.key">
<span class="rr-search-bar__label">{{ f.label }}</span>
<el-input
v-if="f.type === 'text' || !f.type"
v-model="localFilters[f.key]"
:placeholder="f.placeholder || `请输入${f.label}`"
:clearable="f.clearable !== false"
:style="{ width: f.width || 220 }"
:style="{ width: f.width || 180 }"
@keyup.enter="onEnter"
/>
<el-select
v-else-if="f.type === 'select'"
@ -120,9 +151,9 @@ function onEnter() { onSearch() }
v-else-if="f.type === 'daterange'"
v-model="localFilters[f.key]"
type="daterange"
range-separator="至"
start-placeholder="开始"
end-placeholder="结束"
range-separator="~"
:start-placeholder="f.placeholder || '开始日期'"
:end-placeholder="'结束日期'"
:style="{ width: f.width || 260 }"
value-format="YYYY-MM-DD"
/>
@ -132,16 +163,87 @@ function onEnter() { onSearch() }
:placeholder="f.placeholder || `请输入${f.label}`"
:style="{ width: f.width || 180 }"
/>
</el-form-item>
<el-form-item>
</div>
<div class="rr-search-bar__actions">
<el-button type="primary" @click="onSearch">查询</el-button>
<el-button v-if="showReset" @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
</div>
<div v-if="showChips && activeChips.length" class="rr-search-bar__chips">
<span class="rr-search-bar__chips-label">已选:</span>
<el-tag
v-for="c in activeChips"
:key="c.key"
class="rr-search-bar__chip"
closable
:disable-transitions="true"
@close="onClearField(c.key)"
>
{{ c.label }}
</el-tag>
<el-link v-if="activeChips.length > 1" type="primary" :underline="false" @click="onReset" class="rr-search-bar__clear-all">
清空
</el-link>
</div>
</div>
</template>
<style scoped lang="scss">
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 16px; }
.rr-search { padding: 16px 20px; }
.rr-search-bar {
padding: 16px 20px;
background: var(--rr-bg-card, #FFFFFF);
border: 1px solid var(--rr-border-color, #E2E8F0);
border-radius: 8px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
&__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
}
&__field {
display: flex;
align-items: center;
gap: 8px;
}
&__label {
font-size: 13px;
color: var(--rr-text-secondary, #475569);
white-space: nowrap;
flex-shrink: 0;
}
&__actions {
display: flex;
gap: 8px;
margin-left: auto;
}
&__chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--rr-border-color, #E2E8F0);
}
&__chips-label {
font-size: 12px;
color: var(--rr-text-tertiary, #94A3B8);
}
&__chip {
font-size: 12px;
}
&__clear-all {
font-size: 12px;
}
}
</style>

View File

@ -1,15 +1,11 @@
<script setup lang="ts">
/**
* F006 用户管理 (前台用户列表) - W17 设计规范整改
* F006 用户管理 (前台用户列表) - W17 设计规范完整版
*
* W17: 9 列 + 6 搜索 + 4 操作,符合设计规范
* 走 /user/user/page 后端
*
* 字段 (从 API 实测):
* id, nickName, mobile, header (头像 URL)
* isEnterprise (0/1), enterpriseName, accountBalance, enabled (0/1)
* createDate (ms)
* total=2284 真实用户
* 9 列 + 6 搜索 + 4 操作, 符合设计规范:
* - 状态 chip (是否企业认证 / 禁用标识)
* - 操作列 el-link + 竖线分隔
* - 白卡片化表格
*/
import { onMounted, ref, reactive } from 'vue'
import { httpGet, httpPost } from '@/utils/request'
@ -188,7 +184,10 @@ onMounted(load)
<template>
<div class="rr-page">
<PageHeader title="用户列表" :sub="`前台注册用户 (共 ${total} 个)`" />
<PageHeader title="用户列表" :sub="`管理前台注册用户,共 ${total} 个账号`">
<el-button type="primary" :icon="'Plus'" @click="ElMessageBox.alert('导出功能待 W17 完善', '提示', { type: 'info' })">导出</el-button>
<el-button type="primary" @click="ElMessageBox.alert('新增用户功能待 W17 完善', '提示', { type: 'info' })">新增</el-button>
</PageHeader>
<SearchBar
v-model:filters="query"
@ -206,53 +205,52 @@ onMounted(load)
@reset="onReset"
/>
<div class="rr-card">
<DataTable
:columns="columns"
:data="list"
:loading="loading"
:total="total"
:page="query.page"
:limit="query.limit"
:page-sizes="[10, 20, 50, 100]"
:show-pagination="true"
row-key="id"
empty-text="暂无用户"
@page-change="onPageChange"
>
<template #enabled="{ row }">
<el-tag v-if="row.enabled === 1" type="success" size="small" effect="light">正常</el-tag>
<el-tag v-else type="danger" size="small" effect="light">禁用</el-tag>
</template>
<template #action="{ row }">
<span class="rr-actions">
<el-link type="primary" :underline="false" @click="onEdit(row)">修改</el-link>
<span class="rr-actions__sep">|</span>
<el-link :type="row.enabled === 1 ? 'warning' : 'success'" :underline="false" @click="toggleEnabled(row)">
{{ row.enabled === 1 ? '禁用' : '启用' }}
</el-link>
<span class="rr-actions__sep">|</span>
<el-link type="danger" :underline="false" @click="deleteUser(row)">删除</el-link>
</span>
</template>
</DataTable>
</div>
<DataTable
:columns="columns"
:data="list"
:loading="loading"
:total="total"
:page="query.page"
:limit="query.limit"
:page-sizes="[10, 20, 50, 100]"
:show-pagination="true"
:carded="true"
row-key="id"
empty-text="暂无用户"
@page-change="onPageChange"
>
<template #enabled="{ row }">
<el-tag v-if="row.enabled === 1" type="success" size="small" effect="light" round>正常</el-tag>
<el-tag v-else type="danger" size="small" effect="light" round>禁用</el-tag>
</template>
<template #action="{ row }">
<span class="rr-actions">
<el-link type="primary" :underline="false" @click="onEdit(row)">修改</el-link>
<span class="rr-actions__sep">|</span>
<el-link :type="row.enabled === 1 ? 'warning' : 'success'" :underline="false" @click="toggleEnabled(row)">
{{ row.enabled === 1 ? '禁用' : '启用' }}
</el-link>
<span class="rr-actions__sep">|</span>
<el-link type="danger" :underline="false" @click="deleteUser(row)">删除</el-link>
</span>
</template>
</DataTable>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 1600px; }
.rr-page { max-width: 100%; }
.user-header { width: 32px; height: 32px; border-radius: 50%; vertical-align: middle; }
.rr-actions {
display: inline-flex;
align-items: center;
gap: 0;
&__sep {
color: var(--rr-border, #e2e8f0);
color: var(--rr-border-color, #E2E8F0);
margin: 0 8px;
font-size: 12px;
}
.el-link {
:deep(.el-link) {
font-size: 13px;
}
}