fix(F006-W17.3): 圆角对齐模板(--radius 8px) + 分页/弹窗 zhCn + 表头/单元 padding 紧凑

This commit is contained in:
Sisyphus 2026-07-07 13:57:01 +08:00
parent f97a0fb049
commit 990a941a3b
4 changed files with 643 additions and 1 deletions

255
src/DataTable.vue Normal file
View File

@ -0,0 +1,255 @@
<script setup lang="ts">
/**
* F006 通用 DataTable 组件 (W17 设计规范 v2 整改)
*
* 按 ui-preview-v2.html 模板:
* - 表头 text-transform: uppercase + 左对齐
* - 单元格紧凑 padding 12px 16px + 左对齐
* - 颜色全用 var(--xxx) 主题化
* - 表格外无卡片包裹
*
* 用法:
* <DataTable
* :columns="[
* { prop: 'id', label: '单号', width: 180 },
* { label: '操作', width: 100, slot: 'action' }
* ]"
* :data="list" :loading="loading" :total="total" :page="page" :limit="limit"
* @page-change="onPageChange"
* >
* <template #action="{ row }">
* <el-button text type="primary" size="small">查看</el-button>
* </template>
* </DataTable>
*/
import { computed } from 'vue'
interface Column {
prop?: string
label: string
width?: number | string
minWidth?: number | string
type?: 'status' | 'text' | 'selection'
statusMap?: Record<string, { label: string; type: string }>
slot?: string
formatter?: (row: any) => string
align?: 'left' | 'center' | 'right'
fixed?: 'left' | 'right' | true
}
interface Props {
columns: Column[]
data: any[]
loading?: boolean
total?: number
page?: number
limit?: number
showPagination?: boolean
pageSizes?: number[]
rowKey?: string
stripe?: boolean
borderless?: boolean
emptyText?: string
errorText?: string
error?: string | null
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
total: 0,
page: 1,
limit: 20,
showPagination: true,
pageSizes: () => [10, 20, 50, 100],
rowKey: 'id',
stripe: false,
borderless: false,
emptyText: '暂无数据',
errorText: '',
error: null
})
const emit = defineEmits<{
(e: 'row-click', row: any): void
(e: 'page-change', page: number): void
(e: 'limit-change', limit: number): void
(e: 'selection-change', rows: any[]): void
}>()
const hasStatusCol = computed(() => props.columns.some(c => c.type === 'status'))
const showEmpty = computed(() => !props.loading && props.data.length === 0)
const showPager = computed(() => props.showPagination && props.total > 0 && props.data.length > 0)
function getStatusMeta(col: Column, value: string) {
return col.statusMap?.[value] || { label: value || '--', type: 'info' }
}
function onSelectionChange(rows: any[]) { emit('selection-change', rows) }
function onRowClick(row: any) { emit('row-click', row) }
function onPageChange(p: number) { emit('page-change', p) }
function onLimitChange(l: number) { emit('limit-change', l) }
</script>
<template>
<div class="rr-data-table" :class="{ borderless }">
<el-table
:data="data"
:loading="loading"
:stripe="stripe"
:border="!borderless"
:row-key="rowKey"
style="width: 100%"
@row-click="onRowClick"
@selection-change="onSelectionChange"
row-class-name="rr-clickable-row"
:header-row-style="{ height: '36px' }"
:header-cell-style="{
background: 'var(--rr-table-header-bg, #F8FAFC)',
color: 'var(--rr-text-secondary, #64748B)',
fontWeight: '600',
fontSize: '12px',
padding: '8px 16px',
borderBottom: '1px solid var(--rr-border-color, #E2E8F0)',
textTransform: 'uppercase',
letterSpacing: '0.04em',
whiteSpace: 'nowrap',
textAlign: 'left'
}"
:cell-style="{
padding: '10px 16px',
borderBottom: '1px solid var(--rr-border-color, #E2E8F0)',
color: 'var(--rr-text-secondary, #334155)',
fontSize: '13px',
textAlign: 'left',
verticalAlign: 'middle'
}"
>
<template v-for="(col, idx) in columns" :key="idx">
<el-table-column
v-if="col.type === 'selection'"
type="selection"
:width="col.width || 50"
:align="col.align || 'left'"
:fixed="col.fixed"
/>
<el-table-column
v-else
:prop="col.type === 'status' || col.slot ? undefined : col.prop"
:label="col.label"
:width="col.width"
:min-width="col.minWidth"
:align="col.align || 'left'"
:fixed="col.fixed"
:show-overflow-tooltip="true"
>
<template v-if="col.slot" #default="{ row }">
<slot :name="col.slot" :row="row" />
</template>
<template v-else-if="col.type === 'status'" #default="{ row }">
<el-tag
:type="(getStatusMeta(col, row[col.prop!]).type) as any"
size="small"
effect="light"
>
{{ getStatusMeta(col, row[col.prop!]).label }}
</el-tag>
</template>
<template v-else-if="col.formatter" #default="{ row }">
{{ col.formatter?.(row) }}
</template>
</el-table-column>
</template>
<template #empty>
<div class="rr-empty">
<span v-if="error" class="rr-empty__error">{{ error || errorText }}</span>
<span v-else>{{ emptyText }}</span>
</div>
</template>
</el-table>
<div v-if="showPager" class="rr-pager">
<div class="rr-pager__info">
共 {{ total }} 条
</div>
<el-pagination
:current-page="page"
:page-size="limit"
:total="total"
:page-sizes="pageSizes"
:pager-count="5"
layout="sizes, prev, pager, next, jumper"
background
@current-change="onPageChange"
@size-change="onLimitChange"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.rr-data-table {
width: 100%;
background: var(--rr-bg-card, #FFFFFF);
border: 1px solid var(--rr-border-color, #E2E8F0);
border-radius: 10px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
// 行 hover 高亮
:deep(.el-table__row) {
transition: background 0.15s;
}
:deep(.el-table__row:hover > td.el-table__cell) {
background-color: var(--rr-row-hover, #F8FAFC) !important;
}
// 表格本体圆角配合容器
:deep(.el-table) {
border-radius: 10px 10px 0 0;
overflow: hidden;
}
:deep(.el-table__inner-wrapper) {
border-radius: 10px 10px 0 0;
}
// 去掉 el-table 默认的 border
:deep(.el-table--border) {
border: none;
}
:deep(.el-table::before), :deep(.el-table::after) {
display: none;
}
// borderless 模式:整体无边框
&.borderless {
border: none;
box-shadow: none;
}
}
.rr-empty {
text-align: center;
padding: 32px 0;
color: var(--ink-3, #94A3B8);
font-size: 13px;
&__error {
color: var(--red, #EF4444);
}
}
.rr-pager {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
font-size: 12.5px;
color: var(--ink-3, #94A3B8);
background: var(--rr-bg-card, #FFFFFF);
border-top: 1px solid var(--rr-border-color, #F1F5F9);
&__info { color: var(--ink-3, #94A3B8); }
:deep(.el-pagination) {
--el-pagination-bg-color: transparent;
}
:deep(.el-pager li) {
border-radius: 6px;
margin: 0 2px;
}
:deep(.el-pagination .btn-prev),
:deep(.el-pagination .btn-next) {
border-radius: 6px;
}
}
</style>

263
src/SearchBar.vue Normal file
View File

@ -0,0 +1,263 @@
<script setup lang="ts">
/**
* F006 通用 SearchBar 组件 (W17 设计规范 v2 整改)
*
* 按 ui-preview-v2.html 模板: 紧凑布局 + 无 label + 同行末尾 chip + 简洁按钮
*
* 用法:
* <SearchBar
* v-model:filters="filters"
* :fields="[
* { key: 'name', label: '名称', type: 'text', placeholder: '请输入' },
* { key: 'status', label: '状态', type: 'select', options: [...] }
* ]"
* @search="onSearch"
* @reset="onReset"
* />
*/
import { computed } from 'vue'
export interface SearchFieldOption {
value: any
label: string
}
export interface SearchField {
key: string
label: string
type?: 'text' | 'select' | 'date' | 'daterange' | 'number'
placeholder?: string
options?: SearchFieldOption[]
width?: number | string
clearable?: boolean
showLabel?: boolean
}
interface Props {
filters: Record<string, any>
fields: SearchField[]
showReset?: boolean
showChips?: boolean
showLabel?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showReset: true,
showChips: true,
showLabel: false
})
const emit = defineEmits<{
(e: 'update:filters', filters: Record<string, any>): void
(e: 'search'): void
(e: 'reset'): void
}>()
const localFilters = computed({
get: () => props.filters,
set: (v) => emit('update:filters', v)
})
function onSearch() { emit('search') }
function onReset() {
for (const f of props.fields) {
if (f.type === 'daterange') localFilters.value[f.key] = []
else if (f.type === 'number') localFilters.value[f.key] = undefined
else localFilters.value[f.key] = ''
}
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] = ''
}
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-search-bar">
<div class="rr-search-bar__row">
<div class="rr-search-bar__field" v-for="f in fields" :key="f.key">
<span v-if="showLabel || f.showLabel" 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 || 180 }"
@keyup.enter="onEnter"
/>
<el-select
v-else-if="f.type === 'select'"
v-model="localFilters[f.key]"
:placeholder="f.placeholder || `请选择${f.label}`"
:clearable="f.clearable !== false"
:style="{ width: f.width || 160 }"
>
<el-option
v-for="opt in f.options || []"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-date-picker
v-else-if="f.type === 'date'"
v-model="localFilters[f.key]"
type="date"
:placeholder="f.placeholder || `请选择${f.label}`"
:style="{ width: f.width || 180 }"
value-format="YYYY-MM-DD"
/>
<el-date-picker
v-else-if="f.type === 'daterange'"
v-model="localFilters[f.key]"
type="daterange"
range-separator="~"
:start-placeholder="f.placeholder || '开始日期'"
:end-placeholder="'结束日期'"
:style="{ width: f.width || 260 }"
value-format="YYYY-MM-DD"
/>
<el-input-number
v-else-if="f.type === 'number'"
v-model="localFilters[f.key]"
:placeholder="f.placeholder || `请输入${f.label}`"
:style="{ width: f.width || 180 }"
/>
</div>
<div class="rr-search-bar__chips" v-if="showChips && activeChips.length">
<div
v-for="c in activeChips"
:key="c.key"
class="rr-search-bar__chip"
>
{{ c.label }}
<span class="rr-search-bar__chip-close" @click="onClearField(c.key)">×</span>
</div>
</div>
<div class="rr-search-bar__actions">
<el-button type="primary" @click="onSearch">查询</el-button>
<el-button v-if="showReset" @click="onReset">重置</el-button>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
/* === 统一 input/select 圆角 (按 ui-preview-v2.html 模板) === */
:deep(.el-input__wrapper),
:deep(.el-select__wrapper) {
border-radius: var(--radius, 8px);
box-shadow: 0 0 0 1px var(--rr-border-color, #E2E8F0) inset;
transition: box-shadow 0.15s, border-color 0.15s;
min-height: 34px;
}
:deep(.el-input__wrapper:hover),
:deep(.el-select__wrapper:hover) {
box-shadow: 0 0 0 1px var(--ink-3, #94A3B8) inset;
}
:deep(.el-input__wrapper.is-focus),
:deep(.el-select__wrapper.is-focused) {
box-shadow: 0 0 0 1px var(--brand, #2563EB) inset, 0 0 0 3px var(--brand-glow, rgba(37, 99, 235, 0.18));
}
/* === 下拉弹层圆角 (跟 input 一致) === */
:deep(.el-select-dropdown),
:deep(.el-select__popper),
:deep(.el-popper.is-light) {
border-radius: var(--radius, 8px) !important;
overflow: hidden;
}
:deep(.el-select-dropdown__item) {
border-radius: var(--radius-sm, 6px);
margin: 2px 4px;
padding: 0 12px;
height: 32px;
line-height: 32px;
font-size: 13px;
}
:deep(.el-select-dropdown__item.is-hovering) {
background: var(--brand-soft, rgba(37, 99, 235, 0.08));
color: var(--brand, #2563EB);
}
:deep(.el-select-dropdown__item.is-selected) {
font-weight: 600;
color: var(--brand, #2563EB);
}
.rr-search-bar {
margin-bottom: 16px;
&__row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
&__field {
display: inline-flex;
align-items: center;
}
&__label {
font-size: 13px;
color: var(--ink-2, #334155);
margin-right: 6px;
}
&__chips {
display: inline-flex;
align-items: center;
gap: 6px;
}
&__chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: var(--brand-soft, rgba(37, 99, 235, 0.08));
color: var(--brand, #2563EB);
border-radius: var(--radius-pill, 9999px);
font-size: 12px;
font-weight: 500;
&-close {
cursor: pointer;
opacity: 0.6;
font-size: 14px;
line-height: 1;
&:hover { opacity: 1; }
}
}
&__actions {
display: inline-flex;
gap: 8px;
margin-left: auto;
:deep(.el-button) {
padding: 7px 16px;
font-weight: 500;
border-radius: var(--radius, 8px);
}
}
}
</style>

View File

@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
// 主题系统(必须在 App 之前 import) // 主题系统(必须在 App 之前 import)
@ -16,5 +17,5 @@ document.body.setAttribute('data-theme', loadTheme())
const app = createApp(App) const app = createApp(App)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
app.use(ElementPlus) app.use(ElementPlus, { locale: zhCn })
app.mount('#app') app.mount('#app')

123
src/tokens.scss Normal file
View File

@ -0,0 +1,123 @@
// F006 设计 Token - 中性色 + 圆角 + 字体 + 阴影(主题无关)
// W5.x v2 Design Language 升级(2026-07-03)
// 改动:
// + --ease / --ease-out 动画曲线
// + --shadow-xl(毛玻璃下层用)
// + --text-xs ~ --text-3xl 8 级字号
// + --topbar-blur 顶栏毛玻璃
// + --brand-gradient 主按钮渐变
// W5.x.x 暗主题适配全局修复(2026-07-03):
// + 25+ 个 EP 文字色/填充色/边框色/popover 背景等派生色变量
// + 4 主题块通过 CSS 级联自动适配(都引用 var(--ink)/var(--surface) 等)
:root {
// ===== 圆角(沿用 v1.3) =====
--radius-sm: 6px;
--radius: 8px;
--radius-lg: 10px;
--radius-xl: 16px;
--radius-pill: 9999px;
// ===== 字体(沿用) =====
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--font-mono: 'JetBrains Mono', 'SF Mono', Menlo, Consolas, monospace;
// ===== 阴影 v2 加宽 =====
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
--shadow-lg: 0 10px 25px rgba(15, 23, 42, 0.08), 0 2px 6px rgba(15, 23, 42, 0.04);
--shadow-xl: 0 20px 60px rgba(15, 23, 42, 0.10), 0 4px 12px rgba(15, 23, 42, 0.06);
// ===== Element Plus 重定向(沿用) =====
--el-color-primary: var(--brand);
--el-color-primary-light-3: var(--brand-soft);
--el-color-primary-light-5: var(--brand-soft);
--el-color-primary-light-7: var(--brand-soft);
--el-color-primary-light-8: var(--brand-soft);
--el-color-primary-light-9: var(--brand-soft);
--el-color-primary-dark-2: var(--brand-hover);
// ===== Element Plus 文字色派生(W5.x.x 暗主题适配全局修复) =====
// 让 el-breadcrumb / el-popover / el-tooltip / el-select / el-input placeholder
// 等所有用 --el-text-color-* 的 EP 组件,文字色都跟主题切换
--el-text-color-primary: var(--ink);
--el-text-color-regular: var(--ink-2);
--el-text-color-secondary: var(--ink-3);
--el-text-color-placeholder: var(--ink-4);
--el-text-color-disabled: var(--ink-4);
// ===== Element Plus 边框色派生 =====
--el-border-color: var(--border);
--el-border-color-light: var(--border);
--el-border-color-lighter: var(--border);
--el-border-color-extra-light: var(--border);
--el-border-color-dark: var(--border-strong);
--el-border-color-darker: var(--border-strong);
// ===== Element Plus 填充色派生 =====
--el-fill-color: var(--bg-soft);
--el-fill-color-light: var(--bg-soft);
--el-fill-color-lighter: var(--surface-2);
--el-fill-color-extra-light: var(--surface-2);
--el-fill-color-dark: var(--surface-2);
--el-fill-color-darker: var(--surface-2);
--el-fill-color-blank: var(--surface);
// ===== Element Plus 背景色派生(popover/tooltip/dropdown/modal 容器) =====
--el-bg-color: var(--surface);
--el-bg-color-page: var(--bg-soft);
--el-bg-color-overlay: var(--surface);
--el-popover-bg-color: var(--surface);
--el-popover-border-color: var(--border);
// ===== Element Plus Popper 容器(W5.x.x+1: 补 W5.x.x 漏的 popper bg) =====
// EP 2.x 弹窗实际读 --el-popper-bg-color-light(不是 --el-popper-bg-color)
// W5.x.x+1 v3 修根因: tokens 全部重定向到主题色,确保 dark 主题下弹窗是深底
--el-popper-bg-color: var(--surface);
--el-popper-bg-color-light: var(--surface);
--el-popper-border-color: var(--border);
--el-popper-title-text-color: var(--ink);
--el-popper-arrow-bg-color: var(--surface);
--el-popper-plain-bg-color: var(--surface);
--el-popper-padding: 12px;
// 弹窗 border 颜色 EP 实际读 --el-border-color-light
--el-border-color-light: var(--border);
--el-border-color-lighter: var(--border);
// 弹窗底层填充色
--el-fill-color-blank: var(--surface);
--el-bg-color-overlay: var(--surface);
--el-tooltip-bg-color: var(--ink);
--el-dropdown-bg-color: var(--surface);
--el-dropdown-border-color: var(--border);
--el-select-dropdown-bg-color: var(--surface);
--el-select-dropdown-border-color: var(--border);
--el-datepicker-bg-color: var(--surface);
--el-dialog-bg-color: var(--surface);
--el-message-bg-color: var(--surface);
--el-notification-bg-color: var(--surface);
// ===== Element Plus 遮罩 + 禁用 =====
--el-mask-color: rgba(0, 0, 0, 0.5);
--el-disabled-bg-color: var(--bg-soft);
--el-disabled-text-color: var(--ink-4);
--el-disabled-border-color: var(--border);
// ===== v2 新增:动画曲线 =====
--ease: cubic-bezier(0.4, 0, 0.2, 1);
--ease-out: cubic-bezier(0.0, 0, 0.2, 1);
// ===== v2 新增:字号 8 级(letter-spacing 收紧) =====
--text-xs: 11.5px;
--text-sm: 12.5px;
--text-base: 13.5px;
--text-md: 14.5px;
--text-lg: 16px;
--text-xl: 20px;
--text-2xl: 24px;
--text-3xl: 28px;
// ===== v2 新增:顶栏毛玻璃模糊半径 =====
--topbar-blur: blur(16px) saturate(1.5);
// ===== v2 新增:主按钮渐变(从主题 brand 自动组合) =====
--brand-gradient: linear-gradient(135deg, var(--brand), var(--brand-2));
}