admin_new P0+P1 全批次合并 (84 commits) #2

Merged
direv merged 85 commits from develop into main 2026-08-07 11:27:17 +08:00
5 changed files with 200 additions and 333 deletions
Showing only changes of commit fe07612f29 - Show all commits

View File

@ -1,41 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 通用 DataTable 组件 * F006 通用 DataTable 组件 (W17 设计规范 v2 整改)
* W3.3:抽取自 InquiryListView + DashboardView 近期询价表 *
* 按 ui-preview-v2.html 模板:
* - 表头 text-transform: uppercase + 左对齐
* - 单元格紧凑 padding 12px 16px + 左对齐
* - 颜色全用 var(--xxx) 主题化
* - 表格外无卡片包裹
* *
* 用法: * 用法:
* <DataTable * <DataTable
* :columns="[ * :columns="[
* { prop: 'id', label: '单号', width: 180 }, * { prop: 'id', label: '单号', width: 180 },
* { prop: 'customer', label: '客户', minWidth: 180 },
* { prop: 'status', label: '状态', width: 110, type: 'status',
* statusMap: { pending: '待报价', quoting: '报价中', done: '已成交', cancelled: '已取消' } },
* { label: '操作', width: 100, slot: 'action' } * { label: '操作', width: 100, slot: 'action' }
* ]" * ]"
* :data="list" * :data="list" :loading="loading" :total="total" :page="page" :limit="limit"
* :loading="loading"
* :total="total"
* :page="page"
* :limit="limit"
* :show-pagination="true"
* row-key="id"
* @row-click="onRowClick"
* @page-change="onPageChange" * @page-change="onPageChange"
* > * >
* <template #action="{ row }"> * <template #action="{ row }">
* <el-button text type="primary" size="small" @click.stop="onView(row)">查看</el-button> * <el-button text type="primary" size="small">查看</el-button>
* </template> * </template>
* </DataTable> * </DataTable>
*
* 列定义支持:
* - prop: 字段名
* - label: 列标题
* - width / minWidth: 列宽
* - type: 'status' 自动渲染 el-tag(需 statusMap)
* - statusMap: { value: '中文标签' }
* - slot: 自定义插槽名
* - formatter: (row) => string 自定义格式化
* - align: 对齐方式
*/ */
import { computed } from 'vue' import { computed } from 'vue'
@ -49,6 +34,7 @@ interface Column {
slot?: string slot?: string
formatter?: (row: any) => string formatter?: (row: any) => string
align?: 'left' | 'center' | 'right' align?: 'left' | 'center' | 'right'
fixed?: 'left' | 'right' | true
} }
interface Props { interface Props {
@ -59,7 +45,6 @@ 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
@ -77,7 +62,7 @@ const props = withDefaults(defineProps<Props>(), {
showPagination: true, showPagination: true,
pageSizes: () => [10, 20, 50, 100], pageSizes: () => [10, 20, 50, 100],
rowKey: 'id', rowKey: 'id',
stripe: true, stripe: false,
borderless: false, borderless: false,
emptyText: '暂无数据', emptyText: '暂无数据',
errorText: '', errorText: '',
@ -100,49 +85,44 @@ function getStatusMeta(col: Column, value: string) {
} }
function onSelectionChange(rows: any[]) { emit('selection-change', rows) } function onSelectionChange(rows: any[]) { emit('selection-change', rows) }
function onRowClick(row: any) { function onRowClick(row: any) { emit('row-click', row) }
emit('row-click', row) function onPageChange(p: number) { emit('page-change', p) }
} function onLimitChange(l: number) { emit('limit-change', l) }
function onPageChange(p: number) {
emit('page-change', p)
}
function onLimitChange(l: number) {
emit('limit-change', l)
}
</script> </script>
<template> <template>
<div class="rr-data-table" :class="{ borderless, carded }"> <div class="rr-data-table" :class="{ borderless }">
<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%" :cell-class-name="'rr-data-cell'" style="width: 100%"
@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' }"
:header-cell-style="{ :header-cell-style="{
background: 'var(--rr-table-header-bg, #F8FAFC)', background: 'var(--rr-table-header-bg, #F8FAFC)',
color: 'var(--rr-text-secondary, #475569)', color: 'var(--rr-text-secondary, #64748B)',
fontWeight: '600', fontWeight: '600',
fontSize: '12.5px', fontSize: '12px',
padding: '10px 14px', padding: '11px 16px',
borderBottom: '1px solid var(--rr-border-color, #E2E8F0)', borderBottom: '1px solid var(--rr-border-color, #E2E8F0)',
lineHeight: '18.75px', textTransform: 'uppercase',
textAlign: 'center', letterSpacing: '0.04em',
whiteSpace: 'nowrap',
textAlign: 'left',
height: '40px' height: '40px'
}" }"
:cell-style="{ :cell-style="{
padding: '0 14px', padding: '12px 16px',
borderBottom: '1px solid var(--rr-border-color, #E2E8F0)', borderBottom: '1px solid var(--rr-border-color, #E2E8F0)',
fontSize: '13.5px', color: 'var(--rr-text-secondary, #334155)',
lineHeight: '20.25px', fontSize: '13px',
color: 'var(--rr-text-primary, #0F172A)', textAlign: 'left',
height: '52px' verticalAlign: 'middle'
}" }"
> >
<template v-for="(col, idx) in columns" :key="idx"> <template v-for="(col, idx) in columns" :key="idx">
@ -150,7 +130,8 @@ function onLimitChange(l: number) {
v-if="col.type === 'selection'" v-if="col.type === 'selection'"
type="selection" type="selection"
:width="col.width || 50" :width="col.width || 50"
:align="col.align || 'center'" :align="col.align || 'left'"
:fixed="col.fixed"
/> />
<el-table-column <el-table-column
v-else v-else
@ -158,7 +139,8 @@ function onLimitChange(l: number) {
:label="col.label" :label="col.label"
:width="col.width" :width="col.width"
:min-width="col.minWidth" :min-width="col.minWidth"
:align="col.align || 'center'" :align="col.align || 'left'"
:fixed="col.fixed"
:show-overflow-tooltip="true" :show-overflow-tooltip="true"
> >
<template v-if="col.slot" #default="{ row }"> <template v-if="col.slot" #default="{ row }">
@ -178,129 +160,66 @@ function onLimitChange(l: number) {
</template> </template>
</el-table-column> </el-table-column>
</template> </template>
<template #empty> <template #empty>
<div class="rr-empty"> <div class="rr-empty">
<span v-if="error">{{ error }}</span> <span v-if="error" class="rr-empty__error">{{ error || errorText }}</span>
<span v-else>{{ emptyText }}</span> <span v-else>{{ emptyText }}</span>
</div> </div>
</template> </template>
</el-table> </el-table>
<div v-if="showPager" class="rr-pager">
<div v-if="showPager" class="rr-pagination"> <div class="rr-pager__info">
<span class="rr-pagination-total">共 {{ total }} 条记录</span> 共 {{ total }} 条
</div>
<el-pagination <el-pagination
background
layout="prev, pager, next"
:total="total"
:page-size="limit"
:current-page="page" :current-page="page"
:page-sizes="[10, 20, 50, 100]" :page-size="limit"
:prev-text="'上一页'" :total="total"
:next-text="'下一页'" :page-sizes="pageSizes"
:pager-count="5" layout="sizes, prev, pager, next, jumper"
background
@current-change="onPageChange" @current-change="onPageChange"
class="rr-pager" @size-change="onLimitChange"
/> />
<span class="rr-pagination-size">
每页
<el-select :model-value="limit" size="small" @change="onLimitChange" class="rr-page-size-select">
<el-option v-for="s in pageSizes" :key="s" :label="`${s} 行`" :value="s" />
</el-select>
</span>
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.rr-data-table { .rr-data-table {
background: var(--surface); width: 100%;
border: 1px solid var(--border); // 行 hover 高亮
border-radius: var(--radius-lg); :deep(.el-table__row) {
overflow: hidden; transition: background 0.1s;
&.borderless { }
border: 0; :deep(.el-table__row:hover > td.el-table__cell) {
background-color: var(--rr-row-hover, #F8FAFC) !important;
}
// 隐藏全局边线(模板的 table-card 在外层,这里走全局)
&.borderless :deep(.el-table__inner-wrapper) {
border-radius: 0; border-radius: 0;
} }
} }
.rr-clickable-row { cursor: pointer; }
.rr-clickable-row:hover > td { background: var(--bg-soft) !important; }
.rr-empty { .rr-empty {
padding: 32px 0;
color: var(--ink-3);
font-size: 13px;
text-align: center; text-align: center;
padding: 32px 0;
color: var(--ink-3, #94A3B8);
font-size: 13px;
&__error {
color: var(--red, #EF4444);
}
} }
.rr-pagination { .rr-pager {
padding: 11px 14px;
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
border-top: 1px solid var(--border); justify-content: space-between;
color: var(--ink-3); padding: 14px 16px;
font-size: 12.5px; font-size: 12.5px;
gap: 8px; color: var(--ink-3, #94A3B8);
} background: var(--rr-bg-card, #FFFFFF);
.rr-pagination-total { border: 1px solid var(--rr-border-color, #E2E8F0);
font-weight: 400; border-top: none;
color: var(--ink-3); border-radius: 0 0 8px 8px;
} &__info { color: var(--ink-3, #94A3B8); }
.rr-pagination-unit {
font-size: 12.5px;
color: var(--ink-3);
margin-left: -4px;
}
.rr-pagination :deep(.el-pagination) {
flex: 1;
justify-content: flex-end;
}
.rr-pagination :deep(.el-pagination .el-pagination__sizes .el-select .el-input__inner) {
font-size: 12.5px;
}
.rr-pagination-size {
display: inline-flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--ink-3);
flex-shrink: 0;
}
.rr-page-size-select {
width: 80px;
}
.rr-page-size-select :deep(.el-input__wrapper) {
background: var(--surface);
box-shadow: 0 0 0 1px var(--border) inset;
border-radius: 8px;
}
/* 分页器全部按钮 12px(原 14px 太大) + 圆角 + 紧凑间距 */
:deep(.rr-pager .btn-prev),
:deep(.rr-pager .btn-next),
:deep(.rr-pager .el-pager li) {
font-size: 12px !important;
min-width: 28px !important;
height: 28px !important;
line-height: 28px !important;
border-radius: 50% !important;
padding: 0 !important;
}
:deep(.rr-pager .btn-prev),
:deep(.rr-pager .btn-next) {
padding: 0 8px !important;
border-radius: 14px !important;
}
:deep(.rr-pager .el-pager li) {
margin: 0 2px !important;
}
:deep(.rr-pager) {
gap: 4px;
}
:deep(.rr-pager .el-pager li.is-active) {
border-radius: 50% !important;
}
/* 单元格 nowrap 截断(套设计稿 RFQ-20668…) */
:deep(.el-table .cell) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
</style> </style>

View File

@ -1,19 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 通用 ListTabs 组件 (W17 设计规范整改) * F006 通用 ListTabs 组件 (W17 设计规范 v2 整改)
* *
* 状态 tab + 数字 badge, 完全按设计规范: * 按 ui-preview-v2.html 模板: padding 9px 16px + 下划线高亮 + tab-count 圆角 pill
* 全部 128 待报价 23 进行中 45 已完成 52 已取消 8
* *
* 用法: * 全部 [128] 待报价 [23] 进行中 [45] 已完成 [52] 已取消 [8]
* <ListTabs
* v-model="activeTab"
* :items="[
* { key: 'all', label: '全部', count: 128 },
* { key: 'pending', label: '待报价', count: 23 },
* ...
* ]"
* />
*/ */
export interface ListTabItem { export interface ListTabItem {
key: string key: string
@ -57,61 +48,47 @@ function onClick(key: string) {
.rr-list-tabs { .rr-list-tabs {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 32px; gap: 0;
padding: 0 0 12px 0; margin-bottom: 14px;
border-bottom: 1px solid var(--rr-border-color, #E2E8F0); border-bottom: 1px solid var(--border, #E2E8F0);
margin-bottom: 16px;
} }
.rr-list-tab { .rr-list-tab {
padding: 9px 16px;
font-size: 13px;
color: var(--ink-3, #64748B);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
font-weight: 500;
position: relative;
user-select: none;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; 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 { &:hover { color: var(--ink, #0F172A); }
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 { &.is-active {
color: var(--rr-color-primary, #2563EB); color: var(--brand, #2563EB);
font-weight: 600; font-weight: 600;
border-bottom-color: var(--brand, #2563EB);
&::after {
background: var(--rr-color-primary, #2563EB);
}
}
&__label {
line-height: 1;
} }
&__count { &__count {
font-size: 12px; font-size: 11px;
color: var(--rr-text-tertiary, #94A3B8); background: var(--surface-2, #F1F5F9);
font-weight: 400; color: var(--ink-4, #94A3B8);
line-height: 1; padding: 0 6px;
border-radius: 999px;
font-weight: 500;
line-height: 18px;
min-width: 20px;
text-align: center;
} }
&.is-active &__count { &.is-active &__count {
color: var(--rr-color-primary, #2563EB); background: var(--brand-soft, rgba(37, 99, 235, 0.08));
color: var(--brand, #2563EB);
} }
} }
</style> </style>

View File

@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 通用 PageHeader 组件 (W17 设计规范整改) * F006 通用 PageHeader 组件 (W17 设计规范 v2 整改)
* *
* 页面头: 卡片化 (白底圆角阴影) + 标题 + 副标题 + 操作按钮区 * 页面头: 标题 + 副标题 + 操作按钮区 (按 ui-preview-v2.html 模板, 无卡片化)
* *
* 用法: * 用法:
* <PageHeader title="订单管理" :sub="`共 ${total} 条`"> * <PageHeader title="订单管理" :sub="`共 ${total} 条`">
@ -13,16 +13,14 @@
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" :class="{ 'is-bordered': bordered }"> <div class="rr-page-header">
<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>
@ -36,14 +34,8 @@ withDefaults(defineProps<Props>(), {
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page-header { .rr-page-header {
display: flex; display: flex;
align-items: center;
justify-content: space-between; 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; margin-bottom: 16px;
} }
.rr-page-header-text { .rr-page-header-text {
@ -51,16 +43,17 @@ withDefaults(defineProps<Props>(), {
min-width: 0; min-width: 0;
} }
.rr-page-title { .rr-page-title {
font-size: 18px; font-size: 20px;
font-weight: 600; font-weight: 700;
color: var(--rr-text-primary, #0F172A); color: var(--ink, #0F172A);
margin: 0 0 4px 0; margin: 0;
line-height: 1.3; line-height: 1.3;
letter-spacing: -0.02em;
} }
.rr-page-sub { .rr-page-sub {
color: var(--rr-text-tertiary, #94A3B8);
font-size: 12.5px; font-size: 12.5px;
margin: 0; color: var(--ink-3, #64748B);
margin: 2px 0 0 0;
line-height: 1.4; line-height: 1.4;
} }
.rr-page-actions { .rr-page-actions {

View File

@ -1,30 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 通用 SearchBar 组件 (W17 设计规范整改) * F006 通用 SearchBar 组件 (W17 设计规范 v2 整改)
* *
* 标准搜索条: 紧凑布局 + 已选 chip 标签 + 查询/重置按钮 * 按 ui-preview-v2.html 模板: 紧凑布局 + 无 label + 同行末尾 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: [...] }
* { key: 'date', label: '日期', type: 'daterange' }
* ]" * ]"
* @search="onSearch" * @search="onSearch"
* @reset="onReset" * @reset="onReset"
* /> * />
*
* 字段类型:
* - text: el-input
* - select: el-select + options
* - date: el-date-picker (单日)
* - daterange: el-date-picker type=datertange
* - 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
@ -39,6 +30,7 @@ export interface SearchField {
options?: SearchFieldOption[] options?: SearchFieldOption[]
width?: number | string width?: number | string
clearable?: boolean clearable?: boolean
showLabel?: boolean
} }
interface Props { interface Props {
@ -46,11 +38,13 @@ interface Props {
fields: SearchField[] fields: SearchField[]
showReset?: boolean showReset?: boolean
showChips?: boolean showChips?: boolean
showLabel?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
showReset: true, showReset: true,
showChips: true showChips: true,
showLabel: false
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@ -65,22 +59,15 @@ 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') localFilters.value[f.key] = []
localFilters.value[f.key] = [] else if (f.type === 'number') localFilters.value[f.key] = undefined
} else if (f.type === 'number') { else localFilters.value[f.key] = ''
localFilters.value[f.key] = undefined
} else {
localFilters.value[f.key] = ''
}
} }
emit('reset') emit('reset')
} }
function onEnter() { onSearch() } function onEnter() { onSearch() }
function onClearField(key: string) { function onClearField(key: string) {
const f = props.fields.find(it => it.key === key) const f = props.fields.find(it => it.key === key)
if (!f) return if (!f) return
@ -89,7 +76,6 @@ function onClearField(key: string) {
else localFilters.value[key] = '' else localFilters.value[key] = ''
} }
// 提取已选 chip
const activeChips = computed(() => { const activeChips = computed(() => {
return props.fields return props.fields
.map(f => { .map(f => {
@ -116,7 +102,7 @@ const activeChips = computed(() => {
<div class="rr-search-bar"> <div class="rr-search-bar">
<div class="rr-search-bar__row"> <div class="rr-search-bar__row">
<div class="rr-search-bar__field" v-for="f in fields" :key="f.key"> <div class="rr-search-bar__field" v-for="f in fields" :key="f.key">
<span class="rr-search-bar__label">{{ f.label }}</span> <span v-if="showLabel || f.showLabel" class="rr-search-bar__label">{{ f.label }}</span>
<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]"
@ -130,7 +116,7 @@ const activeChips = computed(() => {
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 || 180 }" :style="{ width: f.width || 160 }"
> >
<el-option <el-option
v-for="opt in f.options || []" v-for="opt in f.options || []"
@ -164,86 +150,69 @@ const activeChips = computed(() => {
:style="{ width: f.width || 180 }" :style="{ width: f.width || 180 }"
/> />
</div> </div>
<div class="rr-search-bar__actions"> <div class="rr-search-bar__chips" v-if="showChips && activeChips.length">
<el-button type="primary" @click="onSearch">查询</el-button> <div
<el-button v-if="showReset" @click="onReset">重置</el-button> 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" size="small" @click="onSearch">查询</el-button>
<el-button v-if="showReset" size="small" @click="onReset">重置</el-button>
</div> </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>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.rr-search-bar { .rr-search-bar {
padding: 16px 20px; margin-bottom: 14px;
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 { &__row {
display: flex; display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
} }
&__field { &__field {
display: flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px;
} }
&__label { &__label {
font-size: 13px; font-size: 13px;
color: var(--rr-text-secondary, #475569); color: var(--ink-2, #334155);
white-space: nowrap; margin-right: 6px;
flex-shrink: 0; }
&__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: 999px;
font-size: 12px;
font-weight: 500;
&-close {
cursor: pointer;
opacity: 0.6;
font-size: 14px;
line-height: 1;
&:hover { opacity: 1; }
}
} }
&__actions { &__actions {
display: flex; display: inline-flex;
gap: 8px; gap: 8px;
margin-left: auto; 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,18 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 用户管理 (前台用户列表) - W17 设计规范完整版 * F006 用户管理 (前台用户列表) - W17 设计规范 v2 完整版
* *
* 9 列 + 6 搜索 + 4 操作, 符合设计规范: * 按 ui-preview-v2.html 模板: 9 列 + 6 搜索 + 4 操作 + 状态 tab
* - 状态 chip (是否企业认证 / 禁用标识)
* - 操作列 el-link + 竖线分隔
* - 白卡片化表格
*/ */
import { onMounted, ref, reactive } from 'vue' import { onMounted, ref, reactive, computed } from 'vue'
import { httpGet, httpPost } from '@/utils/request' import { httpGet, httpPost } from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue' import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue' import SearchBar from '@/components/SearchBar/SearchBar.vue'
import ListTabs from '@/components/ListTabs/ListTabs.vue'
interface FrontUser { interface FrontUser {
id: string id: string
@ -31,7 +29,9 @@ interface FrontUser {
const list = ref<FrontUser[]>([]) const list = ref<FrontUser[]>([])
const total = ref(0) const total = ref(0)
const enabledStats = reactive({ normal: 0, disabled: 0 })
const loading = ref(false) const loading = ref(false)
const activeTab = ref('all')
const query = reactive({ const query = reactive({
page: 1, page: 1,
limit: 20, limit: 20,
@ -117,6 +117,9 @@ async function load() {
raw: it raw: it
})) }))
total.value = Number(resp.total || 0) total.value = Number(resp.total || 0)
// 统计 (在当前搜索结果中)
enabledStats.normal = list.value.filter(u => u.enabled === 1).length
enabledStats.disabled = list.value.filter(u => u.enabled === 0).length
} else { } else {
list.value = [] list.value = []
total.value = 0 total.value = 0
@ -130,11 +133,26 @@ async function load() {
} }
} }
const tabItems = computed(() => [
{ key: 'all', label: '全部', count: total.value },
{ key: 'normal', label: '正常', count: enabledStats.normal },
{ key: 'disabled', label: '已禁用', count: enabledStats.disabled }
])
function onTabChange(key: string) {
if (key === 'all') query.enabled = ''
else if (key === 'normal') query.enabled = '1'
else if (key === 'disabled') query.enabled = '0'
query.page = 1
load()
}
function onPageChange(p: number) { query.page = p; load() } function onPageChange(p: number) { query.page = p; load() }
function onSearch() { query.page = 1; load() } function onSearch() { query.page = 1; load() }
function onReset() { function onReset() {
query.userId = ''; query.nickName = ''; query.mobile = '' query.userId = ''; query.nickName = ''; query.mobile = ''
query.enterpriseName = ''; query.isEnterprise = ''; query.enabled = '' query.enterpriseName = ''; query.isEnterprise = ''; query.enabled = ''
activeTab.value = 'all'
query.page = 1 query.page = 1
load() load()
} }
@ -147,12 +165,8 @@ async function toggleEnabled(row: FrontUser) {
const next = row.enabled === 1 ? 0 : 1 const next = row.enabled === 1 ? 0 : 1
const action = next === 1 ? '启用' : '禁用' const action = next === 1 ? '启用' : '禁用'
try { try {
await ElMessageBox.confirm(`确认${action}用户「${row.nickName}」?`, `${action}确认`, { await ElMessageBox.confirm(`确认${action}用户「${row.nickName}」?`, `${action}确认`, { type: 'warning' })
type: 'warning' } catch { return }
})
} catch {
return
}
try { try {
await httpPost('/user/user/update', { id: row.id, enabled: next }) await httpPost('/user/user/update', { id: row.id, enabled: next })
ElMessage.success(`${action}成功`) ElMessage.success(`${action}成功`)
@ -164,12 +178,8 @@ async function toggleEnabled(row: FrontUser) {
async function deleteUser(row: FrontUser) { async function deleteUser(row: FrontUser) {
try { try {
await ElMessageBox.confirm(`确认删除用户「${row.nickName}」?该操作不可恢复`, '删除确认', { await ElMessageBox.confirm(`确认删除用户「${row.nickName}」?该操作不可恢复`, '删除确认', { type: 'warning' })
type: 'warning' } catch { return }
})
} catch {
return
}
try { try {
await httpPost('/user/user/delete', { id: row.id }) await httpPost('/user/user/delete', { id: row.id })
ElMessage.success('删除成功') ElMessage.success('删除成功')
@ -185,21 +195,21 @@ 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 @click="ElMessageBox.alert('导出功能待 W17 完善', '提示', { type: 'info' })">导出</el-button>
<el-button type="primary" @click="ElMessageBox.alert('新增用户功能待 W17 完善', '提示', { type: 'info' })">新增</el-button> <el-button type="primary" @click="ElMessageBox.alert('新增用户功能待 W17 完善', '提示', { type: 'info' })">+ 新增用户</el-button>
</PageHeader> </PageHeader>
<ListTabs v-model="activeTab" :items="tabItems" @change="onTabChange" />
<SearchBar <SearchBar
v-model:filters="query" v-model:filters="query"
:fields="[ :fields="[
{ key: 'userId', label: '用户ID', type: 'text', placeholder: '用户ID' }, { key: 'userId', label: '用户ID', type: 'text', placeholder: '用户ID' },
{ key: 'nickName', label: '用户昵称', type: 'text', placeholder: '用户昵称' }, { key: 'nickName', label: '用户昵称', type: 'text', placeholder: '用户昵称' },
{ key: 'mobile', label: '手机号', type: 'text', placeholder: '手机号' }, { key: 'mobile', label: '手机号', type: 'text', placeholder: '手机号' },
{ key: 'isEnterprise', label: '是否企业认证', type: 'select', placeholder: '是否企业认证', { key: 'isEnterprise', label: '是否企业认证', type: 'select', placeholder: '所有认证',
options: [{ label: '是', value: '1' }, { label: '否', value: '0' }] }, options: [{ label: '已认证', value: '1' }, { label: '未认证', value: '0' }] },
{ key: 'enterpriseName', label: '企业名称', type: 'text', placeholder: '企业名称' }, { key: 'enterpriseName', label: '企业名称', type: 'text', placeholder: '企业名称' }
{ key: 'enabled', label: '禁用标识', type: 'select', placeholder: '禁用标识',
options: [{ label: '正常', value: '0' }, { label: '禁用', value: '1' }] }
]" ]"
@search="onSearch" @search="onSearch"
@reset="onReset" @reset="onReset"
@ -214,7 +224,7 @@ onMounted(load)
: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" :borderless="true"
row-key="id" row-key="id"
empty-text="暂无用户" empty-text="暂无用户"
@page-change="onPageChange" @page-change="onPageChange"
@ -240,7 +250,6 @@ onMounted(load)
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 100%; } .rr-page { max-width: 100%; }
.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;