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

View File

@ -59,6 +59,7 @@ interface Props {
page?: number page?: number
limit?: number limit?: number
showPagination?: boolean showPagination?: boolean
carded?: boolean
pageSizes?: number[] pageSizes?: number[]
rowKey?: string rowKey?: string
stripe?: boolean stripe?: boolean
@ -111,36 +112,36 @@ function onLimitChange(l: number) {
</script> </script>
<template> <template>
<div class="rr-data-table" :class="{ borderless }"> <div class="rr-data-table" :class="{ borderless, carded }">
<el-table <el-table
:data="data" :data="data"
:loading="loading" :loading="loading"
:stripe="stripe" :stripe="stripe"
:border="!borderless" :border="!borderless"
:row-key="rowKey" :row-key="rowKey"
style="width: 100%" style="width: 100%" :cell-class-name="'rr-data-cell'"
@row-click="onRowClick" @row-click="onRowClick"
@selection-change="onSelectionChange" @selection-change="onSelectionChange"
row-class-name="rr-clickable-row" row-class-name="rr-clickable-row"
:header-row-style="{ height: '40px' }" :header-row-style="{ height: '40px' }"
:row-style="{ height: '52px' }" :row-style="{ height: '52px' }"
:header-cell-style="{ :header-cell-style="{
background: '#F8FAFC', background: 'var(--rr-table-header-bg, #F8FAFC)',
color: '#475569', color: 'var(--rr-text-secondary, #475569)',
fontWeight: '600', fontWeight: '600',
fontSize: '12.5px', fontSize: '12.5px',
padding: '10px 14px', padding: '10px 14px',
borderBottom: '1px solid #E2E8F0', borderBottom: '1px solid var(--rr-border-color, #E2E8F0)',
lineHeight: '18.75px', lineHeight: '18.75px',
textAlign: 'center', textAlign: 'center',
height: '40px' height: '40px'
}" }"
:cell-style="{ :cell-style="{
padding: '0 14px', padding: '0 14px',
borderBottom: '1px solid #E2E8F0', borderBottom: '1px solid var(--rr-border-color, #E2E8F0)',
fontSize: '13.5px', fontSize: '13.5px',
lineHeight: '20.25px', lineHeight: '20.25px',
color: '#0F172A', color: 'var(--rr-text-primary, #0F172A)',
height: '52px' 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"> <script setup lang="ts">
/** /**
* F006 通用 PageHeader 组件 (W9 A2) * F006 通用 PageHeader 组件 (W17 设计规范整改)
* *
* 页面头: 标题 + 副标题 + 操作按钮区 * 页面头: 卡片化 (白底圆角阴影) + 标题 + 副标题 + 操作按钮区
* *
* 用法: * 用法:
* <PageHeader title="订单管理" :sub="`共 ${total} 条`"> * <PageHeader title="订单管理" :sub="`共 ${total} 条`">
@ -13,14 +13,16 @@
interface Props { interface Props {
title: string title: string
sub?: string sub?: string
bordered?: boolean
} }
withDefaults(defineProps<Props>(), { withDefaults(defineProps<Props>(), {
sub: '' sub: '',
bordered: true
}) })
</script> </script>
<template> <template>
<div class="rr-page-header"> <div class="rr-page-header" :class="{ 'is-bordered': bordered }">
<div class="rr-page-header-text"> <div class="rr-page-header-text">
<h2 class="rr-page-title">{{ title }}</h2> <h2 class="rr-page-title">{{ title }}</h2>
<p v-if="sub" class="rr-page-sub">{{ sub }}</p> <p v-if="sub" class="rr-page-sub">{{ sub }}</p>
@ -33,26 +35,31 @@ withDefaults(defineProps<Props>(), {
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page-header { .rr-page-header {
margin-bottom: 16px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
gap: 16px; 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 { .rr-page-header-text {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.rr-page-title { .rr-page-title {
font-size: 22px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: var(--ink); color: var(--rr-text-primary, #0F172A);
margin: 0 0 4px 0; margin: 0 0 4px 0;
line-height: 1.3; line-height: 1.3;
} }
.rr-page-sub { .rr-page-sub {
color: var(--ink-3); color: var(--rr-text-tertiary, #94A3B8);
font-size: 13px; font-size: 12.5px;
margin: 0; margin: 0;
line-height: 1.4; line-height: 1.4;
} }

View File

@ -1,24 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 通用 SearchBar 组件 (W9 A2) * F006 通用 SearchBar 组件 (W17 设计规范整改)
* *
* 标准搜索条: 字段映射 + 行内表单 + 查询/重置按钮 * 标准搜索条: 紧凑布局 + 已选 chip 标签 + 查询/重置按钮
* *
* 用法: * 用法:
* <SearchBar * <SearchBar
* v-model:filters="filters" * v-model:filters="filters"
* :fields="[ * :fields="[
* { key: 'name', label: '名称', type: 'text', placeholder: '请输入' }, * { key: 'name', label: '名称', type: 'text', placeholder: '请输入' },
* { key: 'status', label: '状态', type: 'select', options: [ * { key: 'status', label: '状态', type: 'select', options: [...] },
* { value: 0, label: '启用' }, { value: 1, label: '禁用' }
* ] },
* { key: 'date', label: '日期', type: 'daterange' } * { key: 'date', label: '日期', type: 'daterange' }
* ]" * ]"
* @search="onSearch" * @search="onSearch"
* @reset="onReset" * @reset="onReset"
* /> * />
* *
* 字段类型 (W9 阶段支持 5 种, 后续可扩): * 字段类型:
* - text: el-input * - text: el-input
* - select: el-select + options * - select: el-select + options
* - date: el-date-picker (单日) * - date: el-date-picker (单日)
@ -26,6 +24,7 @@
* - number: el-input-number * - number: el-input-number
*/ */
import { computed } from 'vue' import { computed } from 'vue'
import { Close } from '@element-plus/icons-vue'
export interface SearchFieldOption { export interface SearchFieldOption {
value: any value: any
@ -46,10 +45,12 @@ interface Props {
filters: Record<string, any> filters: Record<string, any>
fields: SearchField[] fields: SearchField[]
showReset?: boolean showReset?: boolean
showChips?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
showReset: true showReset: true,
showChips: true
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@ -64,6 +65,7 @@ const localFilters = computed({
}) })
function onSearch() { emit('search') } function onSearch() { emit('search') }
function onReset() { function onReset() {
for (const f of props.fields) { for (const f of props.fields) {
if (f.type === 'daterange') { if (f.type === 'daterange') {
@ -76,23 +78,52 @@ function onReset() {
} }
emit('reset') emit('reset')
} }
function onEnter() { onSearch() } 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> </script>
<template> <template>
<div class="rr-card rr-search"> <div class="rr-search-bar">
<el-form :inline="true" @keyup.enter="onEnter"> <div class="rr-search-bar__row">
<el-form-item <div class="rr-search-bar__field" v-for="f in fields" :key="f.key">
v-for="f in fields" <span class="rr-search-bar__label">{{ f.label }}</span>
:key="f.key"
:label="f.label"
>
<el-input <el-input
v-if="f.type === 'text' || !f.type" v-if="f.type === 'text' || !f.type"
v-model="localFilters[f.key]" v-model="localFilters[f.key]"
:placeholder="f.placeholder || `请输入${f.label}`" :placeholder="f.placeholder || `请输入${f.label}`"
:clearable="f.clearable !== false" :clearable="f.clearable !== false"
:style="{ width: f.width || 220 }" :style="{ width: f.width || 180 }"
@keyup.enter="onEnter"
/> />
<el-select <el-select
v-else-if="f.type === 'select'" v-else-if="f.type === 'select'"
@ -120,9 +151,9 @@ function onEnter() { onSearch() }
v-else-if="f.type === 'daterange'" v-else-if="f.type === 'daterange'"
v-model="localFilters[f.key]" v-model="localFilters[f.key]"
type="daterange" type="daterange"
range-separator="至" range-separator="~"
start-placeholder="开始" :start-placeholder="f.placeholder || '开始日期'"
end-placeholder="结束" :end-placeholder="'结束日期'"
:style="{ width: f.width || 260 }" :style="{ width: f.width || 260 }"
value-format="YYYY-MM-DD" value-format="YYYY-MM-DD"
/> />
@ -132,16 +163,87 @@ function onEnter() { onSearch() }
:placeholder="f.placeholder || `请输入${f.label}`" :placeholder="f.placeholder || `请输入${f.label}`"
:style="{ width: f.width || 180 }" :style="{ width: f.width || 180 }"
/> />
</el-form-item> </div>
<el-form-item> <div class="rr-search-bar__actions">
<el-button type="primary" @click="onSearch">查询</el-button> <el-button type="primary" @click="onSearch">查询</el-button>
<el-button v-if="showReset" @click="onReset">重置</el-button> <el-button v-if="showReset" @click="onReset">重置</el-button>
</el-form-item> </div>
</el-form> </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> </div>
</template> </template>
<style scoped lang="scss"> <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-bar {
.rr-search { padding: 16px 20px; } 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> </style>

View File

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