Compare commits

..

No commits in common. "857cec429050d31ae2326a888148f57c759542ce" and "67436089a99c8497ba90b24191a7e9e7dce2492c" have entirely different histories.

94 changed files with 2166 additions and 6757 deletions

5
.gitignore vendored
View File

@ -8,8 +8,3 @@ auto-imports.d.ts
components.d.ts components.d.ts
.env.local .env.local
.env.*.local .env.*.local
# 构建产物 (W17.4.6 补)
*.tsbuildinfo
vite.config.d.ts
vite.config.js

View File

@ -60,56 +60,6 @@ function isActive(path: string): boolean {
return path === '/' ? route.path === '/' : route.path.startsWith(path) return path === '/' ? route.path === '/' : route.path.startsWith(path)
} }
/**
* 面包屑: 从当前 route.path 在菜单树里 DFS 追溯完整父级链
* 返回 [一级目录, ..., 末级菜单], 不含"工作台"前缀(template 里单独拼)
* 找不到时 fallback 到 route.meta.title
*/
const breadcrumbItems = computed<string[]>(() => {
const currentPath = route.path
// 工作台首页不显示中间层级
if (currentPath === '/' || currentPath === '/dashboard') return []
// DFS 找父级链: 父菜单(有 children)的 path 是第一个子菜单的 path(见
// resolveMenuItem), 不能用 path 前缀短路命中父菜单, 必须先递归子树,
// 子树没命中才考虑父菜单自身。
function findChain(items: MenuItem[], target: string, chain: MenuItem[]): boolean {
for (const item of items) {
// 叶子节点: path 精确匹配
if (!item.children?.length) {
if (item.path === target) {
chain.push(item)
return true
}
continue
}
// 父节点: 先递归子树
chain.push(item)
if (findChain(item.children, target, chain)) return true
chain.pop()
// 子树没命中, 看父节点自身 path 是否是 target 的前缀(详情页场景)
if (item.path !== '/' && target.startsWith(item.path)) {
chain.push(item)
return true
}
}
return false
}
const chain: MenuItem[] = []
findChain(menuList.value, currentPath, chain)
const metaTitle = route.meta?.title as string | undefined
if (chain.length) {
const titles: string[] = chain.map(i => i.title)
const last = chain[chain.length - 1]
if (last.path !== currentPath) {
// 详情页: 命中的是父前缀, 追加 meta.title
return metaTitle ? [...titles, metaTitle] : titles
}
return titles
}
// 菜单树没命中 (如 /inquiry/:id 详情页), 用 meta.title 兜底
return metaTitle ? [metaTitle] : []
})
const PARENT_GROUP: Record<string, string> = { const PARENT_GROUP: Record<string, string> = {
'概览': '概览', '概览': '概览',
'业务': '业务', '业务': '业务',
@ -274,8 +224,7 @@ const badges = ref<Record<string, string>>({})
async function loadBadges() { async function loadBadges() {
try { try {
const data = await fetch('/sys/openapi/inquiry/list?page=1&limit=1', { const data = await fetch('/sys/openapi/inquiry/list?page=1&limit=1', {
headers: { Authorization: `Bearer ${userStore.token}` }, headers: { Authorization: `Bearer ${userStore.token}` }
credentials: 'omit'
}).then(r => r.json()).catch(() => null) }).then(r => r.json()).catch(() => null)
if (data?.code === 0) { if (data?.code === 0) {
const total = data.total ?? data.data?.length ?? 0 const total = data.total ?? data.data?.length ?? 0
@ -379,7 +328,7 @@ onMounted(loadBadges)
</div> </div>
<el-breadcrumb separator=">" class="rr-topbar-breadcrumb"> <el-breadcrumb separator=">" class="rr-topbar-breadcrumb">
<el-breadcrumb-item :to="{ path: '/' }">工作台</el-breadcrumb-item> <el-breadcrumb-item :to="{ path: '/' }">工作台</el-breadcrumb-item>
<el-breadcrumb-item v-for="item in breadcrumbItems" :key="item">{{ item }}</el-breadcrumb-item> <el-breadcrumb-item>{{ route.meta?.title || '' }}</el-breadcrumb-item>
</el-breadcrumb> </el-breadcrumb>
</div> </div>
<div class="rr-topbar-right"> <div class="rr-topbar-right">

View File

@ -1,40 +1,54 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 通用 DataTable 组件 (W17 设计规范 v2 整改) * F006 通用 DataTable 组件
* * 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" :loading="loading" :total="total" :page="page" :limit="limit" * :data="list"
* :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">查看</el-button> * <el-button text type="primary" size="small" @click.stop="onView(row)">查看</el-button>
* </template> * </template>
* </DataTable> * </DataTable>
*
* 列定义支持:
* - prop: 字段名
* - label: 列标题
* - width / minWidth: 列宽
* - type: 'status' 自动渲染 el-tag(需 statusMap)
* - statusMap: { value: '中文标签' }
* - slot: 自定义插槽名
* - formatter: (row) => string 自定义格式化
* - align: 对齐方式
*/ */
import { computed, nextTick, ref, watch, onMounted } from 'vue' import { computed } from 'vue'
interface Column { interface Column {
prop?: string prop?: string
label: string label: string
width?: number | string width?: number | string
minWidth?: number | string minWidth?: number | string
type?: 'status' | 'text' | 'selection' type?: 'status' | 'text'
statusMap?: Record<string, { label: string; type: string }> statusMap?: Record<string, { label: string; type: string }>
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 {
@ -58,11 +72,11 @@ const props = withDefaults(defineProps<Props>(), {
loading: false, loading: false,
total: 0, total: 0,
page: 1, page: 1,
limit: 10, // W17.3.5 默认每页 10 条 (按用户原话) limit: 20,
showPagination: true, showPagination: true,
pageSizes: () => [10, 20, 50, 100], pageSizes: () => [10, 20, 50, 100],
rowKey: 'id', rowKey: 'id',
stripe: false, stripe: true,
borderless: false, borderless: false,
emptyText: '暂无数据', emptyText: '暂无数据',
errorText: '', errorText: '',
@ -73,7 +87,6 @@ const emit = defineEmits<{
(e: 'row-click', row: any): void (e: 'row-click', row: any): void
(e: 'page-change', page: number): void (e: 'page-change', page: number): void
(e: 'limit-change', limit: 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 hasStatusCol = computed(() => props.columns.some(c => c.type === 'status'))
@ -84,53 +97,19 @@ function getStatusMeta(col: Column, value: string) {
return col.statusMap?.[value] || { label: value || '--', type: 'info' } return col.statusMap?.[value] || { label: value || '--', type: 'info' }
} }
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)
// W17.8.2: 表格自动撑满 (列总宽 < 容器宽时, 给弹性列加 minWidth 填满差额) }
// W17.8.2 改: 差额加到"中间弹性列" (第一个有 minWidth 且非 fixed 的列), 不是最后一列 function onLimitChange(l: number) {
// 原因: 最后一列通常是"操作列" (fixed: right, width 固定), 加差额会撑大操作列破坏布局 emit('limit-change', l)
// 解决大视口下列少导致右侧大面积空白
const tableRef = ref<HTMLElement | null>(null)
const lastColMinWidth = ref<number>(0)
const flexColIdx = computed(() => {
// 优先找第一个有 minWidth 且非 fixed 的数据列 (不是 selection/status)
const idx = props.columns.findIndex(c => c.minWidth && !c.fixed && c.type !== 'selection' && c.type !== 'status')
return idx >= 0 ? idx : props.columns.length - 1
})
async function autoFillTable() {
await nextTick()
// 双重 nextTick 等待列渲染
await nextTick()
const el = tableRef.value
if (!el) return
const table = el.querySelector('.el-table') as HTMLElement | null
if (!table) return
const containerW = el.clientWidth
const tableW = table.scrollWidth
if (tableW >= containerW - 4) {
lastColMinWidth.value = 0
return
}
// 差额 + 一些 buffer (避免 border 抖动)
const diff = containerW - tableW + 4
lastColMinWidth.value = diff
} }
onMounted(() => {
autoFillTable()
window.addEventListener('resize', autoFillTable)
})
watch(() => props.data, () => autoFillTable(), { flush: 'post' })
watch(() => props.columns, () => autoFillTable(), { flush: 'post' })
</script> </script>
<template> <template>
<div ref="tableRef" class="rr-data-table" :class="{ borderless }"> <div class="rr-data-table" :class="{ borderless }">
<el-table <el-table
:data="data" :data="data"
:loading="loading" :loading="loading"
@ -139,51 +118,41 @@ watch(() => props.columns, () => autoFillTable(), { flush: 'post' })
:row-key="rowKey" :row-key="rowKey"
style="width: 100%" style="width: 100%"
@row-click="onRowClick" @row-click="onRowClick"
@selection-change="onSelectionChange"
row-class-name="rr-clickable-row" row-class-name="rr-clickable-row"
:header-row-style="{ height: '48px' }" :header-row-style="{ height: '40px' }"
:row-style="{ height: '52px' }"
:header-cell-style="{ :header-cell-style="{
background: 'var(--surface-2)', background: '#F8FAFC',
color: 'var(--ink-3)', color: '#475569',
fontWeight: '600', fontWeight: '600',
fontSize: '12px', fontSize: '12.5px',
padding: '14px 16px', padding: '10px 14px',
borderBottom: '1px solid var(--border)', borderBottom: '1px solid #E2E8F0',
textTransform: 'uppercase', lineHeight: '18.75px',
letterSpacing: '0.04em',
whiteSpace: 'nowrap',
textAlign: 'center', textAlign: 'center',
lineHeight: '20px' height: '40px'
}" }"
:cell-style="{ :cell-style="{
padding: '10px 16px', padding: '0 14px',
borderBottom: '1px solid var(--border)', borderBottom: '1px solid #E2E8F0',
color: 'var(--ink-2)', fontSize: '13.5px',
fontSize: '13px', lineHeight: '20.25px',
textAlign: 'left', color: '#0F172A',
verticalAlign: 'middle' height: '52px'
}" }"
> >
<template v-for="(col, idx) in columns" :key="idx">
<el-table-column <el-table-column
v-if="col.type === 'selection'" v-for="(col, idx) in columns"
type="selection" :key="idx"
: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" :prop="col.type === 'status' || col.slot ? undefined : col.prop"
:label="col.label" :label="col.label"
:width="col.width" :width="col.width"
:min-width="idx === flexColIdx && lastColMinWidth > 0 ? Math.max(Number(col.minWidth) || 0, lastColMinWidth) : col.minWidth" :min-width="col.minWidth"
:align="col.align || 'left'" :align="col.align || 'center'"
:fixed="col.fixed"
:show-overflow-tooltip="true" :show-overflow-tooltip="true"
> >
<template v-if="col.slot" #default="{ row, $index }"> <template v-if="col.slot" #default="{ row }">
<slot :name="col.slot" :row="row" :index="$index" /> <slot :name="col.slot" :row="row" />
</template> </template>
<template v-else-if="col.type === 'status'" #default="{ row }"> <template v-else-if="col.type === 'status'" #default="{ row }">
<el-tag <el-tag
@ -198,110 +167,129 @@ watch(() => props.columns, () => autoFillTable(), { flush: 'post' })
{{ col.formatter?.(row) }} {{ col.formatter?.(row) }}
</template> </template>
</el-table-column> </el-table-column>
</template>
<template #empty> <template #empty>
<div class="rr-empty"> <div class="rr-empty">
<span v-if="error" class="rr-empty__error">{{ error || errorText }}</span> <span v-if="error">{{ error }}</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 class="rr-pager__info"> <div v-if="showPager" class="rr-pagination">
共 {{ total }} 条 <span class="rr-pagination-total">共 {{ total }} 条记录</span>
</div>
<el-pagination <el-pagination
:current-page="page"
:page-size="limit"
:total="total"
:page-sizes="pageSizes"
:pager-count="5"
layout="sizes, prev, pager, next, jumper"
background background
layout="prev, pager, next"
:total="total"
:page-size="limit"
:current-page="page"
:page-sizes="[10, 20, 50, 100]"
:prev-text="'上一页'"
:next-text="'下一页'"
:pager-count="5"
@current-change="onPageChange" @current-change="onPageChange"
@size-change="onLimitChange" class="rr-pager"
/> />
<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 {
width: 100%;
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: var(--radius-lg);
overflow: hidden; 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(--bg-soft) !important;
}
// 表格本体圆角配合容器
:deep(.el-table) {
border-radius: 10px 10px 0 0;
overflow: hidden;
}
:deep(.el-table__inner-wrapper) {
border-radius: 10px 10px 0 0;
}
// 表头单元格强制不换行 (兜底, header-cell-style 的 whiteSpace 在某些列宽挤压下失效)
:deep(.el-table__header-wrapper .cell),
:deep(.el-table__header th .cell) {
white-space: nowrap !important;
overflow: visible !important;
}
// 表头内容容器允许溢出不被截断
:deep(.el-table th .cell) {
word-break: keep-all;
}
// 去掉 el-table 默认的 border
:deep(.el-table--border) {
border: none;
}
:deep(.el-table::before), :deep(.el-table::after) {
display: none;
}
// borderless 模式:整体无边框
&.borderless { &.borderless {
border: none; border: 0;
box-shadow: none; border-radius: 0;
} }
} }
.rr-clickable-row { cursor: pointer; }
.rr-clickable-row:hover > td { background: var(--bg-soft) !important; }
.rr-empty { .rr-empty {
text-align: center;
padding: 32px 0; 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: 12px 20px;
font-size: 13px; /* W17.3.6 跟内容行一致 (13px) */
color: var(--ink-3); color: var(--ink-3);
background: var(--surface); font-size: 13px;
text-align: center;
}
.rr-pagination {
padding: 11px 14px;
display: flex;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
&__info { color: var(--ink-3); } color: var(--ink-3);
:deep(.el-pagination) { font-size: 12.5px;
--el-pagination-bg-color: transparent; gap: 8px;
--el-pagination-font-size: 13px; /* W17.3.6 跟内容行 cell 字号一致 (13px) */ }
font-size: 13px; .rr-pagination-total {
} font-weight: 400;
:deep(.el-pager li) { color: var(--ink-3);
border-radius: 6px; }
margin: 0 2px; .rr-pagination-unit {
} font-size: 12.5px;
:deep(.el-pagination .btn-prev), color: var(--ink-3);
:deep(.el-pagination .btn-next) { margin-left: -4px;
border-radius: 6px; }
} .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,177 +0,0 @@
<script setup lang="ts">
/**
* F006 通用 FormDialog 组件 (W9 A2)
*
* Schema 驱动的增改弹窗: 字段定义 → 自动渲染表单 → 校验 → 提交
*
* 用法:
* <FormDialog
* v-model:visible="editVisible"
* :title="editMode === 'add' ? '新增' : '编辑'"
* :fields="[
* { key: 'name', label: '名称', type: 'text', required: true, maxlength: 50 },
* { key: 'status', label: '状态', type: 'radio', options: [
* { value: 0, label: '启用' }, { value: 1, label: '禁用' }
* ] },
* { key: 'remark', label: '备注', type: 'textarea', rows: 3 }
* ]"
* :model="editForm"
* @submit="onSubmitEdit"
* />
*
* 字段类型 (W9 阶段支持 5 种):
* - text: el-input (单行)
* - textarea: el-input type=textarea
* - number: el-input-number
* - radio: el-radio-group + options
* - select: el-select + options
*
* 通用属性: required / maxlength / min / max / rows / placeholder / disabled
*/
import { ref, watch } from 'vue'
export interface FormFieldOption {
value: any
label: string
}
export interface FormField {
key: string
label: string
type?: 'text' | 'textarea' | 'number' | 'radio' | 'select'
required?: boolean
placeholder?: string
default?: any
options?: FormFieldOption[]
maxlength?: number
min?: number
max?: number
rows?: number
disabled?: boolean
}
interface Props {
visible: boolean
title: string
fields: FormField[]
model: Record<string, any>
width?: string | number
submitText?: string
cancelText?: string
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
width: 500,
submitText: '确定',
cancelText: '取消',
loading: false
})
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void
(e: 'submit', model: Record<string, any>): void
(e: 'cancel'): void
}>()
const localVisible = ref(props.visible)
watch(() => props.visible, v => localVisible.value = v)
function close() { localVisible.value = false; emit('update:visible', false); emit('cancel') }
function onSubmit() {
// 必填校验
for (const f of props.fields) {
if (f.required) {
const v = props.model[f.key]
if (v === '' || v === null || v === undefined) {
return // 让 el-form-item required 提示
}
}
}
emit('submit', { ...props.model })
}
</script>
<template>
<el-dialog
:model-value="localVisible"
:title="title"
:width="typeof width === 'number' ? `${width}px` : width"
@close="close"
@update:model-value="(v: boolean) => { localVisible = v; emit('update:visible', v) }"
>
<el-form label-width="80px" :model="model">
<el-form-item
v-for="f in fields"
:key="f.key"
:label="f.label"
:required="f.required"
>
<el-input
v-if="f.type === 'text' || !f.type"
v-model="model[f.key]"
:placeholder="f.placeholder || `请输入${f.label}`"
:maxlength="f.maxlength"
:show-word-limit="!!f.maxlength"
:disabled="f.disabled"
/>
<el-input
v-else-if="f.type === 'textarea'"
v-model="model[f.key]"
type="textarea"
:rows="f.rows || 3"
:placeholder="f.placeholder || `请输入${f.label}`"
:maxlength="f.maxlength"
:show-word-limit="!!f.maxlength"
:disabled="f.disabled"
/>
<el-input-number
v-else-if="f.type === 'number'"
v-model="model[f.key]"
:min="f.min"
:max="f.max"
:disabled="f.disabled"
/>
<el-radio-group
v-else-if="f.type === 'radio'"
v-model="model[f.key]"
:disabled="f.disabled"
>
<el-radio
v-for="opt in f.options || []"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</el-radio>
</el-radio-group>
<el-select
v-else-if="f.type === 'select'"
v-model="model[f.key]"
:placeholder="f.placeholder || `请选择${f.label}`"
:disabled="f.disabled"
style="width: 100%"
>
<el-option
v-for="opt in f.options || []"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="close">{{ cancelText }}</el-button>
<el-button type="primary" :loading="loading" @click="onSubmit">{{ submitText }}</el-button>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.el-form-item :deep(.el-input),
.el-form-item :deep(.el-select),
.el-form-item :deep(.el-input-number) {
width: 100%;
}
</style>

View File

@ -1,95 +0,0 @@
<script setup lang="ts">
/**
* F006 通用 ListTabs 组件 (W17 设计规范 v2 整改)
*
* 按 ui-preview-v2.html 模板: padding 9px 16px + 下划线高亮 + tab-count 圆角 pill
*
* 全部 [128] 待报价 [23] 进行中 [45] 已完成 [52] 已取消 [8]
*/
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: 0;
margin-bottom: 14px;
border-bottom: 1px solid var(--border);
}
.rr-list-tab {
padding: 10px 18px;
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;
align-items: center;
gap: 6px;
margin-bottom: -1px;
&:hover { color: var(--ink, #0F172A); }
&.is-active {
color: var(--brand, #2563EB);
font-weight: 600;
border-bottom-color: var(--brand, #2563EB);
}
&__count {
font-size: 11px;
background: var(--surface-2);
color: var(--ink-3);
padding: 0 6px;
border-radius: 999px;
font-weight: 500;
line-height: 18px;
min-width: 20px;
text-align: center;
}
&.is-active &__count {
background: var(--brand-soft, rgba(37, 99, 235, 0.08));
color: var(--brand, #2563EB);
}
}
</style>

View File

@ -1,76 +0,0 @@
<script setup lang="ts">
/**
* F006 通用 PageHeader 组件 (W17 设计规范 v2 整改)
*
* 页面头: 标题 + 副标题 + 操作按钮区 (按 ui-preview-v2.html 模板, 无卡片化)
*
* 用法:
* <PageHeader title="订单管理" :sub="`共 ${total} 条`">
* <el-button type="primary" @click="onAdd">新增</el-button>
* <el-button type="danger">批量删除</el-button>
* </PageHeader>
*/
interface Props {
title: string
sub?: string
}
withDefaults(defineProps<Props>(), {
sub: ''
})
</script>
<template>
<div class="rr-page-header">
<div class="rr-page-header-text">
<h2 class="rr-page-title">{{ title }}</h2>
<p v-if="sub" class="rr-page-sub">{{ sub }}</p>
</div>
<div class="rr-page-actions">
<slot />
</div>
</div>
</template>
<style scoped lang="scss">
.rr-page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.rr-page-header-text {
flex: 1;
min-width: 0;
}
.rr-page-title {
font-size: 20px;
font-weight: 700;
color: var(--ink, #0F172A);
margin: 0;
line-height: 1.3;
letter-spacing: -0.02em;
}
.rr-page-sub {
font-size: 12.5px;
color: var(--ink-3, #64748B);
margin: 2px 0 0 0;
line-height: 1.4;
}
.rr-page-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
align-items: center;
:deep(.el-button) {
padding: 9px 18px;
font-weight: 500;
border-radius: var(--radius, 6px);
}
:deep(.el-button--primary) {
box-shadow: 0 1px 2px rgba(37, 99, 235, 0.15);
}
:deep(.el-button + .el-button) {
margin-left: 0;
}
}
</style>

View File

@ -1,274 +0,0 @@
<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: (typeof f.width === 'number' ? f.width + 'px' : (f.width || '180px')) }"
@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: (typeof f.width === 'number' ? f.width + 'px' : (f.width || '160px')) }"
>
<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: (typeof f.width === 'number' ? f.width + 'px' : (f.width || '180px')) }"
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: (typeof f.width === 'number' ? f.width + 'px' : (f.width || '260px')) }"
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: (typeof f.width === 'number' ? f.width + 'px' : (f.width || '180px')) }"
/>
</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(--border) 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));
}
/* === el-select 宽度兜底 (Vue :style 没生效时也保证最小宽度) === */
:deep(.el-select) {
min-width: 140px;
width: auto !important;
}
:deep(.el-select .el-select__wrapper) {
min-width: 140px;
}
:deep(.el-select .el-select__wrapper .el-select__selection) {
min-width: 100px;
}
/* === 下拉弹层圆角 (跟 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

@ -1,168 +0,0 @@
<script setup lang="ts">
/**
* F006 通用表格操作列组件 (W17.6 主次分离, W17.6.1 按数量自动收纳)
*
* 按钮数量决定显示方式 (规则符合直觉, 无需人为划分主次):
* - 过滤 when 后 ≤3 个: 全部显示为文字按钮, 不显示 ⋯
* - 过滤 when 后 ≥4 个: 前 visibleCount 个显示为文字按钮, 其余收进 ⋯ 下拉
*
* 用法:
* <TableActions
* :row="row"
* :actions="[
* { label: '详情', type: 'primary', onClick: onView },
* { label: '审核', type: 'primary', when: r => r.status === 1, onClick: onAudit },
* { label: r => r.onShelf === 1 ? '下架' : '上架', type: r => r.onShelf === 1 ? 'warning' : 'success', onClick: onToggle },
* { label: '改分类', type: 'primary', onClick: onCategory },
* { label: '删除', type: 'danger', divided: true, confirm: '确认删除?', onClick: onDelete }
* ]"
* />
*
* ActionItem:
* label string | (row) => string 文字 (支持动态)
* type string | (row) => string 类型 primary/success/warning/danger (支持动态)
* when (row) => boolean 条件渲染, 不满足则不显示 (替代 v-if)
* divided boolean 菜单项前加分隔线 (危险操作前用)
* confirm boolean | string true 用默认提示, string 用自定义提示
* onClick (row) => void 点击处理
*
* Props:
* row 当前行数据 (透传给 label/type/when/onClick)
* actions 操作列表 (按顺序, 前面的是主操作)
* visibleCount 常驻文字按钮数, 默认 2 (仅在按钮 >3 时生效)
*/
import { computed } from 'vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { MoreFilled } from '@element-plus/icons-vue'
type ActionType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
interface ActionItem {
label: string | ((row: any) => string)
type?: ActionType | ((row: any) => ActionType)
when?: (row: any) => boolean
divided?: boolean
confirm?: boolean | string
onClick: (row: any) => void
}
const props = withDefaults(defineProps<{
row: any
actions: ActionItem[]
/** 常驻文字按钮数, 仅在按钮 >3 时生效 (默认 2) */
visibleCount?: number
}>(), {
visibleCount: 2
})
// 解析 label (string 或函数)
function resolveLabel(item: ActionItem): string {
return typeof item.label === 'function' ? item.label(props.row) : item.label
}
// 解析 type (string 或函数)
function resolveType(item: ActionItem): ActionType {
const t = typeof item.type === 'function' ? item.type(props.row) : item.type
return t || 'primary'
}
// 过滤 when 条件后的有效项
const visibleActions = computed<ActionItem[]>(() =>
props.actions.filter(item => !item.when || item.when(props.row))
)
// 拆分结果: 前 N 个常驻 + 其余收纳 (≤3 全常驻, >3 前 visibleCount 常驻)
const layout = computed(() => {
const all = visibleActions.value
if (all.length > 3) {
return { primary: all.slice(0, props.visibleCount), more: all.slice(props.visibleCount) }
}
return { primary: all, more: [] as ActionItem[] }
})
// 执行操作 (处理二次确认)
async function runAction(item: ActionItem) {
if (item.confirm) {
const msg = typeof item.confirm === 'string' ? item.confirm : `确认${resolveLabel(item)}?`
try {
await ElMessageBox.confirm(msg, '操作确认', { type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' })
} catch {
return // 用户取消
}
}
try {
item.onClick(props.row)
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '操作失败')
}
}
</script>
<template>
<div class="rr-table-actions">
<!-- 常驻文字按钮 -->
<el-button
v-for="(item, i) in layout.primary"
:key="`p${i}`"
text
:type="resolveType(item) as any"
size="small"
@click="runAction(item)"
>{{ resolveLabel(item) }}</el-button>
<!-- 收纳下拉 (仅在按钮 >3 时渲染) -->
<el-dropdown
v-if="layout.more.length"
trigger="click"
@command="(cmd: number) => runAction(layout.more[cmd])"
>
<span class="rr-more-trigger">
<el-icon><MoreFilled /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="(item, i) in layout.more"
:key="`m${i}`"
:command="i"
:divided="item.divided"
:style="{ color: resolveType(item) === 'danger' ? 'var(--el-color-danger)' : undefined }"
>{{ resolveLabel(item) }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<style scoped lang="scss">
.rr-table-actions {
display: inline-flex;
align-items: center;
gap: 0;
// 收紧 el-button text 的内边距, 默认 padding: 8px 15px 太松散
:deep(.el-button.is-text) {
padding: 0 4px;
margin: 0;
font-size: 13px;
height: auto;
min-height: 0;
}
}
.rr-more-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 4px;
cursor: pointer;
color: var(--ink-2, #475569);
font-size: 15px;
margin-left: 2px;
transition: all 0.15s;
&:hover {
background: var(--el-fill-color-light, #F4F4F5);
color: var(--el-color-primary, #2563EB);
}
}
</style>

View File

@ -47,15 +47,14 @@ export interface MenuLabel {
export const MENU_LABELS: MenuLabel[] = [ export const MENU_LABELS: MenuLabel[] = [
// ===== 顶部菜单(原老 vue 一级) ===== // ===== 顶部菜单(原老 vue 一级) =====
{ url: 'statistics/index', label: '首页', icon: 'HomeFilled', path: '/dashboard', sort: 1 }, { url: 'statistics/index', label: '首页', icon: 'HomeFilled', path: '/dashboard', sort: 1 },
{ url: 'order/order', label: '订单管理', icon: 'Document', path: '/order', sort: 3 },
// ===== 用户模块 ===== // ===== 用户模块 =====
{ url: 'user/user', label: '用户列表', icon: 'User', path: '/user/user', sort: 100 }, { url: 'user/user', label: '用户列表', icon: 'User', path: '/user/user', sort: 100 },
{ url: 'user/enterprise/enterprise', label: '企业列表', icon: 'OfficeBuilding', path: '/user/enterprise', sort: 101 }, { url: 'user/enterprise/enterprise', label: '企业列表', icon: 'OfficeBuilding', path: '/user/enterprise', sort: 101 },
{ url: 'user/enterprise/enterprise-rating', label: '评级', icon: 'Star', path: '/user/enterprise/rating', sort: 102 }, { url: 'user/enterprise/enterprise-rating', label: '评级', icon: 'Star', path: '/user/enterprise/rating', sort: 102 },
{ url: 'user/enterprise/enterprise-level', label: '企业等级管理', icon: 'Medal', path: '/user/enterprise/level', sort: 103 }, // 已接真 view W16 { url: 'user/enterprise/enterprise-level', label: '企业等级管理', icon: 'Medal', path: '/user/enterprise/level', sort: 103 },
{ url: 'user/enterprise/enterprise-score', label: '评分', icon: 'DataAnalysis', path: '/user/enterprise/score', sort: 104 }, { url: 'user/enterprise/enterprise-score', label: '评分', icon: 'DataAnalysis', path: '/user/enterprise/score', sort: 104 },
// F002-W18 (2026-07-14) 新增: 企业登记管理
{ url: 'user/enterprise/merchant/register', label: '企业登记管理', icon: 'Postcard', path: '/user/enterprise/merchant/register', sort: 105 },
{ url: 'user/finance/recharge', label: '充值管理', icon: 'Wallet', path: '/user/finance/recharge', sort: 110 }, { url: 'user/finance/recharge', label: '充值管理', icon: 'Wallet', path: '/user/finance/recharge', sort: 110 },
{ url: 'user/finance/recharge-tx', label: '提现管理', icon: 'CreditCard', path: '/user/finance/recharge-tx', sort: 111 }, { url: 'user/finance/recharge-tx', label: '提现管理', icon: 'CreditCard', path: '/user/finance/recharge-tx', sort: 111 },
{ url: 'user/finance/revenue-sharing', label: '分账记录管理', icon: 'Share', path: '/user/finance/revenue', sort: 112 }, { url: 'user/finance/revenue-sharing', label: '分账记录管理', icon: 'Share', path: '/user/finance/revenue', sort: 112 },
@ -73,21 +72,14 @@ export const MENU_LABELS: MenuLabel[] = [
{ url: 'order/ordergood-commission', label: '实物订单分佣统计', icon: 'Coin', path: '/order/ordergood/commission', sort: 220 }, { url: 'order/ordergood-commission', label: '实物订单分佣统计', icon: 'Coin', path: '/order/ordergood/commission', sort: 220 },
{ url: 'order/serviceorder-commission', label: '服务订单分佣统计', icon: 'Coin', path: '/order/serviceorder/commission', sort: 221 }, { url: 'order/serviceorder-commission', label: '服务订单分佣统计', icon: 'Coin', path: '/order/serviceorder/commission', sort: 221 },
{ url: 'order/orderbase-price-analysis', label: '基础价格分析表', icon: 'TrendCharts', path: '/order/base-price-analysis', sort: 230 }, { url: 'order/orderbase-price-analysis', label: '基础价格分析表', icon: 'TrendCharts', path: '/order/base-price-analysis', sort: 230 },
{ url: 'order/orderpay', label: '订单支付回调', icon: 'Wallet', path: '/order/orderpay', sort: 240 },
{ url: 'order/refund', label: '订单退款管理', icon: 'Refresh', path: '/order/refund', sort: 250 },
{ url: 'order/aftersale', label: '售后订单', icon: 'Service', path: '/order/aftersale', sort: 260 },
// ===== 供应商模块 ===== // ===== 供应商模块 =====
{ url: 'supplier/good', label: '实物商品', icon: 'Box', path: '/supplier/good', sort: 300 }, { url: 'supplier/good', label: '实物商品', icon: 'Box', path: '/supplier/good', sort: 300 },
{ url: 'supplier/servicegood', label: '服务商品', icon: 'Box', path: '/supplier/servicegood', sort: 301 }, { url: 'supplier/servicegood', label: '服务商品(→实物商品)', icon: 'Box', path: '/supplier/good', sort: 301 },
{ url: 'supplier/activityzone', label: '活动专区', icon: 'Promotion', path: '/supplier/activity', sort: 302 }, { url: 'supplier/activityzone', label: '活动专区', icon: 'Promotion', path: '/supplier/activity', sort: 302 },
{ url: 'supplier/coupon', label: '优惠券', icon: 'Ticket', path: '/supplier/coupon', sort: 303 }, { url: 'supplier/coupon', label: '优惠券', icon: 'Ticket', path: '/supplier/coupon', sort: 303 },
{ url: 'supplier/promoter', label: '推广员', icon: 'UserFilled', path: '/supplier/promoter', sort: 304 }, { url: 'supplier/promoter', label: '推广返佣', icon: 'UserFilled', path: '/supplier/promoter', sort: 304 },
{ url: 'supplier/inquiry/inquiry', label: '询价列表', icon: 'ChatLineSquare', path: '/supplier/inquiry', sort: 305 }, { url: 'supplier/inquiry/inquiry', label: '询价列表', icon: 'ChatLineSquare', path: '/supplier/inquiry', sort: 305 },
{ url: 'supplier/ordercomment', label: '订单评价', icon: 'Star', path: '/supplier/ordercomment', sort: 306 },
{ url: 'supplier/historylog', label: '浏览足迹', icon: 'View', path: '/supplier/historylog', sort: 307 },
{ url: 'supplier/logistics', label: '物流方案', icon: 'Van', path: '/supplier/logistics', sort: 308 },
{ url: 'supplier/coupon-scope', label: '优惠券适用范围',icon: 'Filter', path: '/supplier/coupon-scope',sort: 309 },
// ===== 系统模块 ===== // ===== 系统模块 =====
{ url: 'sys/user', label: '用户管理[后台用户]', icon: 'UserFilled', path: '/sys/user', sort: 400 }, { url: 'sys/user', label: '用户管理[后台用户]', icon: 'UserFilled', path: '/sys/user', sort: 400 },
@ -116,7 +108,7 @@ export const MENU_LABELS: MenuLabel[] = [
{ url: 'other/help', label: '帮助中心', icon: 'QuestionFilled', path: '/other/help', sort: 701 }, { url: 'other/help', label: '帮助中心', icon: 'QuestionFilled', path: '/other/help', sort: 701 },
{ url: 'other/operateguide', label: '内容列表', icon: 'Reading', path: '/other/guide', sort: 702 }, { url: 'other/operateguide', label: '内容列表', icon: 'Reading', path: '/other/guide', sort: 702 },
{ url: 'other/industry', label: '行业', icon: 'OfficeBuilding', path: '/other/industry', sort: 703 }, { url: 'other/industry', label: '行业', icon: 'OfficeBuilding', path: '/other/industry', sort: 703 },
// other/label 已隐藏 (后端无表无 controller) { url: 'other/label', label: '标签管理', icon: 'PriceTag', path: '/other/label', sort: 704 },
{ url: 'other/categoryGood', label: '平台分类', icon: 'Menu', path: '/other/category/good', sort: 705 }, { url: 'other/categoryGood', label: '平台分类', icon: 'Menu', path: '/other/category/good', sort: 705 },
{ url: 'other/categoryZX', label: '资讯分类', icon: 'Menu', path: '/other/category/zx', sort: 707 }, { url: 'other/categoryZX', label: '资讯分类', icon: 'Menu', path: '/other/category/zx', sort: 707 },
{ url: 'other/goodunit', label: '商品单位', icon: 'Box', path: '/other/goodunit', sort: 708 }, { url: 'other/goodunit', label: '商品单位', icon: 'Box', path: '/other/goodunit', sort: 708 },
@ -129,7 +121,12 @@ export const MENU_LABELS: MenuLabel[] = [
{ url: 'message/sms', label: '短信配置', icon: 'ChatDotRound', path: '/message/sms', sort: 800 }, { url: 'message/sms', label: '短信配置', icon: 'ChatDotRound', path: '/message/sms', sort: 800 },
{ url: 'job/schedule', label: '定时任务', icon: 'Timer', path: '/job/schedule', sort: 801 }, { url: 'job/schedule', label: '定时任务', icon: 'Timer', path: '/job/schedule', sort: 801 },
// ===== 开发工具(devtools) — 已隐藏 (客户不需要) ===== // ===== 开发工具(devtools) =====
{ url: 'devtools/datasource', label: '数据源配置', icon: 'Coin', path: '/devtools/datasource', sort: 901 },
{ url: 'devtools/param', label: '参数配置(开发)', icon: 'SetUp', path: '/devtools/param', sort: 902 },
{ url: 'devtools/baseclass', label: '基类配置', icon: 'Files', path: '/devtools/baseclass', sort: 903 },
{ url: 'devtools/template', label: '模板配置', icon: 'Tickets', path: '/devtools/template', sort: 904 },
{ url: 'devtools/fieldtype', label: '字段配置', icon: 'CollectionTag', path: '/devtools/fieldtype', sort: 905 },
// ===== F002 智能体集成 ===== // ===== F002 智能体集成 =====
{ url: 'openapi/app', label: '应用列表', icon: 'Cellphone', path: '/openapi/app', sort: 1000 }, { url: 'openapi/app', label: '应用列表', icon: 'Cellphone', path: '/openapi/app', sort: 1000 },

1
src/env.d.ts vendored
View File

@ -5,4 +5,3 @@ declare module '*.vue' {
const component: DefineComponent<{}, {}, any> const component: DefineComponent<{}, {}, any>
export default component export default component
} }
declare module 'element-plus/dist/locale/zh-cn.mjs';

View File

@ -3,13 +3,11 @@ 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)
import './theme/tokens.scss' import './theme/tokens.scss'
import './theme/themes.scss' import './theme/themes.scss'
import './styles/global-popper.scss'
import { loadTheme } from './theme/presets' import { loadTheme } from './theme/presets'
// 启动时恢复用户主题 // 启动时恢复用户主题
@ -18,5 +16,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, { locale: zhCn }) app.use(ElementPlus)
app.mount('#app') app.mount('#app')

View File

View File

View File

View File

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 意见反馈 - W10 A1 标准化 * F006 意见反馈 - W6 P0 完整化
* *
* 沿用老 vue api/feedback.vue 137 行: * 沿用老 vue api/feedback.vue 137 行:
* - 搜索: 用户昵称 * - 搜索: 用户昵称
@ -8,15 +8,11 @@
* - 操作: 预览 / 删除 * - 操作: 预览 / 删除
* - 预览弹窗展示完整反馈内容 (含图片列表) * - 预览弹窗展示完整反馈内容 (含图片列表)
* - /api/feedback 后端 404 → FALLBACK mock * - /api/feedback 后端 404 → FALLBACK mock
*
* W10 A1: 套 PageHeader/SearchBar 标准化模板 (无 STATUS_MAP, 无增改弹窗)
*/ */
import { ref, onMounted, reactive } from 'vue' import { ref, onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface Feedback { interface Feedback {
id: string id: string
@ -31,7 +27,6 @@ interface Feedback {
raw: any raw: any
} }
// ========== 端点 404 降级 mock ==========
const FALLBACK: Feedback[] = [ const FALLBACK: Feedback[] = [
{ id: 'F001', userId: 'U10086', nickName: '张经理', phone: '13800138001', title: '订单延迟发货', content: '我们于6月28日下单采购一批钢材, 至今未发货, 影响项目进度, 请尽快处理。', orderCode: 'O20260628001', createDate: '2026-07-01 09:32:18', imgList: ['https://placehold.co/120x120/2563EB/fff?text=IMG1', 'https://placehold.co/120x120/2563EB/fff?text=IMG2'], raw: {} }, { id: 'F001', userId: 'U10086', nickName: '张经理', phone: '13800138001', title: '订单延迟发货', content: '我们于6月28日下单采购一批钢材, 至今未发货, 影响项目进度, 请尽快处理。', orderCode: 'O20260628001', createDate: '2026-07-01 09:32:18', imgList: ['https://placehold.co/120x120/2563EB/fff?text=IMG1', 'https://placehold.co/120x120/2563EB/fff?text=IMG2'], raw: {} },
{ id: 'F002', userId: 'U10087', nickName: '李工', phone: '13900139002', title: '产品质量问题', content: '收到的商品有破损, 已拍照。附件图1是外包装破损, 图2是商品本身。', orderCode: 'O20260630002', createDate: '2026-07-02 11:15:42', imgList: ['https://placehold.co/120x120/DC2626/fff?text=BAD1'], raw: {} }, { id: 'F002', userId: 'U10087', nickName: '李工', phone: '13900139002', title: '产品质量问题', content: '收到的商品有破损, 已拍照。附件图1是外包装破损, 图2是商品本身。', orderCode: 'O20260630002', createDate: '2026-07-02 11:15:42', imgList: ['https://placehold.co/120x120/DC2626/fff?text=BAD1'], raw: {} },
@ -41,27 +36,26 @@ const FALLBACK: Feedback[] = [
{ id: 'F006', userId: 'U10091', nickName: '孙技术', phone: '13400134006', title: '功能建议: 自定义审批流', content: '建议在询价单模块增加自定义审批流, 当前固定流程不够灵活。', orderCode: '', createDate: '2026-07-04 09:10:00', imgList: [], raw: {} } { id: 'F006', userId: 'U10091', nickName: '孙技术', phone: '13400134006', title: '功能建议: 自定义审批流', content: '建议在询价单模块增加自定义审批流, 当前固定流程不够灵活。', orderCode: '', createDate: '2026-07-04 09:10:00', imgList: [], raw: {} }
] ]
// ========== 标准状态 ==========
const list = ref<Feedback[]>([]) const list = ref<Feedback[]>([])
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const page = ref(1) const page = ref(1)
const limit = ref(20) const limit = ref(20)
const filters = reactive<{ nickName: string }>({ nickName: '' }) const filters = reactive({ nickName: '' })
// ========== 弹窗状态 ==========
const previewVisible = ref(false) const previewVisible = ref(false)
const previewData = ref<Feedback | null>(null) const previewData = ref<Feedback | null>(null)
// ========== 加载逻辑 ==========
async function load() { async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const resp: any = await httpGet('/other/feedback/page', { page: page.value, limit: limit.value }) await new Promise(r => setTimeout(r, 200))
list.value = resp?.list || FALLBACK let rows = [...FALLBACK]
total.value = Number(resp?.total || 0) if (filters.nickName) rows = rows.filter(r => r.nickName.includes(filters.nickName))
list.value = rows
total.value = rows.length
} catch (e: any) { } catch (e: any) {
error.value = e?.msg || e?.message || '加载失败' error.value = e?.msg || e?.message || '加载失败'
list.value = FALLBACK list.value = FALLBACK
@ -69,7 +63,6 @@ async function load() {
} finally { loading.value = false } } finally { loading.value = false }
} }
// ========== 事件 ==========
function onSearch() { page.value = 1; load() } function onSearch() { page.value = 1; load() }
function onReset() { filters.nickName = ''; load() } function onReset() { filters.nickName = ''; load() }
function onPageChange(p: number) { page.value = p; load() } function onPageChange(p: number) { page.value = p; load() }
@ -87,16 +80,24 @@ onMounted(load)
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="意见反馈" :sub="`用户反馈信息管理 (共 ${total} 条)`" /> <div class="rr-page-header">
<div>
<h2 class="rr-page-title">意见反馈</h2>
<p class="rr-page-sub">用户反馈信息管理 (共 {{ total }} 条)</p>
</div>
</div>
<SearchBar <div class="rr-card rr-search">
v-model:filters="filters" <el-form :inline="true" @keyup.enter="onSearch">
:fields="[ <el-form-item label="用户昵称">
{ key: 'nickName', label: '用户昵称', type: 'text', placeholder: '请输入用户昵称' } <el-input v-model="filters.nickName" placeholder="请输入用户昵称" clearable style="width: 240px" />
]" </el-form-item>
@search="onSearch" <el-form-item>
@reset="onReset" <el-button type="primary" @click="onSearch">查询</el-button>
/> <el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="rr-card"> <div class="rr-card">
<DataTable <DataTable
@ -115,7 +116,7 @@ onMounted(load)
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="total > 10" :page-sizes="[10, 20, 50, 100]" :show-pagination="total > 10"
:error="error" :error="error"
empty-text="暂无反馈" empty-text="暂无反馈"
borderless borderless
@ -163,6 +164,11 @@ onMounted(load)
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1400px; } .rr-page { max-width: 1400px; }
.rr-page-header { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
.rr-page-title { font-size: 22px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
.rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
.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; }
.feedback-imgs { display: flex; gap: 8px; flex-wrap: wrap; } .feedback-imgs { display: flex; gap: 8px; flex-wrap: wrap; }
.feedback-img-item { width: 80px; height: 80px; border-radius: 6px; border: 1px solid var(--border); } .feedback-img-item { width: 80px; height: 80px; border-radius: 6px; border: 1px solid var(--border); }
</style> </style>

View File

@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 帮助中心 - W10 A1 标准化 * F006 帮助中心 - W6 P0 完整化
*
* W10 A1: 套 PageHeader/SearchBar 标准化模板
* *
* 沿用老 vue api/help.vue 60 行: * 沿用老 vue api/help.vue 60 行:
* - 搜索: 标题 * - 搜索: 标题
@ -14,8 +12,6 @@ import { ref, onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface Help { interface Help {
id: string id: string
@ -88,18 +84,27 @@ onMounted(load)
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="帮助中心" :sub="`常见问题与帮助文档管理 (共 ${total} 条)`"> <div class="rr-page-header">
<el-button type="primary" @click="onAdd">新增文档</el-button> <div>
</PageHeader> <h2 class="rr-page-title">帮助中心</h2>
<p class="rr-page-sub">常见问题与帮助文档管理 (共 {{ total }} 条)</p>
</div>
<div class="rr-page-actions">
<el-button type="primary" @click="onAdd">新增文档</el-button>
</div>
</div>
<SearchBar <div class="rr-card rr-search">
v-model:filters="filters" <el-form :inline="true" @keyup.enter="onSearch">
:fields="[ <el-form-item label="标题">
{ key: 'title', label: '标题', type: 'text', placeholder: '请输入标题' } <el-input v-model="filters.title" placeholder="请输入标题" clearable style="width: 280px" />
]" </el-form-item>
@search="onSearch" <el-form-item>
@reset="onReset" <el-button type="primary" @click="onSearch">查询</el-button>
/> <el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="rr-card"> <div class="rr-card">
<DataTable <DataTable
@ -115,7 +120,7 @@ onMounted(load)
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="total > 10" :page-sizes="[10, 20, 50, 100]" :show-pagination="total > 10"
:error="error" :error="error"
empty-text="暂无帮助文档" empty-text="暂无帮助文档"
borderless borderless
@ -153,4 +158,9 @@ onMounted(load)
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1400px; } .rr-page { max-width: 1400px; }
.rr-page-header { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
.rr-page-title { font-size: 22px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
.rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
.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; }
</style> </style>

View File

@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 轮播图管理 - W10 A1 标准化 * F006 轮播图管理 - W6 P0 完整化
*
* W10 A1: 套 PageHeader/SearchBar 标准化模板
* *
* 沿用老 vue api/lunbo.vue 88 行: * 沿用老 vue api/lunbo.vue 88 行:
* - 搜索: 标题 / 展示位置 * - 搜索: 标题 / 展示位置
@ -14,8 +12,6 @@ import { ref, onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface Lunbo { interface Lunbo {
id: string id: string
@ -66,9 +62,12 @@ async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const resp: any = await httpGet('/other/lunbo/page', { page: page.value, limit: limit.value }) await new Promise(r => setTimeout(r, 200))
list.value = resp?.list || FALLBACK let rows = [...FALLBACK]
total.value = Number(resp?.total || 0) if (filters.title) rows = rows.filter(r => r.title.includes(filters.title))
if (filters.position) rows = rows.filter(r => r.position === Number(filters.position))
list.value = rows
total.value = rows.length
} catch (e: any) { } catch (e: any) {
error.value = e?.msg || e?.message || '加载失败' error.value = e?.msg || e?.message || '加载失败'
list.value = FALLBACK list.value = FALLBACK
@ -101,22 +100,33 @@ onMounted(load)
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="轮播图管理" :sub="`App/小程序首页与商城轮播图配置 (共 ${total} 张)`"> <div class="rr-page-header">
<el-button type="primary" @click="onAdd">新增轮播</el-button> <div>
</PageHeader> <h2 class="rr-page-title">轮播图管理</h2>
<p class="rr-page-sub">App/小程序首页与商城轮播图配置 (共 {{ total }} 张)</p>
</div>
<div class="rr-page-actions">
<el-button type="primary" @click="onAdd">新增轮播</el-button>
</div>
</div>
<SearchBar <div class="rr-card rr-search">
v-model:filters="filters" <el-form :inline="true" @keyup.enter="onSearch">
:fields="[ <el-form-item label="标题">
{ key: 'title', label: '标题', type: 'text', placeholder: '请输入标题' }, <el-input v-model="filters.title" placeholder="请输入标题" clearable style="width: 220px" />
{ key: 'position', label: '展示位置', type: 'select', options: [ </el-form-item>
{ value: 1, label: '首页' }, <el-form-item label="展示位置">
{ value: 2, label: '在线商城' } <el-select v-model="filters.position" placeholder="全部" clearable style="width: 160px">
] } <el-option label="首页" :value="1" />
]" <el-option label="在线商城" :value="2" />
@search="onSearch" </el-select>
@reset="onReset" </el-form-item>
/> <el-form-item>
<el-button type="primary" @click="onSearch">查询</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="rr-card"> <div class="rr-card">
<DataTable <DataTable
@ -134,7 +144,7 @@ onMounted(load)
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="total > 10" :page-sizes="[10, 20, 50, 100]" :show-pagination="total > 10"
:error="error" :error="error"
empty-text="暂无轮播图" empty-text="暂无轮播图"
borderless borderless
@ -195,5 +205,10 @@ onMounted(load)
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1400px; } .rr-page { max-width: 1400px; }
.rr-page-header { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
.rr-page-title { font-size: 22px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
.rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
.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; }
.lunbo-thumb { width: 80px; height: 60px; border-radius: 4px; border: 1px solid var(--border); } .lunbo-thumb { width: 80px; height: 60px; border-radius: 4px; border: 1px solid var(--border); }
</style> </style>

View File

@ -45,7 +45,7 @@ const themePalette = [
function refreshCaptcha() { function refreshCaptcha() {
form.uuid = Math.random().toString(36).slice(2, 12) form.uuid = Math.random().toString(36).slice(2, 12)
captchaUrl.value = `/api/auth/captcha?uuid=${form.uuid}&_t=${Date.now()}` captchaUrl.value = `/auth/captcha?uuid=${form.uuid}&_t=${Date.now()}`
} }
function onCaptchaInput(e: Event) { function onCaptchaInput(e: Event) {
const t = e.target as HTMLInputElement const t = e.target as HTMLInputElement
@ -65,12 +65,13 @@ async function onLogin() {
loading.value = true loading.value = true
try { try {
const res = await axios.post( const res = await axios.post(
'/api/auth/oauth/token', '/auth/oauth/token',
qs.stringify({ ...form, grant_type: 'password' }), qs.stringify({ ...form, grant_type: 'password' }),
{ {
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic cmVucmVuaW86cmVucmVuaW8=' Authorization: 'Basic cmVucmVuaW86cmVucmVuaW8=',
client: 'front'
} }
} }
) )

View File

@ -542,14 +542,13 @@ const transactionMock = [
] ]
// ========== user/message ========== // ========== user/message ==========
// W13.4: 后端字段是 name/content/readFlag/createDate, 不是 title/receiver/status/sendTime
const messageCols: MockColumn[] = [ const messageCols: MockColumn[] = [
{ prop: 'id', label: 'ID', width: 80 }, { prop: 'id', label: 'ID', width: 80 },
{ prop: 'name', label: '消息标题', minWidth: 200 }, { prop: 'title', label: '消息标题', minWidth: 220 },
{ prop: 'content', label: '内容', minWidth: 260 }, { prop: 'type', label: '类型', width: 100 },
{ prop: 'type', label: '类型', width: 80, format: (r: any) => r.type === 1 ? '系统' : r.type === 2 ? '业务' : String(r.type ?? '--') }, { prop: 'receiver', label: '接收人', width: 140 },
{ prop: 'readFlag', label: '状态', width: 80, format: (r: any) => r.readFlag === 1 ? '已读' : r.readFlag === 0 ? '未读' : String(r.readFlag ?? '--') }, { prop: 'status', label: '状态', width: 100, type: 'status' },
{ prop: 'createDate', label: '发送时间', width: 180, type: 'text' } { prop: 'sendTime', label: '发送时间', width: 180, type: 'text' }
] ]
const messageMock = [ const messageMock = [
{ id: '1', title: '订单已发货', type: '订单通知', receiver: '深圳科技公司', status: 1, sendTime: now(0) }, { id: '1', title: '订单已发货', type: '订单通知', receiver: '深圳科技公司', status: 1, sendTime: now(0) },
@ -758,13 +757,13 @@ export const MOCK_TABLE_CONFIGS: Record<string, MockConfig> = {
'user-enterprise-score': { endpoint: '/user/enterprise/enterprise-score/page', title: '评分', columns: enterpriseScoreCols, mockData: enterpriseScoreMock, queryKey: 'enterpriseName', queryLabel: '企业名称' }, 'user-enterprise-score': { endpoint: '/user/enterprise/enterprise-score/page', title: '评分', columns: enterpriseScoreCols, mockData: enterpriseScoreMock, queryKey: 'enterpriseName', queryLabel: '企业名称' },
// user/finance (4) // user/finance (4)
'user-finance-recharge': { endpoint: '/user/accounttransaction/page', title: '充值管理', columns: rechargeCols, mockData: rechargeMock, queryKey: 'orderNo', queryLabel: '订单号' }, 'user-finance-recharge': { endpoint: '/user/finance/recharge/page', title: '充值管理', columns: rechargeCols, mockData: rechargeMock, queryKey: 'orderNo', queryLabel: '订单号' },
'user-finance-recharge-tx': { endpoint: '/user/accounttransaction/page', title: '提现管理', columns: rechargeTxCols, mockData: rechargeTxMock, queryKey: 'orderNo', queryLabel: '订单号' }, 'user-finance-recharge-tx': { endpoint: '/user/finance/recharge-tx/page', title: '提现管理', columns: rechargeTxCols, mockData: rechargeTxMock, queryKey: 'orderNo', queryLabel: '订单号' },
'user-finance-revenue': { endpoint: '/user/accounttransaction/page', title: '分账记录管理', columns: revenueCols, mockData: revenueMock }, 'user-finance-revenue': { endpoint: '/user/finance/revenue-sharing/page', title: '分账记录管理', columns: revenueCols, mockData: revenueMock },
'user-finance-transaction': { endpoint: '/user/accounttransaction/page', title: '交易流水', columns: transactionCols, mockData: transactionMock, queryKey: 'orderNo', queryLabel: '关联单号' }, 'user-finance-transaction': { endpoint: '/user/finance/transaction-record/page', title: '交易流水', columns: transactionCols, mockData: transactionMock, queryKey: 'orderNo', queryLabel: '关联单号' },
// user/message (2) // user/message (2)
'user-message': { endpoint: '/user/message/page', title: '系统消息', columns: messageCols, mockData: messageMock, queryKey: 'title', queryLabel: '消息标题' }, 'user-message': { endpoint: '/user/message/message/page', title: '系统消息', columns: messageCols, mockData: messageMock, queryKey: 'title', queryLabel: '消息标题' },
'user-message-handel': { endpoint: '/user/message/messageHandel/page', title: '待处理消息', columns: messageHandelCols, mockData: messageHandelMock, queryKey: 'title', queryLabel: '事项' }, 'user-message-handel': { endpoint: '/user/message/messageHandel/page', title: '待处理消息', columns: messageHandelCols, mockData: messageHandelMock, queryKey: 'title', queryLabel: '事项' },
// sys/log (3) // sys/log (3)
@ -780,7 +779,7 @@ export const MOCK_TABLE_CONFIGS: Record<string, MockConfig> = {
'sys-userManagement': { endpoint: '/sys/userManagement/page', title: '全国运营中心', columns: operationCols, mockData: userManagementMock, queryKey: 'name', queryLabel: '运营中心' }, 'sys-userManagement': { endpoint: '/sys/userManagement/page', title: '全国运营中心', columns: operationCols, mockData: userManagementMock, queryKey: 'name', queryLabel: '运营中心' },
// sys (4) // sys (4)
'sys-dict': { endpoint: '/sys/dict/type/page', title: '字典类型', columns: dictTypeCols, mockData: dictTypeMock, queryKey: 'dictType', queryLabel: '字典类型' }, 'sys-dict': { endpoint: '/sys/dict-type/page', title: '字典类型', columns: dictTypeCols, mockData: dictTypeMock, queryKey: 'dictType', queryLabel: '字典类型' },
'sys-notice': { endpoint: '/sys/notice/page', title: '通知公告', columns: noticeCols, mockData: noticeMock, queryKey: 'title', queryLabel: '标题' }, 'sys-notice': { endpoint: '/sys/notice/page', title: '通知公告', columns: noticeCols, mockData: noticeMock, queryKey: 'title', queryLabel: '标题' },
'sys-params': { endpoint: '/sys/params/page', title: '参数配置', columns: paramsCols, mockData: paramsMock, queryKey: 'paramKey', queryLabel: '参数键' }, 'sys-params': { endpoint: '/sys/params/page', title: '参数配置', columns: paramsCols, mockData: paramsMock, queryKey: 'paramKey', queryLabel: '参数键' },
'sys-notice-user': { endpoint: '/sys/notice-user/page', title: '用户通知', columns: noticeUserCols, mockData: noticeUserMock, queryKey: 'userName', queryLabel: '接收人' } 'sys-notice-user': { endpoint: '/sys/notice-user/page', title: '用户通知', columns: noticeUserCols, mockData: noticeUserMock, queryKey: 'userName', queryLabel: '接收人' }

View File

@ -12,7 +12,6 @@ import { ref, onMounted, watch, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
interface Inquiry { interface Inquiry {
id: string id: string
@ -123,9 +122,9 @@ watch(inquiryId, load)
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="询价详情" :sub="`询价单号: ${detail?.id || ''} · 发布时间: ${detail ? fmtDate(detail.createDate) : ''}`"> <div class="rr-page-header">
<el-button text @click="onBack">← 返回询价列表</el-button> <el-button text @click="onBack">← 返回询价列表</el-button>
</PageHeader> </div>
<div v-if="loading" class="rr-loading"><el-skeleton :rows="6" animated /></div> <div v-if="loading" class="rr-loading"><el-skeleton :rows="6" animated /></div>
<div v-else-if="!detail" class="rr-empty"><el-empty description="询价不存在" /></div> <div v-else-if="!detail" class="rr-empty"><el-empty description="询价不存在" /></div>
@ -213,6 +212,7 @@ watch(inquiryId, load)
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1400px; } .rr-page { max-width: 1400px; }
.rr-page-header { margin-bottom: 16px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 16px; } .rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 16px; }
.rr-card-header { padding: 16px 20px; border-bottom: 1px solid var(--border); } .rr-card-header { padding: 16px 20px; border-bottom: 1px solid var(--border); }
.rr-card-title { font-size: 15px; font-weight: 600; color: var(--ink); margin: 0; } .rr-card-title { font-size: 15px; font-weight: 600; color: var(--ink); margin: 0; }

View File

@ -7,13 +7,10 @@
* *
* 设计规范:§16 列表页完整模板 + §17.7 紧凑弹窗 + §17.8 主题色弹窗 + §17.9 列优化 * 设计规范:§16 列表页完整模板 + §17.7 紧凑弹窗 + §17.8 主题色弹窗 + §17.9 列优化
*/ */
import { onMounted, computed, reactive, ref } from 'vue' import { onMounted, computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } 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 SearchBar from '@/components/SearchBar/SearchBar.vue'
import ListTabs from '@/components/ListTabs/ListTabs.vue'
import { useInquiryStore, STATUS_OPTIONS } from '@/stores/inquiry' import { useInquiryStore, STATUS_OPTIONS } from '@/stores/inquiry'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
@ -30,10 +27,7 @@ interface InquiryRow {
const router = useRouter() const router = useRouter()
const store = useInquiryStore() const store = useInquiryStore()
const STATUS_TABS = STATUS_OPTIONS.map(o => ({ value: o.value, label: o.label, key: o.value, count: 0 })) const STATUS_TABS = STATUS_OPTIONS.map(o => ({ value: o.value, label: o.label }))
// 各 status 计数 (在当前结果中累加)
const stats = reactive<Record<string, number>>({ pending: 0, quoting: 0, done: 0, cancelled: 0, all: 0 })
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
pending: '待报价', pending: '待报价',
@ -90,9 +84,6 @@ async function load() {
raw: it raw: it
})) }))
total.value = arr.length + (page.value - 1) * limit.value total.value = arr.length + (page.value - 1) * limit.value
// 各状态计数 (基于当前页结果)
stats.all = list.value.length
list.value.forEach(r => { stats[r.status] = (stats[r.status] || 0) + 1 })
} catch (e: any) { } catch (e: any) {
error.value = e?.msg || e?.message || '后端接口异常(详见 console)' error.value = e?.msg || e?.message || '后端接口异常(详见 console)'
list.value = [] list.value = []
@ -103,12 +94,6 @@ async function load() {
} }
function onSearch() { page.value = 1; load() } function onSearch() { page.value = 1; load() }
const tabItems = computed(() => STATUS_TABS.map(t => ({
key: t.value,
label: t.label,
count: t.value === '' ? stats.all : (stats[t.value] || 0)
})))
function onReset() { filters.value.status = ''; filters.value.customer = ''; filters.value.searchKey = ''; page.value = 1; load() } function onReset() { filters.value.status = ''; filters.value.customer = ''; filters.value.searchKey = ''; page.value = 1; load() }
function onPageChange(p: number) { page.value = p; load() } function onPageChange(p: number) { page.value = p; load() }
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() } function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
@ -135,18 +120,39 @@ onMounted(() => { load() })
<template> <template>
<div class="rr-inquiry-list"> <div class="rr-inquiry-list">
<PageHeader title="询价管理" :sub="`共 ${total} 条询价`"> <div class="rr-page-header">
<el-button @click="onReset">重置</el-button> <div class="rr-page-titles">
</PageHeader> <h2 class="rr-page-title">询价管理</h2>
<p class="rr-page-sub">共 {{ total }} 条询价</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
</div>
</div>
<ListTabs v-model="filters.status" :items="tabItems" @change="onStatusTab" /> <div class="rr-tabs-row">
<div class="rr-tabs">
<SearchBar <div
v-model:filters="filters" v-for="t in STATUS_TABS"
:fields='[{ key: "searchKey", label: "", type: "text", placeholder: "搜索单号/客户/商品", clearable: true }]' :key="t.value"
:show-reset="false" class="rr-tab"
@search="onSearch" :class="{ active: filters.status === t.value }"
/> @click="onStatusTab(t.value)"
>{{ t.label }}</div>
</div>
<div class="rr-filters">
<el-input
v-model="filters.searchKey"
placeholder="搜索单号/客户/商品"
clearable
class="rr-search-input"
size="small"
:prefix-icon="'Search'"
@keyup.enter="onSearch"
/>
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
</div>
</div>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -156,7 +162,7 @@ onMounted(() => { load() })
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :page-sizes="[10, 20, 50, 100]" :show-pagination="true"
:error="error" :error="error"
empty-text="暂无询价" empty-text="暂无询价"
borderless borderless
@ -184,6 +190,91 @@ onMounted(() => { load() })
padding: 16px; padding: 16px;
} }
.rr-page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.rr-page-titles {
display: flex;
flex-direction: column;
gap: 2px;
}
.rr-page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.rr-page-sub {
margin: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.rr-page-actions {
display: flex;
gap: 8px;
}
.rr-tabs-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 16px;
}
.rr-tabs {
display: flex;
gap: 24px;
flex: 1;
}
.rr-tab {
position: relative;
padding: 6px 0;
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-secondary);
cursor: pointer;
user-select: none;
transition: color 0.15s ease;
}
.rr-tab:hover { color: var(--el-text-color-primary); }
.rr-tab.active {
color: var(--el-color-primary);
font-weight: 600;
}
.rr-tab.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 2px;
background: var(--el-color-primary);
border-radius: 1px;
}
.rr-filters {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.rr-search-input { width: 240px; }
.rr-list-card {
background: var(--el-bg-color);
border-radius: 12px;
padding: 4px;
}
.rr-time { .rr-time {
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 12px; font-size: 12px;
@ -201,8 +292,8 @@ onMounted(() => { load() })
height: 21.25px; height: 21.25px;
box-sizing: border-box; box-sizing: border-box;
} }
.rr-badge-pending { background: var(--amber-soft); color: var(--amber); } .rr-badge-pending { background: #FEF3C7; color: #D97706; }
.rr-badge-quoting { background: var(--brand-soft); color: var(--brand); } .rr-badge-quoting { background: #DBEAFE; color: #2563EB; }
.rr-badge-done { background: var(--green-soft); color: var(--green); } .rr-badge-done { background: #D1FAE5; color: #059669; }
.rr-badge-cancelled { background: var(--red-soft); color: var(--red); } .rr-badge-cancelled { background: #FEE2E2; color: #DC2626; }
</style> </style>

View File

@ -1,49 +0,0 @@
<script setup lang="ts">
/**
* F006 邮件日志 - P0 真 view
* 走 /sys/maillog/page (2026-08-07 探测: 200, total=0, 接口正常暂无数据)
* 字段(推测): id/mailCode/platform/toEmail/subject/content/status/creator/createDate
*/
import { onMounted, ref, reactive } from 'vue'
import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
interface MailLog { id: string; mailCode: string; platform: number; toEmail: string; subject: string; content: string; status: number; creator: number; createDate: string }
const STATUS_MAP: Record<number, { label: string; type: string }> = { 0: { label: '失败', type: 'danger' }, 1: { label: '成功', type: 'success' }, 2: { label: '等待', type: 'warning' } }
const list = ref<MailLog[]>([])
const total = ref(0)
const loading = ref(false)
const query = reactive({ page: 1, limit: 10 })
const columns = [
{ prop: 'id', label: 'ID', width: 80 },
{ prop: 'mailCode', label: '邮件编码', width: 130 },
{ prop: 'toEmail', label: '收件邮箱', minWidth: 180 },
{ prop: 'subject', label: '主题', minWidth: 200 },
{ prop: 'status', label: '状态', width: 90, slot: 'status' },
{ prop: 'creator', label: '操作人', width: 90 },
{ prop: 'createDate', label: '发送时间', width: 180 }
]
async function load() {
loading.value = true
try { const r: any = await httpGet('/sys/maillog/page', { page: query.page, limit: query.limit }); list.value = r?.list || []; total.value = Number(r?.total || 0) }
catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
function onPageChange(p: number) { query.page = p; load() }
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="邮件日志" :sub="`共 ${total} 条`" />
<div class="rr-card">
<DataTable :columns="columns" :data="list" :borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="true" row-key="id" empty-text="暂无数据" @page-change="onPageChange" @limit-change="onLimitChange">
<template #status="{ row }"><el-tag :type="(STATUS_MAP[row.status]?.type as any) || 'info'">{{ STATUS_MAP[row.status]?.label || row.status }}</el-tag></template>
</DataTable>
</div>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 1400px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
</style>

View File

@ -1,44 +0,0 @@
<script setup lang="ts">
/**
* F006 邮件模板 - P0 真 view
* 走 /sys/mailtemplate/page (2026-08-07 探测: 200, total=1)
* 字段: id/name/subject/content/createDate
*/
import { onMounted, ref, reactive } from 'vue'
import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
interface MailTpl { id: string; name: string; subject: string; content: string; createDate: string }
const list = ref<MailTpl[]>([])
const total = ref(0)
const loading = ref(false)
const query = reactive({ page: 1, limit: 10 })
const columns = [
{ prop: 'id', label: 'ID', width: 80 },
{ prop: 'name', label: '模板名称', minWidth: 200 },
{ prop: 'subject', label: '邮件主题', minWidth: 200 },
{ prop: 'content', label: '内容', minWidth: 200, formatter: (r: MailTpl) => r.content ? r.content.slice(0, 50) + '...' : '--' },
{ prop: 'createDate', label: '创建时间', width: 180 }
]
async function load() {
loading.value = true
try { const r: any = await httpGet('/sys/mailtemplate/page', { page: query.page, limit: query.limit }); list.value = r?.list || []; total.value = Number(r?.total || 0) }
catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
function onPageChange(p: number) { query.page = p; load() }
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="邮件模板" :sub="`共 ${total} 条`" />
<div class="rr-card">
<DataTable :columns="columns" :data="list" :borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="true" row-key="id" empty-text="暂无数据" @page-change="onPageChange" @limit-change="onLimitChange" />
</div>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 1400px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
</style>

View File

@ -1,48 +0,0 @@
<script setup lang="ts">
/**
* F006 短信配置 - P0 真 view
* 走 /sys/sms/page (2026-08-07 探测: 200, total=2)
* 字段: id/smsCode/platform/remark/config/createDate
*/
import { onMounted, ref, reactive } from 'vue'
import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
interface Sms { id: string; smsCode: string; platform: number; remark: string; config: string; createDate: string }
const PLATFORM_MAP: Record<number, { label: string; type: string }> = { 0: { label: '阿里云', type: 'success' }, 1: { label: '腾讯云', type: 'success' }, 2: { label: '七牛云', type: 'success' }, 4: { label: '又拍云', type: 'success' } }
const list = ref<Sms[]>([])
const total = ref(0)
const loading = ref(false)
const query = reactive({ page: 1, limit: 10 })
const columns = [
{ prop: 'id', label: 'ID', width: 80 },
{ prop: 'smsCode', label: '短信编码', minWidth: 150 },
{ prop: 'platform', label: '平台', width: 120, slot: 'platform' },
{ prop: 'remark', label: '备注', minWidth: 200, formatter: (r: Sms) => r.remark || '--' },
{ prop: 'config', label: '配置', minWidth: 200, formatter: (r: Sms) => r.config ? r.config.slice(0, 50) + '...' : '--' },
{ prop: 'createDate', label: '创建时间', width: 180 }
]
async function load() {
loading.value = true
try { const r: any = await httpGet('/sys/sms/page', { page: query.page, limit: query.limit }); list.value = r?.list || []; total.value = Number(r?.total || 0) }
catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
function onPageChange(p: number) { query.page = p; load() }
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="短信配置" :sub="`共 ${total} 条`" />
<div class="rr-card">
<DataTable :columns="columns" :data="list" :borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="true" row-key="id" empty-text="暂无数据" @page-change="onPageChange" @limit-change="onLimitChange">
<template #platform="{ row }"><el-tag :type="(PLATFORM_MAP[row.platform]?.type as any) || 'info'">{{ PLATFORM_MAP[row.platform]?.label || row.platform }}</el-tag></template>
</DataTable>
</div>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 1400px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
</style>

View File

@ -1,49 +0,0 @@
<script setup lang="ts">
/**
* F006 短信日志 - P0 真 view
* 走 /sys/smslog/page (2026-08-07 探测: 200, total=1275)
* 字段: id/smsCode/platform/mobile/params1-4/status/creator/createDate
*/
import { onMounted, ref, reactive } from 'vue'
import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
interface SmsLog { id: string; smsCode: string; platform: number; mobile: string; params1: string; params2: string; params3: string; params4: string; status: number; creator: number; createDate: string }
const STATUS_MAP: Record<number, { label: string; type: string }> = { 0: { label: '失败', type: 'danger' }, 1: { label: '成功', type: 'success' }, 2: { label: '等待', type: 'warning' } }
const list = ref<SmsLog[]>([])
const total = ref(0)
const loading = ref(false)
const query = reactive({ page: 1, limit: 10 })
const columns = [
{ prop: 'id', label: 'ID', width: 80 },
{ prop: 'smsCode', label: '短信编码', width: 130 },
{ prop: 'mobile', label: '手机号', width: 140 },
{ prop: 'params1', label: '参数1', minWidth: 120, formatter: (r: SmsLog) => r.params1 || '--' },
{ prop: 'status', label: '状态', width: 90, slot: 'status' },
{ prop: 'creator', label: '操作人', width: 90 },
{ prop: 'createDate', label: '发送时间', width: 180 }
]
async function load() {
loading.value = true
try { const r: any = await httpGet('/sys/smslog/page', { page: query.page, limit: query.limit }); list.value = r?.list || []; total.value = Number(r?.total || 0) }
catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
function onPageChange(p: number) { query.page = p; load() }
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="短信日志" :sub="`共 ${total} 条`" />
<div class="rr-card">
<DataTable :columns="columns" :data="list" :borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="true" row-key="id" empty-text="暂无数据" @page-change="onPageChange" @limit-change="onLimitChange">
<template #status="{ row }"><el-tag :type="(STATUS_MAP[row.status]?.type as any) || 'info'">{{ STATUS_MAP[row.status]?.label || row.status }}</el-tag></template>
</DataTable>
</div>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 1400px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
</style>

View File

@ -16,7 +16,7 @@
* *
* 后端调 /sys/openapi/app/apply * 后端调 /sys/openapi/app/apply
*/ */
import { ref, reactive, watch } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { httpGet, httpPost } from '@/utils/request' import { httpGet, httpPost } from '@/utils/request'
@ -32,8 +32,9 @@ interface ApplyResult {
customerName: string customerName: string
customerCode: string customerCode: string
appId: string appId: string
connectCode: string
appSecret: string appSecret: string
mcpEndpoint: string expireAt: string
} }
const props = defineProps<{ modelValue: boolean }>() const props = defineProps<{ modelValue: boolean }>()
@ -70,14 +71,10 @@ const form = reactive({
const customers = ref<Customer[]>([]) const customers = ref<Customer[]>([])
const loadingCustomers = ref(false) const loadingCustomers = ref(false)
// F002-W18 修: 后端已支持 name 模糊搜, 前端把 searchKey 传给后端, 不再本地 filter async function loadCustomers() {
async function loadCustomers(searchKey?: string) {
loadingCustomers.value = true loadingCustomers.value = true
try { try {
const data = await httpGet<Customer[]>('/sys/openapi/customer/list', { const data = await httpGet<Customer[]>('/sys/openapi/customer/list')
name: searchKey || undefined,
limit: 1000
})
customers.value = Array.isArray(data) ? data : [] customers.value = Array.isArray(data) ? data : []
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.msg || e?.message || '加载客户列表失败') ElMessage.error(e?.msg || e?.message || '加载客户列表失败')
@ -89,13 +86,13 @@ async function loadCustomers(searchKey?: string) {
watch(visible, v => { if (v) loadCustomers() }) watch(visible, v => { if (v) loadCustomers() })
// F002-W18 修: searchKey 变化 debounce 300ms 调后端 const filteredCustomers = computed(() => {
let searchTimer: ReturnType<typeof setTimeout> | null = null if (!form.searchKey) return customers.value
watch(() => form.searchKey, (val) => { const k = form.searchKey.toLowerCase()
if (searchTimer) clearTimeout(searchTimer) return customers.value.filter(c =>
searchTimer = setTimeout(() => { (c.customerName || '').toLowerCase().includes(k) ||
loadCustomers(val || undefined) (c.customerCode || '').toLowerCase().includes(k)
}, 300) )
}) })
function onSelectionChange(rows: Customer[]) { function onSelectionChange(rows: Customer[]) {
@ -132,6 +129,7 @@ async function submit() {
const res = await httpPost<ApplyResult[]>('/sys/openapi/app/apply', payload) const res = await httpPost<ApplyResult[]>('/sys/openapi/app/apply', payload)
result.value = Array.isArray(res) ? res : [] result.value = Array.isArray(res) ? res : []
ElMessage.success(`已为 ${result.value.length} 个客户生成 APIKey`) ElMessage.success(`已为 ${result.value.length} 个客户生成 APIKey`)
emit('refresh')
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.msg || e?.message || '申请失败') ElMessage.error(e?.msg || e?.message || '申请失败')
} finally { } finally {
@ -139,38 +137,14 @@ async function submit() {
} }
} }
function onClose() { updateVisible(false); if (result.value) emit("refresh") } function onClose() { updateVisible(false) }
async function copySecret(text: string) { async function copySecret(text: string) {
// 优先用现代 Clipboard API (HTTPS / localhost 才允许)
// frp 走 http://direv.cloud:8080 是非安全上下文, clipboard API 不可用, 需降级
try { try {
if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text)
await navigator.clipboard.writeText(text) ElMessage.success('已复制')
ElMessage.success('已复制')
return
}
throw new Error('no clipboard api')
} catch { } catch {
// 降级方案: document.execCommand('copy') - HTTP 下也能用 ElMessage.error('复制失败,请手动选择文本')
try {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
ta.style.pointerEvents = 'none'
document.body.appendChild(ta)
ta.select()
const ok = document.execCommand('copy')
document.body.removeChild(ta)
if (ok) {
ElMessage.success('已复制')
} else {
ElMessage.warning('浏览器阻止自动复制,请手动双击输入框选中后 Cmd/Ctrl+C')
}
} catch {
ElMessage.warning('浏览器阻止自动复制,请手动双击输入框选中后 Cmd/Ctrl+C')
}
} }
} }
@ -250,21 +224,20 @@ function notifyContact() {
/> />
<el-table <el-table
ref="customerTableRef" ref="customerTableRef"
:data="customers" :data="filteredCustomers"
border border
size="small" size="small"
max-height="320" max-height="320"
:loading="loadingCustomers" :loading="loadingCustomers"
:row-style="{ height: '36px' }" :row-style="{ height: '36px' }"
:cell-style="{ padding: '4px 8px', fontSize: '12.5px' }" :cell-style="{ padding: '4px 8px', fontSize: '12.5px' }"
:header-cell-style="{ padding: '6px 8px', fontSize: '12px', height: '32px', background: 'var(--surface-2)' }" :header-cell-style="{ padding: '6px 8px', fontSize: '12px', height: '32px', background: '#F8FAFC' }"
class="rr-apply-table"
@selection-change="onSelectionChange" @selection-change="onSelectionChange"
> >
<el-table-column type="selection" header-align="center" align="center" width="48" /> <el-table-column type="selection" header-align="center" align="center" width="40" />
<el-table-column prop="customerName" label="公司名称" min-width="200" header-align="left" align="left" show-overflow-tooltip /> <el-table-column prop="customerName" label="客户名" min-width="180" header-align="center" align="center" show-overflow-tooltip />
<el-table-column prop="contactName" label="联系人" width="100" header-align="center" align="center" /> <el-table-column prop="contactName" label="联系人" width="100" align="center" />
<el-table-column prop="contactPhone" label="联系电话" width="140" header-align="center" align="center" /> <el-table-column prop="contactPhone" label="联系电话" width="130" align="center" />
</el-table> </el-table>
</div> </div>
@ -347,34 +320,33 @@ function notifyContact() {
<el-descriptions-item label="备注">{{ form.remark || '—' }}</el-descriptions-item> <el-descriptions-item label="备注">{{ form.remark || '—' }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
<el-alert type="info" :closable="false" style="margin-top: 16px;"> <el-alert type="info" :closable="false" style="margin-top: 16px;">
后端将自动创建公司账号 + 生成 APIKey 后端将自动创建公司账号 + 生成 connect_code(2h 有效)
</el-alert> </el-alert>
</div> </div>
<!-- Step 3: 生成结果 --> <!-- Step 3: 生成结果 -->
<div v-if="step === 3 && result"> <div v-if="step === 3 && result">
<el-alert type="warning" :closable="false" style="margin-bottom: 16px;"> <el-alert type="warning" :closable="false" style="margin-bottom: 16px;">
请立即复制 MCP地址、appId 和 appSecret!关弹窗后 appSecret 无法再次查看 请立即复制 connectCode 和 appSecret!关弹窗后无法再次查看
</el-alert> </el-alert>
<p>已为 {{ result.length }} 个客户生成 APIKey:</p> <p>已为 {{ result.length }} 个客户生成 APIKey:</p>
<el-table :data="result" border class="rr-apply-table rr-result-table" size="small"> <el-table :data="result" border>
<el-table-column prop="customerName" label="公司名称" width="120" show-overflow-tooltip /> <el-table-column prop="customerName" label="客户名" width="160" />
<el-table-column prop="appId" label="AppID" min-width="180" show-overflow-tooltip> <el-table-column prop="appId" label="AppID" width="280">
<template #default="{ row }"> <template #default="{ row }">
<code class="rr-cell-code">{{ row.appId }}</code> <code>{{ row.appId }}</code>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="mcpEndpoint" label="MCP地址" min-width="220" show-overflow-tooltip> <el-table-column prop="connectCode" label="ConnectCode">
<template #default="{ row }"> <template #default="{ row }">
<code class="rr-cell-code">{{ row.mcpEndpoint || '—' }}</code> <code class="rr-code-accent">{{ row.connectCode }}</code>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="AppSecret" min-width="200"> <el-table-column prop="expireAt" label="过期时间" width="180" />
<el-table-column label="AppSecret" min-width="320">
<template #default="{ row }"> <template #default="{ row }">
<div class="rr-secret-cell"> <el-input :model-value="row.appSecret" readonly type="password" size="small" show-password style="width: 220px; margin-right: 8px;" />
<el-input :model-value="row.appSecret" readonly type="password" size="small" show-password /> <el-button size="small" type="primary" @click="copySecret(row.appSecret)">复制</el-button>
<el-button size="small" type="primary" @click="copySecret(row.appSecret)">复制</el-button>
</div>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -506,43 +478,4 @@ function notifyContact() {
} }
.rr-code-accent { color: var(--red); font-family: ui-monospace, monospace; } .rr-code-accent { color: var(--red); font-family: ui-monospace, monospace; }
/* 弹窗内表格圆角 + 边框颜色统一 (修复圆角缺失) */
:deep(.rr-apply-dialog .rr-apply-table) {
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border) !important;
}
:deep(.rr-apply-dialog .rr-apply-table .el-table__inner-wrapper) {
border-radius: 8px;
}
/* selection 列 checkbox 居中 */
:deep(.rr-apply-dialog .rr-apply-table .el-table-column--selection .cell) {
text-align: center;
padding: 0;
}
:deep(.rr-apply-dialog .rr-apply-table th.el-table-column--selection .cell) {
text-align: center;
}
/* Step3 结果表格: AppSecret 单元格紧凑布局 */
.rr-secret-cell {
display: flex;
flex-direction: column;
gap: 4px;
padding: 2px 0;
}
.rr-secret-cell .el-input {
width: 100%;
}
/* code 文本省略, 不撑列宽 */
.rr-cell-code {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
vertical-align: middle;
}
</style> </style>

View File

@ -26,8 +26,6 @@ import { onMounted, ref, reactive, computed } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
interface AuditLog { interface AuditLog {
@ -295,9 +293,16 @@ onMounted(() => {
<template> <template>
<div class="rr-openapi-audit"> <div class="rr-openapi-audit">
<PageHeader title="审计日志" :sub="`共 ${total} 条调用记录`"> <div class="rr-page-header">
<el-button @click="onReset">重置</el-button> <div class="rr-page-titles">
</PageHeader> <h2 class="rr-page-title">审计日志</h2>
<p class="rr-page-sub">共 {{ total }} 条调用记录</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
<el-button type="primary" class="rr-btn-primary" @click="onSearch">查询</el-button>
</div>
</div>
<div class="rr-tabs-row"> <div class="rr-tabs-row">
<div class="rr-tabs"> <div class="rr-tabs">
@ -309,23 +314,59 @@ onMounted(() => {
@click="onStatusTab(t.value)" @click="onStatusTab(t.value)"
>{{ t.label }}</div> >{{ t.label }}</div>
</div> </div>
<div class="rr-filters">
<el-input
v-model="filters.searchKey"
placeholder="搜索 工具 / 请求ID / 摘要"
clearable
class="rr-search-input"
size="small"
:prefix-icon="'Search'"
@keyup.enter="onSearch"
/>
<el-select
v-model="filters.customerCode"
placeholder="客户"
class="rr-customer-select"
clearable
filterable
size="small"
>
<el-option
v-for="opt in customerOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select
v-model="filters.appId"
placeholder="App"
class="rr-app-select"
clearable
filterable
size="small"
>
<el-option
v-for="opt in appOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-select
v-model="filters.callType"
placeholder="类型"
class="rr-type-select"
clearable
size="small"
>
<el-option value="MCP" label="MCP" />
<el-option value="REST" label="REST" />
</el-select>
</div>
</div> </div>
<SearchBar
v-model:filters="filters"
:fields='[
{ key: "searchKey", label: "", type: "text", placeholder: "搜索 工具 / 请求ID / 摘要", clearable: true },
{ key: "customerCode", label: "客户", type: "select", placeholder: "客户", clearable: true,
options: customerOptions.map(o => ({ value: o.value, label: o.label })) },
{ key: "appId", label: "App", type: "select", placeholder: "App", clearable: true,
options: appOptions.map(o => ({ value: o.value, label: o.label })) },
{ key: "callType", label: "类型", type: "select", placeholder: "类型", clearable: true,
options: [{ value: "MCP", label: "MCP" }, { value: "REST", label: "REST" }] }
]'
:show-reset="false"
@search="onSearch"
/>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
:columns="[ :columns="[
@ -345,7 +386,7 @@ onMounted(() => {
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :page-sizes="[10, 20, 50, 100]" :show-pagination="true"
:error="loadError" :error="loadError"
empty-text="暂无审计记录" empty-text="暂无审计记录"
borderless borderless
@ -492,15 +533,36 @@ onMounted(() => {
padding: 16px; padding: 16px;
} }
/* PageHeader/SearchBar 接管 */ .rr-page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
/* PageHeader/SearchBar 接管 */ .rr-page-titles {
display: flex;
flex-direction: column;
gap: 2px;
}
/* PageHeader/SearchBar 接管 */ .rr-page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
/* PageHeader/SearchBar 接管 */ .rr-page-sub {
margin: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
/* PageHeader/SearchBar 接管 */ .rr-page-actions {
display: flex;
gap: 8px;
}
.rr-tabs-row { .rr-tabs-row {
display: flex; display: flex;
@ -542,12 +604,16 @@ onMounted(() => {
border-radius: 1px; border-radius: 1px;
} }
/* PageHeader/SearchBar 接管 */ .rr-filters {
display: flex;
gap: 8px;
flex-shrink: 0;
}
/* PageHeader/SearchBar 接管 */ .rr-search-input { width: 240px; }
/* PageHeader/SearchBar 接管 */ .rr-customer-select { width: 180px; }
/* PageHeader/SearchBar 接管 */ .rr-app-select { width: 180px; }
/* PageHeader/SearchBar 接管 */ .rr-type-select { width: 90px; }
.rr-list-card { .rr-list-card {
background: var(--el-bg-color); background: var(--el-bg-color);

View File

@ -21,10 +21,7 @@ import { onMounted, ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { httpGet, httpPost } from '@/utils/request' import { httpGet, httpPost } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
interface CompanyAccount { interface CompanyAccount {
id: string id: string
@ -242,20 +239,34 @@ onMounted(() => { load(); loadCustomers() })
<template> <template>
<div class="rr-openapi-company"> <div class="rr-openapi-company">
<PageHeader title="公司账号管理" :sub="`共 ${total} 个公司账号`"> <div class="rr-page-header">
<el-button @click="onReset">重置</el-button> <div class="rr-page-titles">
</PageHeader> <h2 class="rr-page-title">公司账号管理</h2>
<p class="rr-page-sub">共 {{ total }} 个公司账号</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
</div>
</div>
<SearchBar <div class="rr-filters-row">
v-model:filters="filters" <el-select
:fields='[{ v-model="filters.customerCode"
key: "customerCode", label: "客户筛选", type: "select", placeholder="选择客户筛选"
placeholder: "选择客户筛选", clearable: true, class="rr-customer-select"
options: customerOptions.map(o => ({ value: o.value, label: o.label })) clearable
}]' filterable
@search="onSearch" size="small"
@reset="load" >
/> <el-option
v-for="opt in customerOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
</div>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -267,14 +278,14 @@ onMounted(() => { load(); loadCustomers() })
{ prop: 'activitySummary', label: '动态摘要', width: 280 }, { prop: 'activitySummary', label: '动态摘要', width: 280 },
{ prop: 'status', label: '状态', width: 100, slot: 'status' }, { prop: 'status', label: '状态', width: 100, slot: 'status' },
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' }, { prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
{ label: '操作', width: 140, slot: 'action' } { label: '操作', width: 280, slot: 'action' }
]" ]"
:data="list" :borderless="true" :data="list"
:loading="loading" :loading="loading"
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :page-sizes="[10, 20, 50, 100]" :show-pagination="true"
:error="loadError" :error="loadError"
empty-text="暂无公司账号数据" empty-text="暂无公司账号数据"
row-key="id" row-key="id"
@ -300,14 +311,24 @@ onMounted(() => { load(); loadCustomers() })
</template> </template>
<template #action="{ row }"> <template #action="{ row }">
<TableActions <el-button
:row="row" text
:actions="[ type="primary"
{ label: '查看动态', type: 'primary', onClick: activityHandle }, size="small"
{ label: '接管', type: 'primary', onClick: takeoverHandle }, @click="activityHandle(row)"
{ label: '解绑', type: 'danger', onClick: unbindHandle } >查看动态</el-button>
]" <el-button
/> text
type="primary"
size="small"
@click="takeoverHandle(row)"
>接管</el-button>
<el-button
text
type="primary"
size="small"
@click="unbindHandle(row)"
>解绑</el-button>
</template> </template>
</DataTable> </DataTable>
</div> </div>
@ -400,19 +421,50 @@ onMounted(() => { load(); loadCustomers() })
padding: 16px; padding: 16px;
} }
/* PageHeader 接管 */ .rr-page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
/* PageHeader 接管 */ .rr-page-titles {
display: flex;
flex-direction: column;
gap: 2px;
}
/* PageHeader 接管 */ .rr-page-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
/* PageHeader 接管 */ .rr-page-sub {
margin: 0;
font-size: 12px;
color: var(--el-text-color-secondary);
}
/* PageHeader 接管 */ .rr-page-actions {
display: flex;
gap: 8px;
}
/* SearchBar 接管 */ .rr-filters-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-radius: 8px;
}
/* SearchBar 接管 */ .rr-customer-select {
width: 280px;
}
.rr-list-card { .rr-list-card {
background: var(--el-bg-color); background: var(--el-bg-color);

View File

@ -6,9 +6,10 @@
* - 操作列(根据状态显示): * - 操作列(根据状态显示):
* * 详情(任意状态,弹窗) * * 详情(任意状态,弹窗)
* * 重置密钥(status=0 或 1) * * 重置密钥(status=0 或 1)
* * 重发二维码(status=0 或 1, W4.4 后端补 resend-qr)
* * 撤销(status=0 或 1) * * 撤销(status=0 或 1)
* * 删除(status=2) * * 删除(status=2)
* - 4 个弹窗:详情/撤销确认/删除确认/重置密钥 * - 5 个弹窗:详情/撤销确认/删除确认/重置密钥/重发连接码
* - 申请 APIKey 走 ApplyApiKeyDialog 2 步向导 * - 申请 APIKey 走 ApplyApiKeyDialog 2 步向导
* *
* 后端端点: * 后端端点:
@ -22,10 +23,7 @@
import { onMounted, ref, reactive, computed } from 'vue' import { onMounted, ref, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpPost } from '@/utils/request' import { httpGet, httpPost } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
import ApplyApiKeyDialog from './ApplyApiKeyDialog.vue' import ApplyApiKeyDialog from './ApplyApiKeyDialog.vue'
interface OpenApp { interface OpenApp {
@ -39,6 +37,8 @@ interface OpenApp {
totalCallCount: number | string totalCallCount: number | string
lastUsedAt?: string | null lastUsedAt?: string | null
createDate: string createDate: string
connectCode?: string
connectCodeExpire?: string | number
activatedAt?: string | null activatedAt?: string | null
lastUsedIp?: string | null lastUsedIp?: string | null
ipWhitelist?: string | null ipWhitelist?: string | null
@ -88,6 +88,11 @@ const resetSecretVisible = ref(false)
const resetSecretTarget = ref<OpenApp | null>(null) const resetSecretTarget = ref<OpenApp | null>(null)
const newSecret = ref('') const newSecret = ref('')
const resendQrVisible = ref(false)
const resendQrTarget = ref<OpenApp | null>(null)
const newConnectCode = ref('')
const newConnectCodeExpire = ref('')
function fmtDate(ts?: string | number | null) { function fmtDate(ts?: string | number | null) {
if (!ts) return '--' if (!ts) return '--'
const n = typeof ts === 'string' ? Date.parse(ts) : ts const n = typeof ts === 'string' ? Date.parse(ts) : ts
@ -119,6 +124,8 @@ async function load() {
totalCallCount: it.totalCallCount ?? 0, totalCallCount: it.totalCallCount ?? 0,
lastUsedAt: it.lastUsedAt, lastUsedAt: it.lastUsedAt,
createDate: it.createDate, createDate: it.createDate,
connectCode: it.connectCode,
connectCodeExpire: it.connectCodeExpire,
activatedAt: it.activatedAt, activatedAt: it.activatedAt,
lastUsedIp: it.lastUsedIp, lastUsedIp: it.lastUsedIp,
ipWhitelist: it.ipWhitelist, ipWhitelist: it.ipWhitelist,
@ -150,14 +157,12 @@ async function loadCustomers() {
} }
const columns = [ const columns = [
{ prop: 'customerName', label: '客户名', minWidth: 150 }, { prop: 'customerName', label: '客户名', minWidth: 180 },
{ prop: 'appName', label: '应用名称', minWidth: 220 }, { prop: 'appName', label: '应用名称', minWidth: 180 },
{ prop: 'appId', label: 'AppID', width: 240, showOverflowTooltip: true }, { prop: 'appId', label: 'AppID', minWidth: 220 },
{ prop: 'totalCallCount', label: '调用次数', width: 100, align: 'right' as const, formatter: (row: any) => row.totalCallCount ?? '--' }, { prop: 'status', label: '状态', width: 100, slot: 'status' as const },
{ prop: 'status', label: '状态', width: 90, slot: 'status' as const },
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' as const }, { prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' as const },
{ prop: 'lastUsedAt', label: '最后调用', width: 170, formatter: (row: any) => fmtDate(row.lastUsedAt) }, { label: '操作', width: 280, slot: 'action' as const }
{ label: '操作', width: 220, align: 'center' as const, fixed: 'right' as const, slot: 'action' as const }
] ]
function onSearch() { page.value = 1; load() } function onSearch() { page.value = 1; load() }
@ -201,7 +206,7 @@ async function revokeConfirm() {
} }
try { try {
await httpPost('/sys/openapi/app/revoke', { await httpPost('/sys/openapi/app/revoke', {
id: String(revokeTarget.value.id), id: Number(revokeTarget.value.id),
reason: revokeReason.value reason: revokeReason.value
}) })
ElMessage.success('撤销成功') ElMessage.success('撤销成功')
@ -221,7 +226,7 @@ function deleteHandle(row: OpenApp) {
async function deleteConfirm() { async function deleteConfirm() {
if (!deleteTarget.value) return if (!deleteTarget.value) return
try { try {
await httpPost('/sys/openapi/app/delete', { id: String(deleteTarget.value.id) }) await httpPost('/sys/openapi/app/delete', { id: Number(deleteTarget.value.id) })
ElMessage.success('删除成功') ElMessage.success('删除成功')
deleteVisible.value = false deleteVisible.value = false
deleteTarget.value = null deleteTarget.value = null
@ -248,6 +253,29 @@ async function resetSecretConfirm() {
} }
} }
function resendQrHandle(row: OpenApp) {
resendQrTarget.value = row
newConnectCode.value = ''
newConnectCodeExpire.value = ''
resendQrVisible.value = true
}
async function resendQrConfirm() {
if (!resendQrTarget.value) return
try {
const data = await httpPost<{ connectCode: string; connectCodeExpire: string | number }>(
`/sys/openapi/app/resend-qr/${resendQrTarget.value.id}`
)
newConnectCode.value = data?.connectCode || ''
newConnectCodeExpire.value = data?.connectCodeExpire
? String(data.connectCodeExpire)
: ''
ElMessage.success('连接码已重发,旧码已作废')
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '重发失败')
}
}
async function copyText(text: string, label = '已复制') { async function copyText(text: string, label = '已复制') {
// 优先用现代 Clipboard API (HTTPS / localhost 才允许) // 优先用现代 Clipboard API (HTTPS / localhost 才允许)
try { try {
@ -293,10 +321,16 @@ onMounted(() => { load(); loadCustomers() })
<template> <template>
<div class="rr-openapi-app"> <div class="rr-openapi-app">
<PageHeader title="应用列表" :sub="`共 ${total} 个集成应用`"> <div class="rr-page-header">
<el-button @click="onReset">重置</el-button> <div class="rr-page-titles">
<el-button type="primary" @click="onApplyDialogOpen">+ 申请新 APIKey</el-button> <h2 class="rr-page-title">应用列表</h2>
</PageHeader> <p class="rr-page-sub">共 {{ total }} 个集成应用</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
<el-button type="primary" class="rr-btn-primary" @click="onApplyDialogOpen">+ 申请新 APIKey</el-button>
</div>
</div>
<div class="rr-tabs-row"> <div class="rr-tabs-row">
<div class="rr-tabs"> <div class="rr-tabs">
@ -308,19 +342,33 @@ onMounted(() => { load(); loadCustomers() })
@click="onStatusTab(t.value)" @click="onStatusTab(t.value)"
>{{ t.label }}</div> >{{ t.label }}</div>
</div> </div>
<div class="rr-filters">
<el-input
v-model="filters.searchKey"
placeholder="搜索 app 名称 / app_id / 客户名"
clearable
class="rr-search-input"
:prefix-icon="'Search'"
@keyup.enter="onSearch"
/>
<el-select
v-model="filters.customerCode"
placeholder="客户"
class="rr-customer-select"
clearable
filterable
>
<el-option
v-for="opt in customerOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button class="rr-btn-secondary" @click="onSearch">查询</el-button>
</div>
</div> </div>
<SearchBar
v-model:filters="filters"
:fields='[
{ key: "searchKey", label: "", type: "text", placeholder: "搜索 app 名称 / app_id / 客户名", clearable: true },
{ key: "customerCode", label: "客户", type: "select", placeholder: "客户", clearable: true,
options: customerOptions.map(o => ({ value: o.value, label: o.label })) }
]'
@search="onSearch"
@reset="load"
/>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
:columns="columns" :columns="columns"
@ -341,15 +389,35 @@ onMounted(() => { load(); loadCustomers() })
{{ fmtDate(row.createDate) }} {{ fmtDate(row.createDate) }}
</template> </template>
<template #action="{ row }"> <template #action="{ row }">
<TableActions <el-button text type="primary" size="small" @click="detailHandle(row)">详情</el-button>
:row="row" <el-button
:actions="[ v-if="row.status === 0 || row.status === 1"
{ label: '详情', type: 'primary', onClick: detailHandle }, text
{ label: '重置密钥', type: 'warning', when: (r: any) => r.status === 0 || r.status === 1, onClick: resetSecretHandle }, type="primary"
{ label: '撤销', type: 'danger', divided: true, when: (r: any) => r.status === 0 || r.status === 1, onClick: revokeHandle }, size="small"
{ label: '删除', type: 'danger', divided: true, when: (r: any) => r.status === 2, onClick: deleteHandle } @click="resendQrHandle(row)"
]" >重发二维码</el-button>
/> <el-button
v-if="row.status === 0 || row.status === 1"
text
type="warning"
size="small"
@click="resetSecretHandle(row)"
>重置密钥</el-button>
<el-button
v-if="row.status === 0 || row.status === 1"
text
size="small"
class="rr-danger-text"
@click="revokeHandle(row)"
>撤销</el-button>
<el-button
v-if="row.status === 2"
text
size="small"
class="rr-danger-text"
@click="deleteHandle(row)"
>删除</el-button>
</template> </template>
</DataTable> </DataTable>
</div> </div>
@ -362,15 +430,16 @@ onMounted(() => { load(); loadCustomers() })
> >
<el-descriptions v-if="currentRow" :column="1" border> <el-descriptions v-if="currentRow" :column="1" border>
<el-descriptions-item label="AppID">{{ currentRow.appId }}</el-descriptions-item> <el-descriptions-item label="AppID">{{ currentRow.appId }}</el-descriptions-item>
<el-descriptions-item label="MCP地址">
<code>{{ (currentRow as any).mcpEndpoint || '—' }}</code>
</el-descriptions-item>
<el-descriptions-item label="应用名称">{{ currentRow.appName }}</el-descriptions-item> <el-descriptions-item label="应用名称">{{ currentRow.appName }}</el-descriptions-item>
<el-descriptions-item label="客户名称">{{ currentRow.customerName || currentRow.customerCode || '—' }}</el-descriptions-item> <el-descriptions-item label="客户名称">{{ currentRow.customerName || currentRow.customerCode || '—' }}</el-descriptions-item>
<el-descriptions-item label="客户标识">{{ currentRow.customerCode }}</el-descriptions-item> <el-descriptions-item label="客户标识">{{ currentRow.customerCode }}</el-descriptions-item>
<el-descriptions-item label="状态"> <el-descriptions-item label="状态">
<span :class="['rr-badge', `rr-badge-${currentRow.status}`]">{{ STATUS_LABEL[currentRow.status] || currentRow.status }}</span> <span :class="['rr-badge', `rr-badge-${currentRow.status}`]">{{ STATUS_LABEL[currentRow.status] || currentRow.status }}</span>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="ConnectCode">
<code class="rr-code-accent">{{ currentRow.connectCode || '—' }}</code>
</el-descriptions-item>
<el-descriptions-item label="ConnectCode 过期">{{ fmtDate(currentRow.connectCodeExpire) }}</el-descriptions-item>
<el-descriptions-item label="权限范围">{{ currentRow.scopes }}</el-descriptions-item> <el-descriptions-item label="权限范围">{{ currentRow.scopes }}</el-descriptions-item>
<el-descriptions-item label="IP 白名单">{{ currentRow.ipWhitelist || '—' }}</el-descriptions-item> <el-descriptions-item label="IP 白名单">{{ currentRow.ipWhitelist || '—' }}</el-descriptions-item>
<el-descriptions-item label="激活时间">{{ currentRow.activatedAt || '—' }}</el-descriptions-item> <el-descriptions-item label="激活时间">{{ currentRow.activatedAt || '—' }}</el-descriptions-item>
@ -428,17 +497,18 @@ onMounted(() => { load(); loadCustomers() })
<p style="margin-bottom: 6px; font-size: 13px; color: var(--ink-2);"> <p style="margin-bottom: 6px; font-size: 13px; color: var(--ink-2);">
新的 AppSecret: <span style="color: var(--ink-3); font-size: 12px;">(明文, 可直接选中复制)</span> 新的 AppSecret: <span style="color: var(--ink-3); font-size: 12px;">(明文, 可直接选中复制)</span>
</p> </p>
<div style="display: flex; gap: 8px; align-items: center;"> <el-input
<el-input v-model="newSecret"
v-model="newSecret" readonly
readonly type="text"
type="text" size="default"
size="default" style="font-family: ui-monospace, monospace;"
style="flex: 1; font-family: ui-monospace, monospace;" @focus="onSecretInputFocus"
@focus="onSecretInputFocus" >
/> <template #append>
<el-button type="primary" style="flex-shrink: 0;" @click="copyText(newSecret, 'AppSecret 已复制')">复制</el-button> <el-button type="primary" @click="copyText(newSecret, 'AppSecret 已复制')">复制</el-button>
</div> </template>
</el-input>
</div> </div>
<template #footer> <template #footer>
<el-button v-if="!newSecret" @click="resetSecretVisible = false">取消</el-button> <el-button v-if="!newSecret" @click="resetSecretVisible = false">取消</el-button>
@ -447,19 +517,73 @@ onMounted(() => { load(); loadCustomers() })
</template> </template>
</el-dialog> </el-dialog>
<!-- 重发连接码(二维码)弹窗 -->
<el-dialog
v-model="resendQrVisible"
title="重发连接码"
width="560"
:style="{ borderRadius: '12px', overflow: 'hidden' }"
align-center
destroy-on-close
>
<el-alert v-if="!newConnectCode" type="warning" :closable="false" style="margin-bottom: 16px">
<p>重发后会生成新的 connectCode,旧码立即作废</p>
<p>新码有效期 2 小时,请尽快发给客户扫码激活</p>
</el-alert>
<el-alert v-if="newConnectCode" type="error" :closable="false" style="margin-bottom: 16px">
<p>新连接码仅显示一次!请立即复制并通过安全渠道发给客户</p>
<p>关闭弹窗后无法再次查看(只能再次重发)</p>
</el-alert>
<p v-if="resendQrTarget && !newConnectCode">
app: <code class="rr-code-accent">{{ resendQrTarget.appId }}</code>
({{ resendQrTarget.appName }})
</p>
<div v-if="newConnectCode" style="margin-top: 12px;">
<p style="margin-bottom: 6px; font-size: 13px; color: var(--el-text-color-regular);">
新的 ConnectCode: <span style="color: var(--el-text-color-secondary); font-size: 12px;">(明文,可直接选中复制)</span>
</p>
<el-input
v-model="newConnectCode"
readonly
type="text"
size="default"
style="font-family: ui-monospace, monospace;"
@focus="onSecretInputFocus"
>
<template #append>
<el-button type="primary" @click="copyText(newConnectCode, '连接码已复制')">复制</el-button>
</template>
</el-input>
<p v-if="newConnectCodeExpire" style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
过期时间: {{ fmtDate(newConnectCodeExpire) }}
</p>
</div>
<template #footer>
<el-button v-if="!newConnectCode" @click="resendQrVisible = false">取消</el-button>
<el-button v-if="!newConnectCode" type="primary" @click="resendQrConfirm">确认重发</el-button>
<el-button v-if="newConnectCode" type="primary" @click="resendQrVisible = false">我已复制,关闭</el-button>
</template>
</el-dialog>
<!-- 申请 APIKey 向导(2 步) --> <!-- 申请 APIKey 向导(2 步) -->
<ApplyApiKeyDialog v-model="applyDialogVisible" @refresh="onApplySubmitted" /> <ApplyApiKeyDialog v-model="applyDialogVisible" @refresh="onApplySubmitted" />
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.rr-openapi-app { max-width: 100%; } .rr-openapi-app { max-width: 1400px; }
/* PageHeader/SearchBar 接管 */ .rr-page-header {
/* PageHeader/SearchBar 接管 */ display: flex; align-items: flex-start; justify-content: space-between;
/* PageHeader/SearchBar 接管 */ margin-bottom: 20px;
/* PageHeader/SearchBar 接管 */ }
/* PageHeader/SearchBar 接管 */ .rr-page-titles { display: flex; flex-direction: column; gap: 4px; }
.rr-page-title {
font-size: 22px; font-weight: 600; letter-spacing: -0.02em;
color: var(--ink); margin: 0;
}
.rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
.rr-page-actions { display: flex; gap: 8px; flex-shrink: 0; }
.rr-list-card { .rr-list-card {
background: var(--surface); background: var(--surface);
@ -486,9 +610,9 @@ onMounted(() => { load(); loadCustomers() })
height: 2px; background: var(--brand); border-radius: 1px; height: 2px; background: var(--brand); border-radius: 1px;
} }
} }
/* PageHeader/SearchBar 接管 */ .rr-filters { display: flex; gap: 8px; flex-shrink: 0; }
/* PageHeader/SearchBar 接管 */ .rr-search-input { width: 280px; }
/* PageHeader/SearchBar 接管 */ .rr-customer-select { width: 160px; }
/* 状态徽章(浅底色块 - 4 种状态) */ /* 状态徽章(浅底色块 - 4 种状态) */
.rr-badge { .rr-badge {
@ -496,10 +620,10 @@ onMounted(() => { load(); loadCustomers() })
font-size: 11.5px; font-weight: 500; line-height: 17.25px; height: 21.25px; font-size: 11.5px; font-weight: 500; line-height: 17.25px; height: 21.25px;
box-sizing: border-box; box-sizing: border-box;
} }
.rr-badge-0 { background: var(--amber-soft); color: var(--amber); } /* 待激活 amber */ .rr-badge-0 { background: #FEF3C7; color: #D97706; } /* 待激活 amber */
.rr-badge-1 { background: var(--green-soft); color: var(--green); } /* 已激活 green */ .rr-badge-1 { background: #D1FAE5; color: #059669; } /* 已激活 green */
.rr-badge-2 { background: var(--brand-soft); color: var(--brand); } /* 已撤销 blue */ .rr-badge-2 { background: #DBEAFE; color: #2563EB; } /* 已撤销 blue */
.rr-badge-3 { background: var(--surface-2); color: var(--ink-3); } /* 已过期 gray */ .rr-badge-3 { background: #E2E8F0; color: #64748B; } /* 已过期 gray */
/* 危险文字按钮(撤销/删除 - 老 vue 用 style color: #ff4d4f) */ /* 危险文字按钮(撤销/删除 - 老 vue 用 style color: #ff4d4f) */
:deep(.rr-danger-text) { :deep(.rr-danger-text) {
@ -522,7 +646,7 @@ onMounted(() => { load(); loadCustomers() })
:deep(.el-input__inner), :deep(.el-input__inner),
:deep(.el-select__wrapper) { :deep(.el-select__wrapper) {
border-radius: 8px !important; border-radius: 8px !important;
background: var(--surface-2); background: #F8FAFC;
} }
/* 弹窗圆角 12px + 紧凑(套设计规范 §7.1 + §17 v3) */ /* 弹窗圆角 12px + 紧凑(套设计规范 §7.1 + §17 v3) */

View File

@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 订单详情 - W10 A6 标准化 * F006 订单详情 - 完整版 (W5.x.x+5 整改)
*
* W10 A6: 套 PageHeader (返回按钮 slot)
* *
* 沿用老 vue order/order-detail.vue 1379 行完整功能: * 沿用老 vue order/order-detail.vue 1379 行完整功能:
* - 4 个 tab: 订单信息 / 商品明细 / 物流信息 / 付款发票 * - 4 个 tab: 订单信息 / 商品明细 / 物流信息 / 付款发票
@ -21,7 +19,6 @@ import { ref, onMounted, watch, computed, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
interface OrderDetail { interface OrderDetail {
id: string id: string
@ -328,9 +325,9 @@ watch(orderId, load)
<template> <template>
<div class="rr-order-detail"> <div class="rr-order-detail">
<PageHeader title="订单详情" :sub="detail?.orderCode || ''"> <div class="rr-page-header">
<el-button text @click="onBack">← 返回订单列表</el-button> <el-button text @click="onBack">← 返回订单列表</el-button>
</PageHeader> </div>
<div v-if="loading" class="rr-loading"> <div v-if="loading" class="rr-loading">
<el-skeleton :rows="6" animated /> <el-skeleton :rows="6" animated />
@ -550,6 +547,7 @@ watch(orderId, load)
<style scoped lang="scss"> <style scoped lang="scss">
.rr-order-detail { max-width: 1400px; } .rr-order-detail { max-width: 1400px; }
.rr-page-header { margin-bottom: 16px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 16px; } .rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 16px; }
.rr-card-header { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; } .rr-card-header { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
.rr-card-title { font-size: 15px; font-weight: 600; color: var(--ink); margin: 0; } .rr-card-title { font-size: 15px; font-weight: 600; color: var(--ink); margin: 0; }

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 订单管理列表 - W9 A3 标准化示范 * F006 订单管理列表 - 完整版 (W5.x.x+5 整改)
* *
* 沿用老 vue order/order-good.vue 289 行 + order/serviceorder 154 行等: * 沿用老 vue order/order-good.vue 289 行 + order/serviceorder 154 行等:
* - 搜索: 订单编号/合同编号/供应商/采购方/状态/创建时间范围 * - 搜索: 订单编号/合同编号/供应商/采购方/状态/创建时间范围
@ -9,21 +9,12 @@
* - 操作: 详情/付款/发货/收货/取消/退款/导出 * - 操作: 详情/付款/发货/收货/取消/退款/导出
* - 批量导出 * - 批量导出
* - 通过 query.type 区分 good/service/group * - 通过 query.type 区分 good/service/group
*
* 字段: id/orderNo/contractCode/enterpriseName/orderUserName/orderType/totalAmount/totalNum/shippedNum/status/createDate/payDate/payType/pickupType
* 端点: /order/ordergood/page 200 (按 type 区分 good/service/group)
*
* W9 A3: 用 PageHeader / SearchBar 组件替换硬编码, 用 DataTable type:'selection' 替换裸表
*/ */
import { ref, onMounted, reactive, computed } from 'vue' import { ref, onMounted, reactive, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage } from 'element-plus'
import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import ListTabs from '@/components/ListTabs/ListTabs.vue'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import TableActions from '@/components/TableActions/TableActions.vue' import { httpGet } from '@/utils/request'
interface Order { interface Order {
id: string id: string
@ -72,37 +63,10 @@ const FALLBACK: Order[] = [
const list = ref<Order[]>([]) const list = ref<Order[]>([])
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null)
const page = ref(1) const page = ref(1)
const limit = ref(10) const limit = ref(10)
const filters = reactive({ const error = ref<string | null>(null)
orderNo: '',
contractCode: '',
enterpriseName: '',
status: '',
startDate: '',
endDate: ''
})
const selection = ref<Order[]>([]) const selection = ref<Order[]>([])
const columns: any = computed(() => [
{ type: 'selection', label: '', width: 50 },
{ prop: 'orderNo', label: '订单号', width: 180 },
{ prop: 'contractCode', label: '合同号', width: 160, slot: 'contractCode' },
{ prop: 'enterpriseName', label: '供应商', minWidth: 180, slot: 'enterpriseName' },
{ prop: 'orderUserName', label: '采购方', minWidth: 160, slot: 'orderUserName' },
{ prop: 'totalAmount', label: '金额', width: 130, align: 'right', slot: 'amount' },
{ prop: 'totalNum', label: '数量', width: 80, align: 'center' },
{ prop: 'shippedNum', label: '已发', width: 80, align: 'center' },
{ prop: 'status', label: '状态', width: 110, align: 'center', slot: 'status' },
{ prop: 'createDate', label: '下单时间', width: 170, slot: 'time' },
{ label: '操作', width: 140, align: 'center', slot: 'action' }
])
const tabItems = computed(() => STATUS_TABS.map(t => ({
key: t.value,
label: t.label,
count: t.value === '' ? stats.all : (stats[Number(t.value)] || 0)
})))
const stats = reactive<Record<number|string, number>>({ 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, all: 0 })
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -116,184 +80,161 @@ const listType = computed(() => {
return 'good' return 'good'
}) })
// W12.2 修正: 后端 OpenApiOrderController 只暴露 /openapi/order/list 一个端点, const filters = reactive({
// 旧路径 (/order/ordergood/page 等) 404。所有 5 种订单类型都走统一端点。 orderNo: '',
const endpoint = computed(() => '/sys/openapi/order/list') contractCode: '',
enterpriseName: '',
status: '',
startDate: '',
endDate: ''
})
const typeLabel = computed(() => {
const map: Record<string, string> = {
good: '商品订单', service: '服务订单', groupsupplier: '商品拼团订单', groupservice: '服务拼团订单', aftersale: '售后订单', commission: '分佣统计'
}
return map[listType.value] || '订单管理'
})
async function load() { async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
// W12.3 修复: httpGet() 已解开 res.data.data, 所以 r 直接是 {total, list, page, limit} await new Promise(r => setTimeout(r, 200))
const r: any = await httpGet(endpoint.value, { page: page.value, limit: limit.value }) let rows = [...FALLBACK]
const respList: any[] = Array.isArray(r?.list) ? r.list : [] if (filters.orderNo) rows = rows.filter(r => r.orderNo.includes(filters.orderNo))
const rows: Order[] = respList.map((it: any) => ({ if (filters.contractCode) rows = rows.filter(r => r.contractCode.includes(filters.contractCode))
id: it.orderId || '', if (filters.enterpriseName) rows = rows.filter(r => r.enterpriseName.includes(filters.enterpriseName))
orderNo: it.orderId || '', if (filters.status) rows = rows.filter(r => String(r.status) === filters.status)
contractCode: '',
enterpriseName: '',
orderUserName: '',
orderType: 1,
totalAmount: Number(it.totalAmount ?? 0),
totalNum: Number(it.goodsQuantity ?? 0),
shippedNum: 0,
status: Number(it.status ?? 0),
createDate: it.createDate ? String(it.createDate) : '',
payDate: null,
payType: 0,
pickupType: 1,
raw: it
}))
list.value = rows list.value = rows
// 后端 total 是字符串 "1356", parseInt 兜底 total.value = rows.length
const t = r?.total
total.value = typeof t === 'string' ? parseInt(t, 10) : (Number(t) || 0)
// 各 status 计数
Object.keys(stats).forEach(k => (stats as any)[k] = 0)
stats.all = rows.length
rows.forEach(r => { stats[r.status] = ((stats[r.status] as number) || 0) + 1 })
} catch (e: any) { } catch (e: any) {
error.value = e?.msg || e?.message || '加载失败' error.value = e?.msg || e?.message || '加载失败'
list.value = FALLBACK list.value = FALLBACK
total.value = FALLBACK.length total.value = FALLBACK.length
} finally { } finally { loading.value = false }
loading.value = false
}
} }
function onSearch() { page.value = 1; load() } function onSearch() { page.value = 1; load() }
function fmtDate(ts?: string | number | null) { function onReset() { Object.assign(filters, { orderNo: '', contractCode: '', enterpriseName: '', status: '', startDate: '', endDate: '' }); load() }
if (!ts) return '--'
let n: number
if (typeof ts === 'string') {
n = /^\d+$/.test(ts) ? Number(ts) : Date.parse(ts)
} else {
n = ts
}
if (!n || isNaN(n)) return String(ts)
const d = new Date(n)
if (isNaN(d.getTime())) return String(ts)
const pad = (x: number) => String(x).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
function onReset() {
filters.orderNo = ''
filters.contractCode = ''
filters.enterpriseName = ''
filters.status = ''
filters.startDate = ''
filters.endDate = ''
load()
}
function onPageChange(p: number) { page.value = p; load() } function onPageChange(p: number) { page.value = p; load() }
function onLimitChange(l: number) { limit.value = l; load() } function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
function onStatusTab(v: string) { filters.status = v; load() }
function onSelectionChange(rows: Order[]) { selection.value = rows } function onSelectionChange(rows: Order[]) { selection.value = rows }
function onRowClick(row: Order) { router.push(`/order/${row.id}`) }
function onStatusTabChange(status: string) { filters.status = status; onSearch() }
function onViewDetail(row: Order) { router.push(`/order/${row.id}`) } function onViewDetail(row: Order) { router.push(`/order/${row.id}`) }
function onPay(row: Order) { ElMessage.success(`付款 ${row.orderNo} (mock)`); load() } function onPay(row: Order) { ElMessage.success(`付款 (mock) ${row.orderNo}`) }
function onShip(row: Order) { ElMessage.success(`发货 ${row.orderNo} (mock)`); load() } function onShip(row: Order) { ElMessage.success(`发货 (mock) ${row.orderNo}`) }
function onConfirmReceipt(row: Order) { ElMessage.success(`确认收货 ${row.orderNo} (mock)`); load() } function onConfirmReceipt(row: Order) { ElMessage.success(`确认收货 (mock) ${row.orderNo}`) }
function onRefund(row: Order) { ElMessage.success(`退款 ${row.orderNo} (mock)`); load() } function onCancel(row: Order) { ElMessage.info(`取消 (mock) ${row.orderNo}`) }
function onCancel(row: Order) { ElMessage.success(`取消 ${row.orderNo} (mock)`); load() } function onRefund(row: Order) { ElMessage.info(`退款 (mock) ${row.orderNo}`) }
function onPrint(row: Order) { ElMessage.info(`打印 ${row.orderNo} (mock)`) } function onExport() { ElMessage.success('导出订单 (mock)') }
function onExportSelected() { function onExportSelected() { if (!selection.value.length) { ElMessage.warning('请先选择订单'); return } ElMessage.success(`导出 ${selection.value.length} 个订单 (mock)`) }
if (!selection.value.length) { ElMessage.warning('请先选择订单'); return } function onPrint(row: Order) { ElMessage.info(`打印 (mock) ${row.orderNo}`) }
ElMessage.success(`导出 ${selection.value.length} 个订单 (mock)`)
}
onMounted(load) onMounted(load)
</script> </script>
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="订单管理" :sub="`共 ${total} 条订单${selection.length ? `, 已选 ${selection.length}` : ''}`"> <div class="rr-page-header">
<el-button v-if="selection.length > 0" type="primary" @click="onExportSelected"> <div>
导出选中 ({{ selection.length }}) <h2 class="rr-page-title">{{ typeLabel }}</h2>
</el-button> <p class="rr-page-sub">共 {{ total }} 条订单</p>
</PageHeader> </div>
<div class="rr-page-actions">
<el-button @click="onExport">导出</el-button>
<el-button v-if="selection.length > 0" type="primary" @click="onExportSelected">导出选中 ({{ selection.length }})</el-button>
</div>
</div>
<ListTabs v-model="filters.status" :items="tabItems" @change="onStatusTabChange" /> <!-- 搜索区 (沿用老 vue: 订单号/合同号/供应商/状态/时间) -->
<div class="rr-card rr-search">
<el-form :inline="true" @keyup.enter="onSearch">
<el-form-item label="订单号">
<el-input v-model="filters.orderNo" placeholder="订单号" clearable style="width: 180px" />
</el-form-item>
<el-form-item label="合同号">
<el-input v-model="filters.contractCode" placeholder="合同号" clearable style="width: 180px" />
</el-form-item>
<el-form-item label="供应商">
<el-input v-model="filters.enterpriseName" placeholder="供应商" clearable style="width: 180px" />
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker v-model="filters.startDate" type="datetime" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" placeholder="开始" clearable style="width: 170px" />
<span style="margin: 0 8px">至</span>
<el-date-picker v-model="filters.endDate" type="datetime" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" placeholder="结束" clearable style="width: 170px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">查询</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<SearchBar <!-- 状态 Tab -->
v-model:filters="filters" <div class="rr-tabs-row">
:fields="[ <div class="rr-tabs">
{ key: 'orderNo', label: '订单号', placeholder: '请输入订单号' }, <div v-for="t in STATUS_TABS" :key="t.value" class="rr-tab" :class="{ active: filters.status === t.value }" @click="onStatusTab(t.value)">{{ t.label }}</div>
{ key: 'contractCode', label: '合同号', placeholder: '请输入合同号' }, </div>
{ key: 'enterpriseName', label: '供应商', placeholder: '请输入供应商名称' }, </div>
{ key: 'startDate', label: '开始日期', type: 'date', placeholder: '选择开始日期' },
{ key: 'endDate', label: '结束日期', type: 'date', placeholder: '选择结束日期' }
]"
@search="onSearch"
@reset="onReset"
/>
<DataTable <!-- 表格 -->
:columns="columns" <div class="rr-card">
:data="list" <el-table :data="list" border style="width: 100%" v-loading="loading" :empty-text="error || '暂无订单'" @selection-change="onSelectionChange" @row-click="onRowClick" row-class-name="rr-clickable">
:loading="loading" <el-table-column type="selection" width="50" align="center" @selectable="(row: any) => row.status !== 7" />
:total="total" <el-table-column type="index" label="#" width="50" align="center" />
:page="page" <el-table-column prop="orderNo" label="订单号" width="180" />
:limit="limit" <el-table-column prop="contractCode" label="合同号" width="160" show-overflow-tooltip />
:page-sizes="[10, 20, 50, 100]" <el-table-column prop="enterpriseName" label="供应商" min-width="180" show-overflow-tooltip />
:show-pagination="true" <el-table-column prop="orderUserName" label="采购方" min-width="160" show-overflow-tooltip />
:error="error" <el-table-column label="金额" width="120" align="right">
empty-text="暂无订单" <template #default="{ row }">¥ {{ row.totalAmount.toLocaleString() }}</template>
:borderless="true" </el-table-column>
row-key="id" <el-table-column label="数量" prop="totalNum" width="80" align="center" />
@page-change="onPageChange" <el-table-column label="已发" prop="shippedNum" width="80" align="center" />
@limit-change="onLimitChange" <el-table-column label="状态" width="100" align="center">
@selection-change="onSelectionChange" <template #default="{ row }">
> <el-tag :type="(STATUS_LABEL[row.status] === '已完成' ? 'success' : STATUS_LABEL[row.status] === '已取消' ? 'info' : 'primary') as any" size="small" effect="light">
<template #contractCode="{ row }"> {{ STATUS_LABEL[row.status] || `状态${row.status}` }}
<span v-if="row.contractCode">{{ row.contractCode }}</span> </el-tag>
<span v-else class="rr-muted">--</span> </template>
</template> </el-table-column>
<template #enterpriseName="{ row }"> <el-table-column label="下单时间" width="170">
<span v-if="row.enterpriseName">{{ row.enterpriseName }}</span> <template #default="{ row }">{{ row.createDate }}</template>
<span v-else class="rr-muted">--</span> </el-table-column>
</template> <el-table-column label="操作" width="240" align="center" fixed="right">
<template #orderUserName="{ row }"> <template #default="{ row }">
<span v-if="row.orderUserName">{{ row.orderUserName }}</span> <el-button text type="primary" size="small" @click.stop="onViewDetail(row)">详情</el-button>
<span v-else class="rr-muted">--</span> <el-button v-if="row.status === 1" text type="primary" size="small" @click.stop="onPay(row)">付款</el-button>
</template> <el-button v-if="row.status === 2" text type="primary" size="small" @click.stop="onShip(row)">发货</el-button>
<template #time="{ row }"> <el-button v-if="row.status === 3" text type="primary" size="small" @click.stop="onConfirmReceipt(row)">收货</el-button>
<span class="rr-time">{{ fmtDate(row.createDate) }}</span> <el-button v-if="[1,2,3,4].includes(row.status)" text type="warning" size="small" @click.stop="onRefund(row)">退款</el-button>
</template> <el-button v-if="[1,2].includes(row.status)" text type="danger" size="small" @click.stop="onCancel(row)">取消</el-button>
<template #amount="{ row }"> <el-button text size="small" @click.stop="onPrint(row)">打印</el-button>
<span class="rr-amount">¥ {{ row.totalAmount.toLocaleString() }}</span> </template>
</template> </el-table-column>
<template #status="{ row }"> </el-table>
<el-tag <div class="rr-pagination">
:type="(STATUS_LABEL[row.status] === '已完成' ? 'success' : STATUS_LABEL[row.status] === '已取消' ? 'info' : 'primary') as any" <el-pagination :current-page="page" :page-sizes="[10, 20, 50, 100]" :page-size="limit" :total="total" layout="total, sizes, prev, pager, next, jumper" @size-change="onLimitChange" @current-change="onPageChange" />
size="small" </div>
effect="light" </div>
>
{{ STATUS_LABEL[row.status] || `状态${row.status}` }}
</el-tag>
</template>
<template #action="{ row }">
<TableActions
:row="row"
:actions="[
{ label: '详情', type: 'primary', onClick: onViewDetail },
{ label: '付款', type: 'primary', when: (r: any) => r.status === 1, onClick: onPay },
{ label: '发货', type: 'primary', when: (r: any) => r.status === 2, onClick: onShip },
{ label: '收货', type: 'primary', when: (r: any) => r.status === 3, onClick: onConfirmReceipt },
{ label: '退款', type: 'warning', when: (r: any) => r.status === 4, onClick: onRefund },
{ label: '取消', type: 'danger', when: (r: any) => [1,2].includes(r.status), onClick: onCancel },
{ label: '打印', type: 'primary', onClick: onPrint }
]"
/>
</template>
</DataTable>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1500px; } .rr-page { max-width: 1500px; }
.rr-muted { color: var(--ink-3); } .rr-page-header { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
.rr-amount { font-weight: 600; color: var(--primary); } .rr-page-title { font-size: 22px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); } .rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
.rr-page-actions { display: flex; gap: 8px; }
.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-tabs-row { margin-bottom: 16px; }
.rr-tabs { display: flex; gap: 4px; padding: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); width: fit-content; flex-wrap: wrap; }
.rr-tab { padding: 6px 16px; border-radius: var(--radius); font-size: 13px; color: var(--ink-2); cursor: pointer; }
.rr-tab.active { color: white; background: var(--primary); font-weight: 500; }
.rr-pagination { padding: 12px 20px; display: flex; justify-content: flex-end; border-top: 1px solid var(--border); }
:deep(.rr-clickable) { cursor: pointer; }
:deep(.rr-clickable:hover > td) { background: var(--bg-soft) !important; }
</style> </style>

View File

@ -1,220 +0,0 @@
<script setup lang="ts">
/**
* F006 实物订单售后 - W15.5
*
* 沿用老 vue order/orderafter.vue 字段:
* afterSalesOrderId / shopType / afterSaleType / enterpriseName / name / title
* returnQuantity / totalAmount / requestTime / status (1=发起 2=处理中 3=同意 4=不同意 5=寄回 6=完成 7=取消)
*
* 端点: /order/aftersale/page 探测; 404 → FALLBACK seed (14 条)
*/
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.vue'
interface AftersaleRow {
afterSalesOrderId: string
shopType: number
afterSaleType: number
enterpriseName: string
name: string
title: string
returnQuantity: number
totalAmount: number
requestTime: string
status: number
reason: string
}
const SHOP_LABEL: Record<number, string> = { 1: '实物商品', 2: '服务商品', 3: '拼团商品', 4: '虚拟商品' }
const TYPE_LABEL: Record<number, string> = { 1: '仅退款', 2: '退货退款', 3: '换货', 4: '维修' }
const STATUS_LABEL: Record<number, string> = { 1: '发起申请', 2: '处理中', 3: '同意退/换', 4: '不同意', 5: '货物寄回', 6: '完成', 7: '取消' }
const STATUS_TYPE: Record<number, string> = { 1:'warning', 2:'primary', 3:'success', 4:'danger', 5:'info', 6:'success', 7:'info' }
const ENDPOINTS = ['/order/orderAfterSale/shopAfterSalePage', '/order/orderAfterSale/supplierPageList']
const FALLBACK: AftersaleRow[] = [
{ afterSalesOrderId:'AS20260701001', shopType:1, afterSaleType:2, enterpriseName:'坚美建筑材料有限公司', name:'张老板', title:'国标槽钢 10# 6m 热镀锌', returnQuantity:5, totalAmount:3450.00, requestTime:'2026-07-01 14:20:00', status:3, reason:'型号发错' },
{ afterSalesOrderId:'AS20260701002', shopType:1, afterSaleType:1, enterpriseName:'华建门窗厂', name:'李工', title:'断桥铝平开窗 60系列', returnQuantity:2, totalAmount:1800.00, requestTime:'2026-07-02 09:18:42', status:6, reason:'协商退款' },
{ afterSalesOrderId:'AS20260702001', shopType:1, afterSaleType:2, enterpriseName:'融鑫管业', name:'王经理', title:'PPR给水管 dn25 4m', returnQuantity:20, totalAmount:1260.00, requestTime:'2026-07-02 16:32:11', status:2, reason:'包装破损' },
{ afterSalesOrderId:'AS20260702002', shopType:1, afterSaleType:4, enterpriseName:'东方五金机械', name:'赵师傅', title:'电锤 GBH 2-28 800W', returnQuantity:1, totalAmount:680.00, requestTime:'2026-07-03 11:42:30', status:4, reason:'已过维保期' },
{ afterSalesOrderId:'AS20260703001', shopType:1, afterSaleType:3, enterpriseName:'美洁涂料', name:'陈女士', title:'环保内墙乳胶漆 5L', returnQuantity:4, totalAmount:920.00, requestTime:'2026-07-03 13:28:00', status:5, reason:'颜色偏差' },
{ afterSalesOrderId:'AS20260703002', shopType:1, afterSaleType:2, enterpriseName:'万家灯具', name:'周总', title:'LED投光灯 200W IP65', returnQuantity:3, totalAmount:1080.00, requestTime:'2026-07-04 08:15:55', status:1, reason:'不亮灯' },
{ afterSalesOrderId:'AS20260704001', shopType:2, afterSaleType:1, enterpriseName:'顺达物流', name:'吴先生', title:'同城配送服务 (50km内)', returnQuantity:1, totalAmount:280.00, requestTime:'2026-07-04 16:48:22', status:7, reason:'客户主动取消' },
{ afterSalesOrderId:'AS20260704002', shopType:1, afterSaleType:2, enterpriseName:'华建门窗厂', name:'林总', title:'铝合金推拉门 90系列', returnQuantity:1, totalAmount:2880.00, requestTime:'2026-07-05 10:14:42', status:3, reason:'尺寸不符' },
{ afterSalesOrderId:'AS20260705001', shopType:3, afterSaleType:1, enterpriseName:'众成厨具', name:'黄总', title:'不锈钢汤锅 30cm', returnQuantity:1, totalAmount:128.00, requestTime:'2026-07-05 14:30:00', status:6, reason:'拼团失败退款' },
{ afterSalesOrderId:'AS20260705002', shopType:1, afterSaleType:4, enterpriseName:'东方五金机械', name:'何师傅', title:'角磨机 100mm 720W', returnQuantity:1, totalAmount:380.00, requestTime:'2026-07-06 09:25:00', status:2, reason:'电机异响' },
{ afterSalesOrderId:'AS20260706001', shopType:1, afterSaleType:2, enterpriseName:'坚美建筑材料', name:'钱老板', title:'国标角钢 50×5 6m', returnQuantity:8, totalAmount:2240.00, requestTime:'2026-07-06 11:02:18', status:3, reason:'厚度偏差' },
{ afterSalesOrderId:'AS20260706002', shopType:1, afterSaleType:1, enterpriseName:'美洁涂料', name:'孙总', title:'环保外墙腻子粉 25kg', returnQuantity:6, totalAmount:780.00, requestTime:'2026-07-06 15:48:32', status:1, reason:'施工后剩余' },
{ afterSalesOrderId:'AS20260706003', shopType:1, afterSaleType:2, enterpriseName:'融鑫管业', name:'李经理', title:'PE给水管 dn32 6m', returnQuantity:12, totalAmount:960.00, requestTime:'2026-07-06 17:33:21', status:2, reason:'现场清点异常' },
{ afterSalesOrderId:'AS20260706004', shopType:2, afterSaleType:1, enterpriseName:'顺达物流', name:'郑总', title:'异地整车运输', returnQuantity:1, totalAmount:1680.00, requestTime:'2026-07-06 18:55:08', status:1, reason:'时效不达标' }
]
const STATUS_TABS = [
{ value: '', label: '全部' },
{ value: '1', label: '发起申请' },
{ value: '2', label: '处理中' },
{ value: '3', label: '已同意' },
{ value: '4', label: '已拒绝' },
{ value: '5', label: '货物寄回' },
{ value: '6', label: '已完成' },
{ value: '7', label: '已取消' }
]
type Col = {
prop?: string; label: string; width?: number; slot?: string; fixed?: 'right' | true; type?: 'status' | 'text' | 'selection'
}
const columns: Col[] = [
{ prop: 'afterSalesOrderId', label: '售后订单号', width: 180, fixed: true, slot: 'orderId' },
{ type: 'status', prop: 'shopType', label: '商品类型', width: 110 },
{ type: 'status', prop: 'afterSaleType', label: '售后类型', width: 100 },
{ prop: 'enterpriseName', label: '供应商信息', width: 200 },
{ prop: 'name', label: '用户信息', width: 120 },
{ prop: 'title', label: '商品信息', width: 240, slot: 'title' },
{ prop: 'returnQuantity', label: '售后数量', width: 90 },
{ prop: 'totalAmount', label: '退款金额', width: 110 },
{ prop: 'requestTime', label: '创建时间', width: 160, slot: 'time' },
{ type: 'status', prop: 'status', label: '订单状态', width: 110 },
{ prop: 'reason', label: '售后原因', width: 200, slot: 'reason' },
{ prop: 'actions', label: '操作', width: 220, fixed: 'right', slot: 'actions' }
]
const list = ref<AftersaleRow[]>([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const limit = ref(10)
const filterKw = ref('')
const statusTab = ref('')
const dataSource = ref<'api' | 'mock' | 'loading'>('loading')
const apiError = ref<string>('')
async function load() {
loading.value = true
dataSource.value = 'loading'
for (const ep of ENDPOINTS) {
try {
const params: any = { page: page.value, limit: limit.value }
const data: any = await httpGet<any>(ep, params)
const rows: AftersaleRow[] = (data?.list || []).map((it: any) => ({ ...it }))
list.value = rows
total.value = Number(data?.total || rows.length)
dataSource.value = 'api'
apiError.value = ''
loading.value = false
return
} catch {}
}
list.value = [...FALLBACK]
total.value = FALLBACK.length
dataSource.value = 'mock'
apiError.value = '后端 API 暂未实装, 已加载前端示例数据'
loading.value = false
}
const filtered = computed(() => {
const kw = filterKw.value.trim().toLowerCase()
let rows = list.value
if (statusTab.value) rows = rows.filter(r => String(r.status) === statusTab.value)
if (kw) rows = rows.filter(r =>
String(r.afterSalesOrderId).toLowerCase().includes(kw) ||
String(r.enterpriseName).toLowerCase().includes(kw) ||
String(r.name).toLowerCase().includes(kw) ||
String(r.title).toLowerCase().includes(kw)
)
return rows
})
const searchFields: Array<{ key: string; label: string; type: 'text' }> = [
{ key: 'afterSalesOrderId', label: '售后订单号', type: 'text' },
{ key: 'enterpriseName', label: '供应商', type: 'text' },
{ key: 'title', label: '商品名', type: 'text' }
]
const filters = reactive<Record<string, any>>({})
function onSearch() {
filterKw.value = String(filters.afterSalesOrderId || filters.enterpriseName || filters.title || '')
page.value = 1
}
function onStatusTab(v: string) { statusTab.value = v; page.value = 1 }
async function onAgree(row: AftersaleRow) {
try { await ElMessageBox.confirm(`确认通过售后申请「${row.afterSalesOrderId}」?`, '提示', { type: 'warning' }) } catch { return }
row.status = 3
ElMessage.success('已通过 (前端示例)')
}
async function onReject(row: AftersaleRow) {
try {
const { value } = await ElMessageBox.prompt('请填写拒绝原因', '拒绝售后', { inputPlaceholder: '原因', confirmButtonText: '拒绝', cancelButtonText: '取消' })
row.status = 4
row.reason = (value || '已拒绝').trim()
ElMessage.success('已拒绝 (前端示例)')
} catch {}
}
async function onComplete(row: AftersaleRow) {
row.status = 6
ElMessage.success('已标记完成')
}
function onView(row: AftersaleRow) {
ElMessage.info(`打开售后详情 ${row.afterSalesOrderId}`)
}
function onExport() {
ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)')
}
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="实物订单售后" sub="实物商品的退/换/修售后流程审核" :data-source="dataSource" :api-error="apiError">
<el-button type="primary" @click="onExport">导出明细</el-button>
</PageHeader>
<el-tabs v-model="statusTab" @tab-change="onStatusTab" class="rr-tabs">
<el-tab-pane v-for="t in STATUS_TABS" :key="t.value" :name="t.value" :label="t.label" />
</el-tabs>
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" />
<DataTable
:columns="columns"
:data="filtered"
:loading="loading"
:page="page"
:limit="limit"
:total="total"
row-key="afterSalesOrderId"
@page-change="(p: number) => { page = p; load() }"
@limit-change="(l: number) => { limit = l; page = 1; load() }"
>
<template #orderId="{ row }">
<el-link type="primary" :underline="false" @click="onView(row)">{{ row.afterSalesOrderId }}</el-link>
</template>
<template #title="{ row }">
<span :class="{ 'rr-muted': !row.title }">{{ row.title || '--' }}</span>
</template>
<template #time="{ row }">{{ row.requestTime }}</template>
<template #reason="{ row }">
<span :class="{ 'rr-muted': !row.reason }">{{ row.reason || '--' }}</span>
</template>
<template #actions="{ row }">
<template v-if="row.status === 1 || row.status === 2">
<el-button type="success" link @click="onAgree(row)">同意</el-button>
<el-button type="danger" link @click="onReject(row)">拒绝</el-button>
</template>
<el-button v-if="row.status === 5" type="success" link @click="onComplete(row)">确认收货完成</el-button>
<el-button type="primary" link @click="onView(row)">详情</el-button>
</template>
</DataTable>
</div>
</template>
<style scoped>
.rr-page { padding: 16px; }
.rr-tabs { margin-bottom: 12px; }
.rr-muted { color: var(--ink-muted); }
</style>

View File

@ -1,214 +0,0 @@
<script setup lang="ts">
/**
* F006 订单支付回调列表 - W15.2
*
* 沿用老 vue order/orderpay.vue 字段:
* - 选择 + id + orderUserId + userType + orderId + goodDetailId
* - orderGoodId + orderBatchId + payStatus + sum + couponMoney + couponId
* - freight + payMoney + unitPrice + totalNum + payDate + isDel + pickupType
*
* 端点: /order/orderpay/page 探测 (按 5.18 4 URL + 老 vue 字段口径)
* 端点 404 → 用 FALLBACK 前端 seed 数据 (15 条)
*
* 状态: payStatus 1=预付款 2=发货款 3=到货款 4=安装款 5=结算款 6=质保金 7=已结束
*/
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.vue'
interface PaymentRow {
id: string
orderUserId: string
userType: number
orderId: string
goodDetailId: string
orderGoodId: string
orderBatchId: string
payStatus: number
sum: number
couponMoney: number
couponId: string
freight: number
payMoney: number
unitPrice: number
totalNum: number
payDate: string
isDel: number
pickupType: number
}
const PAY_STATUS_LABEL: Record<number, string> = {
1: '预付款', 2: '发货款', 3: '到货款', 4: '安装款', 5: '结算款', 6: '质保金', 7: '已结束'
}
const PICKUP_LABEL: Record<number, string> = { 1: '商家配送', 2: '买家自提' }
const USER_TYPE_LABEL: Record<number, string> = { 1: 'C端用户', 2: '供应链' }
// 端点探测 (W5.18 教训: 4 URL 试探 + 老 vue 字段口径, 找不到走 FALLBACK seed)
const ENDPOINTS = ['/order/orderpay/page', '/order/orderpay/list', '/order/orderpay/search', '/order/orderpay/queryPage']
const FALLBACK: PaymentRow[] = [
{ id:'OP001', orderUserId:'u10086', userType:1, orderId:'O001', goodDetailId:'GD101', orderGoodId:'OG201', orderBatchId:'OB301', payStatus:7, sum:12800.00, couponMoney:300.00, couponId:'C202501', freight:0, payMoney:12500.00, unitPrice:1280.00, totalNum:10, payDate:'2026-07-01 14:32:11', isDel:0, pickupType:1 },
{ id:'OP002', orderUserId:'u10112', userType:2, orderId:'O002', goodDetailId:'GD102', orderGoodId:'OG202', orderBatchId:'OB302', payStatus:3, sum:5600.00, couponMoney:0, couponId:'', freight:80.00, payMoney:5680.00, unitPrice:560.00, totalNum:10, payDate:'2026-07-02 09:18:42', isDel:0, pickupType:1 },
{ id:'OP003', orderUserId:'u10133', userType:1, orderId:'O003', goodDetailId:'GD103', orderGoodId:'OG203', orderBatchId:'OB303', payStatus:5, sum:8800.00, couponMoney:200.00, couponId:'C202503', freight:100.00, payMoney:8700.00, unitPrice:880.00, totalNum:10, payDate:'2026-06-28 16:45:00', isDel:0, pickupType:1 },
{ id:'OP004', orderUserId:'u10144', userType:1, orderId:'O004', goodDetailId:'GD104', orderGoodId:'OG204', orderBatchId:'OB304', payStatus:7, sum:2200.00, couponMoney:0, couponId:'', freight:0, payMoney:2200.00, unitPrice:220.00, totalNum:10, payDate:'2026-07-03 11:02:18', isDel:0, pickupType:2 },
{ id:'OP005', orderUserId:'u10155', userType:2, orderId:'O005', goodDetailId:'GD105', orderGoodId:'OG205', orderBatchId:'OB305', payStatus:6, sum:4500.00, couponMoney:150.00, couponId:'C202505', freight:60.00, payMoney:4410.00, unitPrice:450.00, totalNum:10, payDate:'2026-07-04 08:30:55', isDel:0, pickupType:1 },
{ id:'OP006', orderUserId:'u10166', userType:1, orderId:'O006', goodDetailId:'GD106', orderGoodId:'OG206', orderBatchId:'OB306', payStatus:2, sum:13800.00, couponMoney:500.00, couponId:'C202506', freight:200.00, payMoney:13500.00, unitPrice:1380.00, totalNum:10, payDate:'2026-07-05 13:25:30', isDel:0, pickupType:1 },
{ id:'OP007', orderUserId:'u10177', userType:1, orderId:'O007', goodDetailId:'GD107', orderGoodId:'OG207', orderBatchId:'OB307', payStatus:4, sum:3200.00, couponMoney:0, couponId:'', freight:0, payMoney:3200.00, unitPrice:320.00, totalNum:10, payDate:'2026-07-06 10:12:00', isDel:0, pickupType:2 },
{ id:'OP008', orderUserId:'u10188', userType:2, orderId:'O008', goodDetailId:'GD108', orderGoodId:'OG208', orderBatchId:'OB308', payStatus:1, sum:6800.00, couponMoney:0, couponId:'', freight:120.00, payMoney:6920.00, unitPrice:680.00, totalNum:10, payDate:'', isDel:0, pickupType:1 },
{ id:'OP009', orderUserId:'u10199', userType:1, orderId:'O009', goodDetailId:'GD109', orderGoodId:'OG209', orderBatchId:'OB309', payStatus:3, sum:9800.00, couponMoney:280.00, couponId:'C202509', freight:0, payMoney:9520.00, unitPrice:980.00, totalNum:10, payDate:'2026-07-02 15:48:22', isDel:0, pickupType:1 },
{ id:'OP010', orderUserId:'u10210', userType:1, orderId:'O010', goodDetailId:'GD110', orderGoodId:'OG210', orderBatchId:'OB310', payStatus:7, sum:5400.00, couponMoney:100.00, couponId:'C202510', freight:60.00, payMoney:5360.00, unitPrice:540.00, totalNum:10, payDate:'2026-06-30 12:30:00', isDel:0, pickupType:1 },
{ id:'OP011', orderUserId:'u10221', userType:2, orderId:'O011', goodDetailId:'GD111', orderGoodId:'OG211', orderBatchId:'OB311', payStatus:5, sum:18800.00, couponMoney:600.00, couponId:'C202511', freight:300.00, payMoney:18500.00, unitPrice:1880.00, totalNum:10, payDate:'2026-07-03 09:55:08', isDel:0, pickupType:1 },
{ id:'OP012', orderUserId:'u10232', userType:1, orderId:'O012', goodDetailId:'GD112', orderGoodId:'OG212', orderBatchId:'OB312', payStatus:6, sum:2400.00, couponMoney:0, couponId:'', freight:0, payMoney:2400.00, unitPrice:240.00, totalNum:10, payDate:'2026-07-04 16:20:14', isDel:0, pickupType:2 },
{ id:'OP013', orderUserId:'u10243', userType:1, orderId:'O013', goodDetailId:'GD113', orderGoodId:'OG213', orderBatchId:'OB313', payStatus:7, sum:7200.00, couponMoney:240.00, couponId:'C202513', freight:80.00, payMoney:7040.00, unitPrice:720.00, totalNum:10, payDate:'2026-06-25 11:42:35', isDel:0, pickupType:1 },
{ id:'OP014', orderUserId:'u10254', userType:2, orderId:'O014', goodDetailId:'GD114', orderGoodId:'OG214', orderBatchId:'OB314', payStatus:2, sum:11500.00, couponMoney:400.00, couponId:'C202514', freight:180.00, payMoney:11280.00, unitPrice:1150.00, totalNum:10, payDate:'2026-07-05 14:18:50', isDel:0, pickupType:1 },
{ id:'OP015', orderUserId:'u10265', userType:1, orderId:'O015', goodDetailId:'GD115', orderGoodId:'OG215', orderBatchId:'OB315', payStatus:4, sum:4400.00, couponMoney:0, couponId:'', freight:50.00, payMoney:4450.00, unitPrice:440.00, totalNum:10, payDate:'2026-07-06 17:33:21', isDel:0, pickupType:2 }
]
const STATUS_TABS = [
{ value: '', label: '全部' },
{ value: '1', label: '预付款' },
{ value: '2', label: '发货款' },
{ value: '3', label: '到货款' },
{ value: '4', label: '安装款' },
{ value: '5', label: '结算款' },
{ value: '6', label: '质保金' },
{ value: '7', label: '已结束' }
]
type Col = {
prop?: string; label: string; width?: number; slot?: string; fixed?: 'right' | true; type?: 'status' | 'text' | 'selection'
}
const columns: Col[] = [
{ prop: 'id', label: 'ID', width: 90, fixed: true },
{ prop: 'orderId', label: '订单编号', width: 180, slot: 'orderId' },
{ prop: 'orderUserId', label: '用户ID', width: 100 },
{ type: 'status', prop: 'userType', label: '用户类型', width: 100 },
{ prop: 'orderGoodId', label: '小订单ID', width: 120 },
{ type: 'status', prop: 'payStatus', label: '款项流程', width: 110 },
{ prop: 'sum', label: '优惠前金额', width: 110 },
{ prop: 'couponMoney', label: '优惠金额', width: 100 },
{ prop: 'freight', label: '运费', width: 80 },
{ prop: 'payMoney', label: '实际支付', width: 110 },
{ prop: 'unitPrice', label: '单价', width: 90 },
{ prop: 'totalNum', label: '待发货总数', width: 100 },
{ type: 'status', prop: 'pickupType', label: '配送方式', width: 110 },
{ prop: 'payDate', label: '支付时间', width: 160, slot: 'time' },
{ prop: 'actions', label: '操作', width: 180, fixed: 'right', slot: 'actions' }
]
const list = ref<PaymentRow[]>([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const limit = ref(10)
const filterKw = ref('')
const statusTab = ref('')
const dataSource = ref<'api' | 'mock' | 'loading'>('loading')
const apiError = ref<string>('')
async function load() {
loading.value = true
dataSource.value = 'loading'
for (const ep of ENDPOINTS) {
try {
const params: any = { page: page.value, limit: limit.value }
if (statusTab.value) params.payStatus = Number(statusTab.value)
const data: any = await httpGet<any>(ep, params)
const rows: PaymentRow[] = (data?.list || []).map((it: any) => ({ ...it, raw: it }))
list.value = rows
total.value = Number(data?.total || rows.length)
dataSource.value = 'api'
apiError.value = ''
loading.value = false
return
} catch {}
}
// 全部失败 → 前端 seed
list.value = [...FALLBACK]
total.value = FALLBACK.length
dataSource.value = 'mock'
apiError.value = '后端 API 暂未实装, 已加载前端示例数据'
loading.value = false
}
const filtered = computed(() => {
const kw = filterKw.value.trim().toLowerCase()
let rows = list.value
if (statusTab.value) rows = rows.filter(r => String(r.payStatus) === statusTab.value)
if (kw) rows = rows.filter(r =>
String(r.id).toLowerCase().includes(kw) ||
String(r.orderId).toLowerCase().includes(kw) ||
String(r.orderGoodId).toLowerCase().includes(kw)
)
return rows
})
const searchFields: Array<{ key: string; label: string; type: 'text'; placeholder?: string }> = [
{ key: 'id', label: 'ID', type: 'text', placeholder: '支付ID' },
{ key: 'orderId', label: '订单ID', type: 'text', placeholder: '订单ID' },
{ key: 'orderGoodId', label: '小订单ID', type: 'text', placeholder: '小订单ID' }
]
const filters = reactive<Record<string, any>>({})
function onSearch() {
filterKw.value = String(filters.id || filters.orderId || filters.orderGoodId || '')
page.value = 1
}
function onStatusTab(v: string) { statusTab.value = v; page.value = 1 }
async function onMarkPaid(row: PaymentRow) {
try { await ElMessageBox.confirm(`确认「${row.id}」该款项标记为已支付?`, '提示', { type: 'warning' }) } catch { return }
ElMessage.success('已标记 (前端示例)')
}
function onViewOrder(row: PaymentRow) {
ElMessage.info(`打开订单 ${row.orderId}`)
}
function onExport() {
ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)')
}
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="订单支付回调" sub="实物订单分阶段支付款项管理" :data-source="dataSource" :api-error="apiError">
<el-button type="primary" @click="onExport">导出明细</el-button>
</PageHeader>
<el-tabs v-model="statusTab" @tab-change="onStatusTab" class="rr-tabs">
<el-tab-pane v-for="t in STATUS_TABS" :key="t.value" :name="t.value" :label="t.label" />
</el-tabs>
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" />
<DataTable
:columns="columns"
:data="filtered"
:loading="loading"
:page="page"
:limit="limit"
:total="total"
row-key="id"
@page-change="(p: number) => { page = p; load() }"
@limit-change="(l: number) => { limit = l; page = 1; load() }"
>
<template #orderId="{ row }">
<el-link type="primary" :underline="false" @click="onViewOrder(row)">{{ row.orderId }}</el-link>
</template>
<template #time="{ row }">{{ row.payDate || '--' }}</template>
<template #actions="{ row }">
<el-button type="primary" link @click="onViewOrder(row)">详情</el-button>
<el-button v-if="row.payStatus !== 7" type="success" link @click="onMarkPaid(row)">标记已付</el-button>
</template>
</DataTable>
</div>
</template>
<style scoped>
.rr-page { padding: 16px; }
.rr-tabs { margin-bottom: 12px; }
</style>

View File

@ -1,212 +0,0 @@
<script setup lang="ts">
/**
* F006 订单退款管理 - W15.3
*
* 沿用老 vue order/orderrefund.vue 字段:
* - 选择 + id + userId + supplierId + userEnterpriseId + enterpriseId
* - supplierOrderCode + payStatus + payMoney + refundTime + requestTime + remark + delFlag
*
* 端点: /order/refund/page 探测; 404 → FALLBACK seed (12 条)
*
* 状态: payStatus 1=待支付 2=已支付 / 退款处理状态: 审核中/已退款/已拒绝
*/
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.vue'
interface RefundRow {
id: string
userId: string
supplierId: string
userEnterpriseId: string
enterpriseId: string
supplierOrderCode: string
payStatus: number
payMoney: number
refundTime: string
requestTime: string
remark: string
delFlag: number
refundStatus: number // 扩展字段 (老 vue 没有, 用于退款流程: 1=审核中 2=已退款 3=已拒绝 4=已撤销)
}
const PAY_LABEL: Record<number, string> = { 1: '待支付', 2: '已支付' }
const REFUND_LABEL: Record<number, string> = { 1: '审核中', 2: '已退款', 3: '已拒绝', 4: '已撤销' }
const REFUND_TYPE: Record<number, string> = { 1: 'primary', 2: 'success', 3: 'danger', 4: 'info' }
const ENDPOINTS = ['/order/orderrefund/page', '/order/orderrefund/list']
const FALLBACK: RefundRow[] = [
{ id:'RF001', userId:'u10086', supplierId:'s20015', userEnterpriseId:'ue001', enterpriseId:'e008', supplierOrderCode:'SO20260701001', payStatus:2, payMoney:3280.00, refundTime:'2026-07-02 14:32:11', requestTime:'2026-07-01 11:25:00', remark:'商品质量问题', delFlag:0, refundStatus:2 },
{ id:'RF002', userId:'u10112', supplierId:'s20016', userEnterpriseId:'ue002', enterpriseId:'e009', supplierOrderCode:'SO20260701002', payStatus:2, payMoney:5680.00, refundTime:'', requestTime:'2026-07-03 09:42:18', remark:'发货延误', delFlag:0, refundStatus:1 },
{ id:'RF003', userId:'u10133', supplierId:'s20017', userEnterpriseId:'ue003', enterpriseId:'e010', supplierOrderCode:'SO20260702001', payStatus:2, payMoney:1880.00, refundTime:'2026-06-29 16:00:00', requestTime:'2026-06-28 13:18:30', remark:'规格不符', delFlag:0, refundStatus:3 },
{ id:'RF004', userId:'u10144', supplierId:'s20018', userEnterpriseId:'ue004', enterpriseId:'e011', supplierOrderCode:'SO20260702002', payStatus:1, payMoney:0, refundTime:'', requestTime:'', remark:'', delFlag:0, refundStatus:4 },
{ id:'RF005', userId:'u10155', supplierId:'s20019', userEnterpriseId:'ue005', enterpriseId:'e012', supplierOrderCode:'SO20260703001', payStatus:2, payMoney:4410.00, refundTime:'2026-07-04 11:08:55', requestTime:'2026-07-03 18:32:00', remark:'协商一致退款', delFlag:0, refundStatus:2 },
{ id:'RF006', userId:'u10166', supplierId:'s20020', userEnterpriseId:'ue006', enterpriseId:'e013', supplierOrderCode:'SO20260703002', payStatus:2, payMoney:13500.00, refundTime:'', requestTime:'2026-07-05 10:14:42', remark:'部分退款申请', delFlag:0, refundStatus:1 },
{ id:'RF007', userId:'u10177', supplierId:'s20021', userEnterpriseId:'ue007', enterpriseId:'e014', supplierOrderCode:'SO20260704001', payStatus:2, payMoney:3200.00, refundTime:'2026-07-06 14:30:00', requestTime:'2026-07-05 16:48:22', remark:'客户主动撤销', delFlag:0, refundStatus:4 },
{ id:'RF008', userId:'u10188', supplierId:'s20022', userEnterpriseId:'ue008', enterpriseId:'e015', supplierOrderCode:'SO20260704002', payStatus:2, payMoney:6920.00, refundTime:'', requestTime:'2026-07-06 09:55:08', remark:'物流丢件', delFlag:0, refundStatus:1 },
{ id:'RF009', userId:'u10199', supplierId:'s20023', userEnterpriseId:'ue009', enterpriseId:'e016', supplierOrderCode:'SO20260705001', payStatus:2, payMoney:9520.00, refundTime:'2026-07-02 17:20:14', requestTime:'2026-07-01 11:42:35', remark:'商品损坏', delFlag:0, refundStatus:2 },
{ id:'RF010', userId:'u10210', supplierId:'s20024', userEnterpriseId:'ue010', enterpriseId:'e017', supplierOrderCode:'SO20260705002', payStatus:2, payMoney:5360.00, refundTime:'2026-06-30 13:30:00', requestTime:'2026-06-28 16:18:50', remark:'客户拒收', delFlag:0, refundStatus:3 },
{ id:'RF011', userId:'u10221', supplierId:'s20025', userEnterpriseId:'ue011', enterpriseId:'e018', supplierOrderCode:'SO20260706001', payStatus:2, payMoney:18500.00, refundTime:'2026-07-05 14:18:50', requestTime:'2026-07-04 09:25:00', remark:'换货协商一致折现', delFlag:0, refundStatus:2 },
{ id:'RF012', userId:'u10232', supplierId:'s20026', userEnterpriseId:'ue012', enterpriseId:'e019', supplierOrderCode:'SO20260706002', payStatus:2, payMoney:2400.00, refundTime:'', requestTime:'2026-07-06 11:02:18', remark:'批次问题', delFlag:0, refundStatus:1 }
]
const STATUS_TABS = [
{ value: '', label: '全部' },
{ value: '1', label: '审核中' },
{ value: '2', label: '已退款' },
{ value: '3', label: '已拒绝' },
{ value: '4', label: '已撤销' }
]
type Col = {
prop?: string; label: string; width?: number; slot?: string; fixed?: 'right' | true; type?: 'status' | 'text' | 'selection'
}
const columns: Col[] = [
{ prop: 'id', label: '退款ID', width: 100, fixed: true },
{ prop: 'supplierOrderCode', label: '供应商订单号', width: 200, slot: 'orderCode' },
{ prop: 'userId', label: '用户ID', width: 90 },
{ prop: 'supplierId', label: '商户ID', width: 90 },
{ prop: 'enterpriseId', label: '商户企业ID', width: 110 },
{ prop: 'userEnterpriseId', label: '用户企业ID', width: 110 },
{ type: 'status', prop: 'payStatus', label: '原支付状态', width: 100 },
{ prop: 'payMoney', label: '实际支付金额', width: 120 },
{ type: 'status', prop: 'refundStatus', label: '退款状态', width: 100 },
{ prop: 'remark', label: '退款原因', width: 200, slot: 'remark' },
{ prop: 'requestTime', label: '申请时间', width: 160, slot: 'requestTime' },
{ prop: 'refundTime', label: '退款完成时间', width: 160, slot: 'refundTime' },
{ prop: 'actions', label: '操作', width: 200, fixed: 'right', slot: 'actions' }
]
const list = ref<RefundRow[]>([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const limit = ref(10)
const filterKw = ref('')
const statusTab = ref('')
const dataSource = ref<'api' | 'mock' | 'loading'>('loading')
const apiError = ref<string>('')
async function load() {
loading.value = true
dataSource.value = 'loading'
for (const ep of ENDPOINTS) {
try {
const params: any = { page: page.value, limit: limit.value }
const data: any = await httpGet<any>(ep, params)
const rows: RefundRow[] = (data?.list || []).map((it: any) => ({ ...it, raw: it }))
list.value = rows
total.value = Number(data?.total || rows.length)
dataSource.value = 'api'
apiError.value = ''
loading.value = false
return
} catch {}
}
list.value = [...FALLBACK]
total.value = FALLBACK.length
dataSource.value = 'mock'
apiError.value = '后端 API 暂未实装, 已加载前端示例数据'
loading.value = false
}
const filtered = computed(() => {
const kw = filterKw.value.trim().toLowerCase()
let rows = list.value
if (statusTab.value) rows = rows.filter(r => String(r.refundStatus) === statusTab.value)
if (kw) rows = rows.filter(r =>
String(r.id).toLowerCase().includes(kw) ||
String(r.supplierOrderCode).toLowerCase().includes(kw) ||
String(r.userId).toLowerCase().includes(kw)
)
return rows
})
const searchFields: Array<{ key: string; label: string; type: 'text' }> = [
{ key: 'id', label: '退款ID', type: 'text' },
{ key: 'supplierOrderCode', label: '供应商订单号', type: 'text' },
{ key: 'userId', label: '用户ID', type: 'text' }
]
const filters = reactive<Record<string, any>>({})
function onSearch() {
filterKw.value = String(filters.id || filters.supplierOrderCode || filters.userId || '')
page.value = 1
}
function onStatusTab(v: string) { statusTab.value = v; page.value = 1 }
async function onApprove(row: RefundRow) {
try { await ElMessageBox.confirm(`确认通过退款申请「${row.id}」?`, '提示', { type: 'warning' }) } catch { return }
row.refundStatus = 2
row.refundTime = new Date().toLocaleString('zh-CN')
ElMessage.success('已通过 (前端示例)')
}
async function onReject(row: RefundRow) {
try {
const { value } = await ElMessageBox.prompt('请填写拒绝原因', '拒绝退款', { inputPlaceholder: '原因', confirmButtonText: '拒绝', cancelButtonText: '取消' })
row.refundStatus = 3
row.remark = (value || '已拒绝').trim()
ElMessage.success('已拒绝 (前端示例)')
} catch {}
}
function onView(row: RefundRow) {
ElMessage.info(`打开退款详情 ${row.id}`)
}
function onExport() {
ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)')
}
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="退款管理" sub="实物订单退款流程审核 + 跟踪" :data-source="dataSource" :api-error="apiError">
<el-button type="primary" @click="onExport">导出明细</el-button>
</PageHeader>
<el-tabs v-model="statusTab" @tab-change="onStatusTab" class="rr-tabs">
<el-tab-pane v-for="t in STATUS_TABS" :key="t.value" :name="t.value" :label="t.label" />
</el-tabs>
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" />
<DataTable
:columns="columns"
:data="filtered"
:loading="loading"
:page="page"
:limit="limit"
:total="total"
row-key="id"
@page-change="(p: number) => { page = p; load() }"
@limit-change="(l: number) => { limit = l; page = 1; load() }"
>
<template #orderCode="{ row }">
<el-link type="primary" :underline="false" @click="onView(row)">{{ row.supplierOrderCode }}</el-link>
</template>
<template #remark="{ row }">
<span :class="{ 'rr-muted': !row.remark }">{{ row.remark || '--' }}</span>
</template>
<template #requestTime="{ row }">{{ row.requestTime || '--' }}</template>
<template #refundTime="{ row }">{{ row.refundTime || '--' }}</template>
<template #actions="{ row }">
<template v-if="row.refundStatus === 1">
<el-button type="success" link @click="onApprove(row)">通过</el-button>
<el-button type="danger" link @click="onReject(row)">拒绝</el-button>
</template>
<el-button v-else type="primary" link @click="onView(row)">详情</el-button>
</template>
</DataTable>
</div>
</template>
<style scoped>
.rr-page { padding: 16px; }
.rr-tabs { margin-bottom: 12px; }
.rr-muted { color: var(--ink-muted); }
</style>

View File

@ -1,55 +0,0 @@
<script setup lang="ts">
/**
* F006 行业/资讯分类 - P0 真 view (共用组件, props.type 区分)
* 走 /other/category/categoryDeepPageList (type: 1=行业 3=资讯分类)
* 2026-08-07 探测: type=1 total=27, type=3 total=4
* 字段: id/name/level/pid/sort/status/createDate/children
*/
import { onMounted, ref, reactive } from 'vue'
import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
interface Category { id: string; name: string; level: number; pid: string; sort: number; status: number; createDate: string; children?: Category[] }
const props = defineProps<{ type?: number; title?: string }>()
const TITLES: Record<number, string> = { 1: '行业管理', 3: '资讯分类' }
const list = ref<Category[]>([])
const total = ref(0)
const loading = ref(false)
const query = reactive({ page: 1, limit: 100 })
const columns = [
{ prop: 'id', label: 'ID', width: 80 },
{ prop: 'name', label: '名称', minWidth: 200 },
{ prop: 'level', label: '层级', width: 80, formatter: (r: Category) => ({ 1: '一级', 2: '二级', 3: '三级', 4: '四级' } as Record<number, string>)[r.level] || String(r.level) },
{ prop: 'sort', label: '排序', width: 80 },
{ prop: 'status', label: '状态', width: 90, slot: 'status' },
{ prop: 'createDate', label: '创建时间', width: 180 }
]
async function load() {
loading.value = true
try {
const r: any = await httpGet('/other/category/categoryDeepPageList', { type: props.type ?? 1, page: query.page, limit: query.limit })
list.value = r?.list || []
total.value = Number(r?.total || 0)
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
function onPageChange(p: number) { query.page = p; load() }
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader :title="props.title || TITLES[props.type ?? 1] || '分类管理'" :sub="`共 ${total} 条`" />
<div class="rr-card">
<DataTable :columns="columns" :data="list" :borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="false" row-key="id" :tree-props="{ children: 'children', hasChildren: 'hasChildren' }" empty-text="暂无数据" />
</div>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 1400px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
</style>

View File

@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 商品分类 - W10 A2 标准化 * F006 商品分类 - W6 P0 完整化
*
* W10 A2: 套 PageHeader/SearchBar (保留 el-table 树形, DataTable 不支持树形)
* *
* 沿用老 vue other/categoryGood.vue 273 行: * 沿用老 vue other/categoryGood.vue 273 行:
* - 搜索: 分类名 * - 搜索: 分类名
@ -15,9 +13,6 @@
import { ref, onMounted, reactive, computed } from 'vue' import { ref, onMounted, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
interface Category { interface Category {
id: string id: string
@ -87,28 +82,14 @@ async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const resp: any = await httpGet('/other/category/categoryDeepPageList', { type: 2, page: 1, limit: 200 }) await new Promise(r => setTimeout(r, 200))
const rawList = resp?.list || [] tree.value = TREE_DATA
tree.value = rawList.map((r: any) => normalize(r))
} catch (e: any) { } catch (e: any) {
error.value = e?.msg || e?.message || '加载失败' error.value = e?.msg || e?.message || '加载失败'
tree.value = TREE_DATA tree.value = TREE_DATA
} finally { loading.value = false } } finally { loading.value = false }
} }
function normalize(raw: any): Category {
return {
id: String(raw.id || ''),
pid: String(raw.pid || '0'),
name: raw.name || '',
level: raw.level || 1,
sort: raw.sort || 0,
createDate: raw.createDate || '',
raw,
children: (raw.children || []).map((c: any) => normalize(c))
}
}
function onSearch() {} function onSearch() {}
function onReset() { filters.name = '' } function onReset() { filters.name = '' }
function onAdd(parent?: Category) { function onAdd(parent?: Category) {
@ -157,19 +138,28 @@ onMounted(load)
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="商品分类" :sub="`商品 3 级分类管理 (含子级: ${tree.length} 个一级分类)`"> <div class="rr-page-header">
<el-button type="primary" @click="onAdd()">新增一级分类</el-button> <div>
<el-button type="danger" :disabled="selection.length === 0" @click="onBatchDelete">批量删除 ({{ selection.length }})</el-button> <h2 class="rr-page-title">商品分类</h2>
</PageHeader> <p class="rr-page-sub">商品 3 级分类管理 (含子级: {{ tree.length }} 个一级分类)</p>
</div>
<div class="rr-page-actions">
<el-button type="primary" @click="onAdd()">新增一级分类</el-button>
<el-button type="danger" :disabled="selection.length === 0" @click="onBatchDelete">批量删除 ({{ selection.length }})</el-button>
</div>
</div>
<SearchBar <div class="rr-card rr-search">
v-model:filters="filters" <el-form :inline="true" @keyup.enter="onSearch">
:fields="[ <el-form-item label="分类名称">
{ key: 'name', label: '分类名称', type: 'text', placeholder: '请输入分类名' } <el-input v-model="filters.name" placeholder="请输入分类名" clearable style="width: 240px" />
]" </el-form-item>
@search="onSearch" <el-form-item>
@reset="onReset" <el-button type="primary" @click="onSearch">查询</el-button>
/> <el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="rr-card"> <div class="rr-card">
<el-table <el-table
@ -181,7 +171,6 @@ onMounted(load)
borderless borderless
empty-text="暂无分类" empty-text="暂无分类"
class="cat-table" class="cat-table"
:header-cell-style="{ textAlign: 'center' }"
@selection-change="onSelectionChange" @selection-change="onSelectionChange"
> >
<el-table-column type="selection" width="50" /> <el-table-column type="selection" width="50" />
@ -200,16 +189,11 @@ onMounted(load)
<el-button text type="primary" size="small" @click="onMove(row, 2)">下移</el-button> <el-button text type="primary" size="small" @click="onMove(row, 2)">下移</el-button>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="140" align="center" fixed="right"> <el-table-column label="操作" width="320" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<TableActions <el-button v-if="row.level < 3" text type="primary" size="small" @click="onAdd(row)">新增子分类</el-button>
:row="row" <el-button text type="warning" size="small" @click="onEdit(row)">修改</el-button>
:actions="[ <el-button text type="danger" size="small" @click="onDelete(row)">删除</el-button>
{ label: '修改', type: 'primary', onClick: onEdit },
{ label: '新增子分类', type: 'primary', when: (r: any) => r.level < 3, onClick: onAdd },
{ label: '删除', type: 'danger', onClick: onDelete }
]"
/>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -241,5 +225,10 @@ onMounted(load)
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1500px; } .rr-page { max-width: 1500px; }
.rr-page-header { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
.rr-page-title { font-size: 22px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
.rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
.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; }
.cat-table { background: transparent; } .cat-table { background: transparent; }
</style> </style>

View File

@ -1,16 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 商品单位 - W10 A4 标准化 * F006 商品单位 - W3.x C P1.1
*
* W10 A4: 套 PageHeader/SearchBar 标准化模板 (前端筛选转 SearchBar)
* 走 /other/goodunit/list 真 API (返 data: [...] 数组, 共 112 行) * 走 /other/goodunit/list 真 API (返 data: [...] 数组, 共 112 行)
* 字段: id/name/type/supplierId/enterpriseId/createTime * 字段: id/name/type/supplierId/enterpriseId/createTime
*/ */
import { onMounted, ref, computed, reactive } from 'vue' import { onMounted, ref, computed } from 'vue'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface GoodUnit { interface GoodUnit {
id: string id: string
@ -26,7 +22,6 @@ const list = ref<GoodUnit[]>([])
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const filterKw = ref('') const filterKw = ref('')
const filters = reactive({ kw: filterKw })
const TYPE_MAP: Record<number, string> = { const TYPE_MAP: Record<number, string> = {
1: '系统', 1: '系统',
@ -56,7 +51,7 @@ async function load() {
} }
const filtered = computed(() => { const filtered = computed(() => {
const kw = (filters.kw || "").trim().toLowerCase() const kw = filterKw.value.trim().toLowerCase()
if (!kw) return list.value if (!kw) return list.value
return list.value.filter(it => it.name.toLowerCase().includes(kw)) return list.value.filter(it => it.name.toLowerCase().includes(kw))
}) })
@ -71,17 +66,14 @@ onMounted(load)
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="商品单位" :sub="`商品计量单位管理 (共 ${list.length} 个, 筛选后 ${filtered.length} 个)`" /> <div class="rr-page-header">
<h2 class="rr-page-title">商品单位</h2>
<p class="rr-page-sub">商品计量单位管理(共 {{ list.length }} 个, 筛选后 {{ filtered.length }} 个)</p>
</div>
<SearchBar <div class="rr-filters">
v-model:filters="filters" <el-input v-model="filterKw" placeholder="按名称筛选" clearable class="rr-filter-input" size="default" />
:fields="[ </div>
{ key: 'kw', label: '名称', type: 'text', placeholder: '按名称筛选' }
]"
:show-reset="false"
@search="() => {}"
@reset="() => { filterKw = '' }"
/>
<div class="rr-card"> <div class="rr-card">
<DataTable <DataTable
@ -98,7 +90,7 @@ onMounted(load)
:total="filtered.length" :total="filtered.length"
:page="1" :page="1"
:limit="filtered.length || 1" :limit="filtered.length || 1"
:show-pagination="list.length > 20" :page-sizes="[10, 20, 50, 100]" :show-pagination="list.length > 20"
:error="error" :error="error"
empty-text="暂无单位" empty-text="暂无单位"
borderless borderless
@ -120,5 +112,11 @@ onMounted(load)
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1400px; } .rr-page { max-width: 1400px; }
.rr-page-header { margin-bottom: 16px; }
.rr-page-title { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; color: var(--ink); margin-bottom: 4px; }
.rr-page-sub { color: var(--ink-3); font-size: 13px; }
.rr-filters { margin-bottom: 16px; display: flex; gap: 8px; }
.rr-filter-input { width: 280px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
.rr-time { font-family: 'SF Mono', Menlo, monospace; font-size: 12px; color: var(--ink-2); } .rr-time { font-family: 'SF Mono', Menlo, monospace; font-size: 12px; color: var(--ink-2); }
</style> </style>

View File

@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 拼团活动 - W10 A3 标准化 * F006 拼团活动 - W6 P0 完整化
*
* W10 A3: 套 PageHeader/SearchBar 标准化模板
* *
* 沿用老 vue other/groupbuy.vue 116 行: * 沿用老 vue other/groupbuy.vue 116 行:
* - 搜索: 活动名称 * - 搜索: 活动名称
@ -14,8 +12,6 @@
import { ref, onMounted, reactive } from 'vue' import { ref, onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface Groupbuy { interface Groupbuy {
id: string id: string
@ -102,18 +98,25 @@ onMounted(load)
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="拼团活动" :sub="`拼团/团购活动管理 (共 ${total} 个)`"> <div class="rr-page-header">
<el-button type="danger" :disabled="selection.length === 0" @click="onBatchDelete">批量删除 ({{ selection.length }})</el-button> <div>
</PageHeader> <h2 class="rr-page-title">拼团活动</h2>
<p class="rr-page-sub">拼团/团购活动管理 (共 {{ total }} 个)</p>
</div>
</div>
<SearchBar <div class="rr-card rr-search">
v-model:filters="filters" <el-form :inline="true" @keyup.enter="onSearch">
:fields="[ <el-form-item label="活动名称">
{ key: 'name', label: '活动名称', type: 'text', placeholder: '请输入活动名称' } <el-input v-model="filters.name" placeholder="请输入活动名称" clearable style="width: 280px" />
]" </el-form-item>
@search="onSearch" <el-form-item>
@reset="onReset" <el-button @click="onReset">重置</el-button>
/> <el-button type="primary" @click="onSearch">查询</el-button>
<el-button type="danger" :disabled="selection.length === 0" @click="onBatchDelete">批量删除 ({{ selection.length }})</el-button>
</el-form-item>
</el-form>
</div>
<div class="rr-card"> <div class="rr-card">
<div class="rr-pagination"> <div class="rr-pagination">
@ -142,5 +145,10 @@ onMounted(load)
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1400px; } .rr-page { max-width: 1400px; }
.rr-page-header { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
.rr-page-title { font-size: 22px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
.rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
.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-pagination { padding: 12px 20px; display: flex; justify-content: flex-end; border-top: 1px solid var(--border); } .rr-pagination { padding: 12px 20px; display: flex; justify-content: flex-end; border-top: 1px solid var(--border); }
</style> </style>

View File

@ -1,62 +0,0 @@
<script setup lang="ts">
/**
* F006 帮助中心 - P1 真 view
*
* 走 /other/help/page
* 真实接口 (2026-08-07 探测: 200, total=1)
* 字段: id/title/content/pid/sort/createTime
*/
import { onMounted, ref, reactive } from 'vue'
import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
interface HelpItem {
id: string
title: string
content: string
pid: string
sort: number
createTime: string
}
const list = ref<HelpItem[]>([])
const total = ref(0)
const loading = ref(false)
const query = reactive({ page: 1, limit: 10 })
const columns = [
{ prop: 'id', label: 'ID', width: 80 },
{ prop: 'title', label: '标题', minWidth: 240 },
{ prop: 'sort', label: '排序', width: 90, formatter: (r: HelpItem) => String(r.sort ?? 0) },
{ prop: 'createTime', label: '创建时间', width: 180 }
]
async function load() {
loading.value = true
try {
const resp: any = await httpGet('/other/help/page', { page: query.page, limit: query.limit })
list.value = resp?.list || []
total.value = Number(resp?.total || 0)
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
function onPageChange(p: number) { query.page = p; load() }
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="帮助中心" :sub="`帮助文档 (共 ${total} 条)`" />
<div class="rr-card">
<DataTable :columns="columns" :data="list"
:borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="true" row-key="id" empty-text="暂无内容" @page-change="onPageChange"
@limit-change="onLimitChange" />
</div>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 1400px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
</style>

View File

@ -1,61 +0,0 @@
<script setup lang="ts">
/**
* F006 资讯列表 - P1 真 view
*
* 走 /other/information/page
* 真实接口 (2026-08-07 探测: 200, total=7)
* 字段: id/title/categoryId/categoryName/content/createTime
*/
import { onMounted, ref, reactive } from 'vue'
import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
interface Info {
id: string
title: string
categoryId: string
categoryName: string
createTime: string
}
const list = ref<Info[]>([])
const total = ref(0)
const loading = ref(false)
const query = reactive({ page: 1, limit: 10 })
const columns = [
{ prop: 'id', label: 'ID', width: 90 },
{ prop: 'title', label: '资讯标题', minWidth: 280 },
{ prop: 'categoryName', label: '分类', width: 140, formatter: (r: Info) => r.categoryName || '--' },
{ prop: 'createTime', label: '发布时间', width: 180 }
]
async function load() {
loading.value = true
try {
const resp: any = await httpGet('/other/information/page', { page: query.page, limit: query.limit })
list.value = resp?.list || []
total.value = Number(resp?.total || 0)
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
function onPageChange(p: number) { query.page = p; load() }
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="资讯列表" :sub="`平台资讯 (共 ${total} 条)`" />
<div class="rr-card">
<DataTable :columns="columns" :data="list"
:borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="true" row-key="id" empty-text="暂无资讯" @page-change="onPageChange"
@limit-change="onLimitChange" />
</div>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 1400px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
</style>

View File

@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 标签管理 - W10 A1 标准化 * F006 标签管理 - 完整版 (W6 整改)
*
* W10 A1: 套 PageHeader/SearchBar 标准化模板
* *
* 沿用老 vue other/label.vue 173 行: * 沿用老 vue other/label.vue 173 行:
* - 搜索: 标签名 * - 搜索: 标签名
@ -17,9 +15,6 @@ import { ref, onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
interface Label { interface Label {
id: string id: string
@ -108,18 +103,27 @@ onMounted(load)
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="标签管理" :sub="`商品/活动/服务标签分类管理 (共 ${total} 个)`"> <div class="rr-page-header">
<el-button type="primary" @click="onAdd">新增标签</el-button> <div>
</PageHeader> <h2 class="rr-page-title">标签管理</h2>
<p class="rr-page-sub">商品/活动/服务标签分类管理 (共 {{ total }} 个)</p>
</div>
<div class="rr-page-actions">
<el-button type="primary" @click="onAdd">新增标签</el-button>
</div>
</div>
<SearchBar <div class="rr-card rr-search">
v-model:filters="filters" <el-form :inline="true" @keyup.enter="onSearch">
:fields="[ <el-form-item label="标签名称">
{ key: 'name', label: '标签名称', type: 'text', placeholder: '请输入标签名' } <el-input v-model="filters.name" placeholder="请输入标签名" clearable style="width: 240px" />
]" </el-form-item>
@search="onSearch" <el-form-item>
@reset="onReset" <el-button type="primary" @click="onSearch">查询</el-button>
/> <el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="rr-card"> <div class="rr-card">
<DataTable <DataTable
@ -127,14 +131,14 @@ onMounted(load)
{ prop: 'name', label: '标签名称', minWidth: 200 }, { prop: 'name', label: '标签名称', minWidth: 200 },
{ prop: 'createDate', label: '创建时间', width: 180 }, { prop: 'createDate', label: '创建时间', width: 180 },
{ prop: 'status', label: '状态', width: 100, slot: 'status' }, { prop: 'status', label: '状态', width: 100, slot: 'status' },
{ label: '操作', width: 140, align: 'center', slot: 'action' } { label: '操作', width: 240, align: 'center', slot: 'action' }
]" ]"
:data="list" :data="list"
:loading="loading" :loading="loading"
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="total > 10" :page-sizes="[10, 20, 50, 100]" :show-pagination="total > 10"
:error="error" :error="error"
empty-text="暂无标签" empty-text="暂无标签"
borderless borderless
@ -148,14 +152,11 @@ onMounted(load)
</el-tag> </el-tag>
</template> </template>
<template #action="{ row }"> <template #action="{ row }">
<TableActions <el-button text type="primary" size="small" @click="onEdit(row)">修改</el-button>
:row="row" <el-button text :type="row.status === 0 ? 'warning' : 'success'" size="small" @click="onToggleStatus(row)">
:actions="[ {{ row.status === 0 ? '禁用' : '启用' }}
{ label: '修改', type: 'primary', onClick: onEdit }, </el-button>
{ label: (r: any) => r.status === 0 ? '禁用' : '启用', type: (r: any) => r.status === 0 ? 'warning' : 'success', onClick: onToggleStatus }, <el-button text type="danger" size="small" @click="onDelete(row)">删除</el-button>
{ label: '删除', type: 'danger', onClick: onDelete }
]"
/>
</template> </template>
</DataTable> </DataTable>
</div> </div>
@ -183,4 +184,9 @@ onMounted(load)
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1400px; } .rr-page { max-width: 1400px; }
.rr-page-header { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
.rr-page-title { font-size: 22px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
.rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
.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; }
</style> </style>

View File

@ -1,14 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 操作指南 - W10 A7 标准化 * F006 操作指南 - W3.x C P0.4
*
* W10 A7: 套 PageHeader (无搜索, 简洁)
* 走 /other/operateguide/page * 走 /other/operateguide/page
*/ */
import { onMounted, ref, reactive } from 'vue' import { onMounted, ref, reactive } from 'vue'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
interface Guide { interface Guide {
id: string id: string
@ -23,7 +20,7 @@ interface Guide {
const list = ref<Guide[]>([]) const list = ref<Guide[]>([])
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const query = reactive({ page: 1, limit: 10 }) const query = reactive({ page: 1, limit: 20 })
const columns = [ const columns = [
{ prop: 'id', label: 'ID', width: 80 }, { prop: 'id', label: 'ID', width: 80 },
@ -41,22 +38,25 @@ async function load() {
} catch { list.value = []; total.value = 0 } finally { loading.value = false } } catch { list.value = []; total.value = 0 } finally { loading.value = false }
} }
function onPageChange(p: number) { query.page = p; load() } function onPageChange(p: number) { query.page = p; load() }
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
onMounted(load) onMounted(load)
</script> </script>
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="操作指南" :sub="`操作指南内容 (共 ${total} 条)`" /> <div class="rr-page-header">
<h2 class="rr-page-title">内容列表</h2>
<p class="rr-page-sub">操作指南内容(共 {{ total }} 条)</p>
</div>
<div class="rr-card"> <div class="rr-card">
<DataTable :columns="columns" :data="list" <DataTable :columns="columns" :data="list" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="true" row-key="id" empty-text="暂无内容" @page-change="onPageChange" />
:borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="true" row-key="id" empty-text="暂无内容" @page-change="onPageChange"
@limit-change="onLimitChange" />
</div> </div>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1400px; } .rr-page { max-width: 1400px; }
.rr-page-header { margin-bottom: 20px; }
.rr-page-title { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; color: var(--ink); margin-bottom: 4px; }
.rr-page-sub { color: var(--ink-3); font-size: 13px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; } .rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
</style> </style>

View File

@ -1,256 +0,0 @@
<script setup lang="ts">
/**
* 价格公式系统变量管理 (运营后台)
*
* 走 /supplier/supplierGoodPriceFormulaVariable/ API
* 功能: 变量 CRUD + 快速改值 (改后 SKU 公式价格自动变化)
* 字段: varKey/varName/varUnit/currentValue/sourceType/sourceCode/formula/sort/status
*/
import { onMounted, ref, reactive } from 'vue'
import { httpGet, httpPost } from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
interface Variable {
id: string
varKey: string
varName: string
varUnit: string
currentValue: number
sourceType: number
sourceCode: string
formula: string
sort: number
status: number
createDate: string
updateDate: string
}
const list = ref<Variable[]>([])
const loading = ref(false)
const total = ref(0)
const page = ref(1)
const limit = ref(20)
const keyword = ref('')
// 新增/编辑弹窗
const dialogVisible = ref(false)
const saving = ref(false)
const form = reactive<any>({
id: null, varKey: '', varName: '', varUnit: '', currentValue: 0,
sourceType: 1, sourceCode: '', formula: '', sort: 0, status: 1
})
// 快速改值
const quickEditId = ref<string | null>(null)
const quickValue = ref('')
const SOURCE_MAP: Record<number, string> = { 1: '手工维护', 2: 'AKShare采集' }
async function load() {
loading.value = true
try {
const data: any = await httpGet('/supplier/supplierGoodPriceFormulaVariable/page', {
page: page.value, limit: limit.value, keyword: keyword.value || undefined
})
list.value = (data?.list || []).map((it: any) => ({
id: String(it.id), varKey: it.var_key || '--', varName: it.var_name || '--',
varUnit: it.var_unit || '', currentValue: Number(it.current_value || 0),
sourceType: Number(it.source_type || 1), sourceCode: it.source_code || '',
formula: it.formula || '', sort: Number(it.sort || 0), status: Number(it.status || 1),
createDate: it.create_date || '--', updateDate: it.update_date || '--'
}))
total.value = Number(data?.total || 0)
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '加载失败')
list.value = []
} finally {
loading.value = false
}
}
function onSearch() { page.value = 1; load() }
function onReset() { keyword.value = ''; page.value = 1; load() }
function onPageChange(p: number) { page.value = p; load() }
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
function openAdd() {
Object.assign(form, { id: null, varKey: '', varName: '', varUnit: '', currentValue: 0, sourceType: 1, sourceCode: '', formula: '', sort: 0, status: 1 })
dialogVisible.value = true
}
function openEdit(row: Variable) {
Object.assign(form, {
id: row.id, varKey: row.varKey, varName: row.varName, varUnit: row.varUnit,
currentValue: row.currentValue, sourceType: row.sourceType, sourceCode: row.sourceCode,
formula: row.formula, sort: row.sort, status: row.status
})
dialogVisible.value = true
}
async function save() {
if (!form.varKey.trim() || !form.varName.trim()) {
ElMessage.warning('变量标识和变量名必填'); return
}
saving.value = true
try {
await httpPost('/supplier/supplierGoodPriceFormulaVariable/save', { ...form })
ElMessage.success('保存成功')
dialogVisible.value = false
load()
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '保存失败')
} finally { saving.value = false }
}
async function quickUpdateValue(row: Variable) {
if (quickValue.value === '') return
try {
await httpPost('/supplier/supplierGoodPriceFormulaVariable/updateValue', { id: row.id, currentValue: quickValue.value })
ElMessage.success(`「${row.varName}」值已更新为 ${quickValue.value}, 相关商品价格将自动变化`)
quickEditId.value = null
quickValue.value = ''
load()
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '更新失败')
}
}
async function remove(row: Variable) {
try {
await ElMessageBox.confirm(`确认删除变量「${row.varName}」? 引用该变量的商品公式将无法计算`, '删除确认', { type: 'warning' })
await httpGet('/supplier/supplierGoodPriceFormulaVariable/delete', { id: row.id })
ElMessage.success('删除成功')
load()
} catch (e: any) {
if (e === 'cancel' || e === 'close') return
ElMessage.error(e?.msg || e?.message || '删除失败')
}
}
function toggleStatus(row: Variable) {
const next = row.status === 1 ? 0 : 1
httpPost('/supplier/supplierGoodPriceFormulaVariable/save', {
id: row.id, varKey: row.varKey, varName: row.varName, varUnit: row.varUnit,
currentValue: row.currentValue, sourceType: row.sourceType, sourceCode: row.sourceCode,
formula: row.formula, sort: row.sort, status: next
}).then(() => {
ElMessage.success(next === 1 ? '已启用' : '已禁用')
load()
}).catch((e: any) => ElMessage.error(e?.msg || e?.message || '操作失败'))
}
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="价格变量管理" :sub="`系统级价格变量 (共 ${total} 个), 修改变量值后所有引用商品的售价自动更新`">
<el-button type="primary" @click="openAdd">新增变量</el-button>
</PageHeader>
<div class="rr-card">
<div class="flex items-center gap-3 mb-4">
<el-input v-model="keyword" placeholder="按变量名/标识搜索" clearable style="width: 260px" @keyup.enter="onSearch" />
<el-button type="primary" @click="onSearch">搜索</el-button>
<el-button @click="onReset">重置</el-button>
</div>
<DataTable
:columns="[
{ prop: 'varName', label: '变量名', width: 120 },
{ prop: 'varKey', label: '标识', width: 140 },
{ prop: 'varUnit', label: '单位', width: 80 },
{ prop: 'currentValue', label: '当前值', width: 130, slot: 'value' },
{ prop: 'sourceType', label: '取值方式', width: 110, slot: 'source' },
{ prop: 'sourceCode', label: '采集代码', width: 100 },
{ prop: 'status', label: '状态', width: 80, slot: 'status' },
{ prop: 'updateDate', label: '更新时间', width: 170 },
{ prop: 'action', label: '操作', width: 220, slot: 'action', fixed: 'right' }
]"
:data="list"
:loading="loading"
:total="total"
:page="page"
:limit="limit"
:show-pagination="true"
:page-sizes="[10, 20, 50, 100]"
@page-change="onPageChange"
@limit-change="onLimitChange"
empty-text="暂无变量"
borderless
row-key="id"
>
<template #value="{ row }">
<div class="flex items-center gap-2">
<span class="font-bold text-primary">¥{{ Number(row.currentValue).toLocaleString() }}</span>
<template v-if="quickEditId === row.id">
<el-input v-model="quickValue" size="small" style="width: 90px" placeholder="新值" @keyup.enter="quickUpdateValue(row)" />
<el-button size="small" type="primary" @click="quickUpdateValue(row)">确认</el-button>
<el-button size="small" @click="quickEditId = null; quickValue = ''">取消</el-button>
</template>
<el-button v-else size="small" text type="primary" @click="quickEditId = row.id; quickValue = String(row.currentValue)">改值</el-button>
</div>
</template>
<template #source="{ row }">
<el-tag :type="row.sourceType === 2 ? 'warning' : 'info'" size="small" effect="light">
{{ SOURCE_MAP[row.sourceType] || '未知' }}
</el-tag>
</template>
<template #status="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small" effect="light">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
<template #action="{ row }">
<el-link type="primary" :underline="false" @click="openEdit(row)">编辑</el-link>
<el-divider direction="vertical" />
<el-link type="warning" :underline="false" @click="toggleStatus(row)">{{ row.status === 1 ? '禁用' : '启用' }}</el-link>
<el-divider direction="vertical" />
<el-link type="danger" :underline="false" @click="remove(row)">删除</el-link>
</template>
</DataTable>
</div>
<!-- 新增/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑变量' : '新增变量'" width="520px" destroy-on-close>
<el-form label-width="90px" label-position="left">
<el-form-item label="变量标识" required>
<el-input v-model="form.varKey" placeholder="如 copper_price (英文/下划线, 唯一)" :disabled="!!form.id" />
</el-form-item>
<el-form-item label="变量名" required>
<el-input v-model="form.varName" placeholder="如 铜价 (公式中显示的名称)" />
</el-form-item>
<el-form-item label="单位">
<el-input v-model="form.varUnit" placeholder="如 元/吨" />
</el-form-item>
<el-form-item label="当前值">
<el-input-number v-model="form.currentValue" :precision="4" :step="100" style="width: 100%" />
</el-form-item>
<el-form-item label="取值方式">
<el-radio-group v-model="form.sourceType">
<el-radio :value="1">手工维护</el-radio>
<el-radio :value="2">AKShare采集</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.sourceType === 2" label="采集代码">
<el-input v-model="form.sourceCode" placeholder="AKShare 商品代码, 如 copper/aluminum/steel" />
</el-form-item>
<el-form-item v-if="form.sourceType === 2" label="换算公式">
<el-input v-model="form.formula" placeholder="可选, 如 raw * 1.05 (raw=采集原始值)" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>

View File

@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 秒杀活动 - W10 A3 标准化 * F006 秒杀活动 - W6 P0 完整化
*
* W10 A3: 套 PageHeader/SearchBar 标准化模板
* *
* 沿用老 vue other/seckill.vue 113 行: * 沿用老 vue other/seckill.vue 113 行:
* - 搜索: 活动名称 * - 搜索: 活动名称
@ -14,8 +12,6 @@
import { ref, onMounted, reactive } from 'vue' import { ref, onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface Seckill { interface Seckill {
id: string id: string
@ -103,18 +99,25 @@ onMounted(load)
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="秒杀活动" :sub="`限时秒杀活动管理 (共 ${total} 个)`"> <div class="rr-page-header">
<el-button type="danger" :disabled="selection.length === 0" @click="onBatchDelete">批量删除 ({{ selection.length }})</el-button> <div>
</PageHeader> <h2 class="rr-page-title">秒杀活动</h2>
<p class="rr-page-sub">限时秒杀活动管理 (共 {{ total }} 个)</p>
</div>
</div>
<SearchBar <div class="rr-card rr-search">
v-model:filters="filters" <el-form :inline="true" @keyup.enter="onSearch">
:fields="[ <el-form-item label="活动名称">
{ key: 'name', label: '活动名称', type: 'text', placeholder: '请输入活动名称' } <el-input v-model="filters.name" placeholder="请输入活动名称" clearable style="width: 280px" />
]" </el-form-item>
@search="onSearch" <el-form-item>
@reset="onReset" <el-button @click="onReset">重置</el-button>
/> <el-button type="primary" @click="onSearch">查询</el-button>
<el-button type="danger" :disabled="selection.length === 0" @click="onBatchDelete">批量删除 ({{ selection.length }})</el-button>
</el-form-item>
</el-form>
</div>
<div class="rr-card"> <div class="rr-card">
<div class="rr-pagination"> <div class="rr-pagination">
@ -143,5 +146,10 @@ onMounted(load)
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1400px; } .rr-page { max-width: 1400px; }
.rr-page-header { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
.rr-page-title { font-size: 22px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
.rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
.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-pagination { padding: 12px 20px; display: flex; justify-content: flex-end; border-top: 1px solid var(--border); } .rr-pagination { padding: 12px 20px; display: flex; justify-content: flex-end; border-top: 1px solid var(--border); }
</style> </style>

View File

@ -1,28 +1,21 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 品牌管理 (供应商品牌审核) - 真页面重写 2026-07-28 * F006 供应商品牌审核 - W6 P0 完整化
* *
* 对齐老 vue other/supplierBrand.vue: * 沿用老 vue other/supplierBrand.vue 230 行:
* - 搜索: 品牌名称/企业名称/审核状态/申请时间范围 * - 搜索: 品牌名/企业名/审核状态/申请时间范围
* - 表格列: 品牌来源/企业名称/品牌名称/授权书(50px缩略)/审核状态/申请时间 * - 表格列: sourceEnterpriseName/enterpriseName/brandName/authUrl(50px缩略)/status(1待/2通过/3拒绝)/createDate
* - 操作: 审核(仅待审) / 删除 / 批量删除 * - 操作: 审核(仅待审) / 删除
* - 审核弹窗: 通过/拒绝 + 拒绝原因 (POST /other/GoodBrand/updateCheck) * - 审核弹窗: 通过/拒绝 + 拒绝原因
* 需求3: 查看授权企业 — 平台品牌(sourceType=1)可查看被授权企业列表 (sourceType=2 同品牌) * - 批量删除
* * - /other/supplierBrand 后端 404 → FALLBACK mock
* 端点:
* GET /other/GoodBrand/page?page=&limit=&brandName=&enterpriseName=&status=&startDate=&endDate=&sourceType=
* POST /other/GoodBrand/updateCheck {id,status,failName}
* POST /other/GoodBrand/delete [ids]
*/ */
import { ref, onMounted, reactive } from 'vue' import { ref, onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpPost } from '@/utils/request' import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface Brand { interface Brand {
id: string id: string
sourceType: number // 1平台新增 2授权
sourceEnterpriseName: string sourceEnterpriseName: string
enterpriseName: string enterpriseName: string
brandName: string brandName: string
@ -39,68 +32,49 @@ const STATUS_MAP: Record<number, { label: string; type: string }> = {
3: { label: '审核拒绝', type: 'danger' } 3: { label: '审核拒绝', type: 'danger' }
} }
const FALLBACK: Brand[] = [
{ id: 'B001', sourceEnterpriseName: '中国钢铁工业协会', enterpriseName: '宝钢集团', brandName: '宝钢', authUrl: 'https://placehold.co/100x100/2563EB/fff?text=Auth1', status: 1, createDate: '2026-07-01 10:00:00', raw: {} },
{ id: 'B002', sourceEnterpriseName: '中国水泥协会', enterpriseName: '海螺水泥', brandName: '海螺', authUrl: 'https://placehold.co/100x100/059669/fff?text=Auth2', status: 2, createDate: '2026-06-28 14:20:00', raw: {} },
{ id: 'B003', sourceEnterpriseName: '国家建材测试中心', enterpriseName: '北新建材', brandName: '龙牌', authUrl: 'https://placehold.co/100x100/DC2626/fff?text=Auth3', status: 3, createDate: '2026-06-25 11:15:00', failName: '授权书扫描件不清晰, 请重新上传', raw: {} },
{ id: 'B004', sourceEnterpriseName: '中国建材联合会', enterpriseName: '金隅集团', brandName: '金隅', authUrl: 'https://placehold.co/100x100/5645D4/fff?text=Auth4', status: 1, createDate: '2026-07-02 16:30:00', raw: {} },
{ id: 'B005', sourceEnterpriseName: '中国五金制品协会', enterpriseName: '长城精工', brandName: '长城', authUrl: 'https://placehold.co/100x100/F59E0B/fff?text=Auth5', status: 2, createDate: '2026-06-30 09:00:00', raw: {} },
{ id: 'B006', sourceEnterpriseName: '中国建筑材料联合会', enterpriseName: '华润水泥', brandName: '华润', authUrl: 'https://placehold.co/100x100/8B5CF6/fff?text=Auth6', status: 1, createDate: '2026-07-03 11:45:00', raw: {} }
]
const list = ref<Brand[]>([]) const list = ref<Brand[]>([])
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const page = ref(1) const page = ref(1)
const limit = ref(10) const limit = ref(10)
const filters = reactive({ brandName: '', enterpriseName: '', status: '', range: [] as string[], _startDate: '', _endDate: '' }) const filters = reactive({ brandName: '', enterpriseName: '', status: '', startDate: '', endDate: '' })
const selection = ref<Brand[]>([]) const selection = ref<Brand[]>([])
const auditVisible = ref(false) const auditVisible = ref(false)
const auditForm = reactive<{ id: string; status: number; failName: string }>({ id: '', status: 2, failName: '' }) const auditForm = reactive<{ id: string; status: number; failName: string }>({ id: '', status: 2, failName: '' })
// 授权企业弹窗
const authVisible = ref(false)
const authBrandName = ref('')
const authList = ref<{ enterpriseName: string; sourceEnterpriseName: string; status: number; createDate: string }[]>([])
const authLoading = ref(false)
async function load() { async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const params: any = { page: page.value, limit: limit.value } await new Promise(r => setTimeout(r, 200))
if (filters.brandName) params.brandName = filters.brandName let rows = [...FALLBACK]
if (filters.enterpriseName) params.enterpriseName = filters.enterpriseName if (filters.brandName) rows = rows.filter(r => r.brandName.includes(filters.brandName))
if (filters.status !== '') params.status = filters.status if (filters.enterpriseName) rows = rows.filter(r => r.enterpriseName.includes(filters.enterpriseName))
if (filters.range?.length === 2) { if (filters.status) rows = rows.filter(r => r.status === Number(filters.status))
params.startDate = `${filters.range[0]} 00:00:00` list.value = rows
params.endDate = `${filters.range[1]} 23:59:59` total.value = rows.length
}
const data: any = await httpGet('/other/GoodBrand/page', params)
const rows: any[] = data?.list || []
list.value = rows.map((it: any) => ({
id: String(it.id),
sourceType: it.sourceType ?? 1,
sourceEnterpriseName: it.sourceEnterpriseName || '--',
enterpriseName: it.enterpriseName || '--',
brandName: it.brandName || '--',
authUrl: it.authUrl || '',
status: Number(it.status) || 1,
createDate: it.createDate || '--',
failName: it.failName,
raw: it
}))
total.value = Number(data?.total) || 0
} catch (e: any) { } catch (e: any) {
error.value = e?.msg || e?.message || '加载失败' error.value = e?.msg || e?.message || '加载失败'
list.value = [] list.value = FALLBACK
total.value = 0 total.value = FALLBACK.length
} finally { loading.value = false } } finally { loading.value = false }
} }
function onSearch() { page.value = 1; load() } function onSearch() { page.value = 1; load() }
function onReset() { function onReset() { filters.brandName = ''; filters.enterpriseName = ''; filters.status = ''; filters.startDate = ''; filters.endDate = ''; load() }
filters.brandName = ''
filters.enterpriseName = ''
filters.status = ''
filters.range = []
onSearch()
}
function onPageChange(p: number) { page.value = p; load() } function onPageChange(p: number) { page.value = p; load() }
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() } function onLimitChange(l: number) { limit.value = l; load() }
function onSelectionChange(rows: Brand[]) { selection.value = rows } function onSelectionChange(rows: Brand[]) { selection.value = rows }
function onAudit(row: Brand) { function onAudit(row: Brand) {
@ -109,63 +83,24 @@ function onAudit(row: Brand) {
auditForm.failName = '' auditForm.failName = ''
auditVisible.value = true auditVisible.value = true
} }
async function onSubmitAudit() { function onSubmitAudit() {
if (auditForm.status === 3 && !auditForm.failName.trim()) { ElMessage.warning('请填写拒绝原因'); return } if (auditForm.status === 3 && !auditForm.failName.trim()) { ElMessage.warning('请填写拒绝原因'); return }
try { ElMessage.success(`审核${auditForm.status === 2 ? '通过' : '拒绝'}成功 (mock)`)
await httpPost('/other/GoodBrand/updateCheck', { auditVisible.value = false
id: auditForm.id, load()
status: auditForm.status, }
failName: auditForm.status === 3 ? auditForm.failName.trim() : '' function onDelete(row: Brand) {
}) ElMessageBox.confirm(`确认删除「${row.brandName}」?`, '提示', { type: 'warning' }).then(() => {
ElMessage.success(`审核${auditForm.status === 2 ? '通过' : '拒绝'}成功`) ElMessage.success('已删除 (mock)')
auditVisible.value = false
load() load()
} catch (e: any) { }).catch(() => {})
ElMessage.error(e?.msg || e?.message || '审核失败')
}
} }
function onBatchDelete() {
async function onViewAuth(row: Brand) {
authBrandName.value = row.brandName
authVisible.value = true
authList.value = []
authLoading.value = true
try {
// 该品牌名下所有授权记录 (sourceType=2)
const data: any = await httpGet('/other/GoodBrand/page', { page: 1, limit: 100, brandName: row.brandName, sourceType: 2 })
authList.value = (data?.list || []).map((it: any) => ({
enterpriseName: it.enterpriseName || '--',
sourceEnterpriseName: it.sourceEnterpriseName || '--',
status: Number(it.status) || 1,
createDate: it.createDate || '--'
}))
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '加载授权企业失败')
authList.value = []
} finally { authLoading.value = false }
}
async function onDelete(row: Brand) {
await ElMessageBox.confirm(`确认删除「${row.brandName}」?`, '提示', { type: 'warning' })
try {
await httpPost('/other/GoodBrand/delete', [row.id])
ElMessage.success('删除成功')
load()
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '删除失败')
}
}
async function onBatchDelete() {
if (selection.value.length === 0) { ElMessage.warning('请先选择要删除的项'); return } if (selection.value.length === 0) { ElMessage.warning('请先选择要删除的项'); return }
await ElMessageBox.confirm(`确认批量删除选中的 ${selection.value.length} 项?`, '提示', { type: 'warning' }) ElMessageBox.confirm(`确认批量删除选中的 ${selection.value.length} 项?`, '提示', { type: 'warning' }).then(() => {
try { ElMessage.success('已批量删除 (mock)')
await httpPost('/other/GoodBrand/delete', selection.value.map(s => s.id))
ElMessage.success('删除成功')
load() load()
} catch (e: any) { }).catch(() => {})
ElMessage.error(e?.msg || e?.message || '删除失败')
}
} }
onMounted(load) onMounted(load)
@ -173,71 +108,39 @@ onMounted(load)
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="品牌管理" :sub="`供应商品牌审核与授权管理 (共 ${total} 个)`"> <div class="rr-page-header">
<el-button type="danger" :disabled="selection.length === 0" @click="onBatchDelete">批量删除 ({{ selection.length }})</el-button> <div>
</PageHeader> <h2 class="rr-page-title">供应商品牌审核</h2>
<p class="rr-page-sub">供应商提交的品牌授权审核 (共 {{ total }} 个)</p>
</div>
</div>
<SearchBar <div class="rr-card rr-search">
v-model:filters="filters" <el-form :inline="true" @keyup.enter="onSearch">
:fields="[ <el-form-item label="品牌名称">
{ key: 'brandName', label: '品牌名称', type: 'text', placeholder: '请输入品牌名' }, <el-input v-model="filters.brandName" placeholder="请输入品牌名" clearable style="width: 180px" />
{ key: 'enterpriseName', label: '企业名称', type: 'text', placeholder: '请输入企业名' }, </el-form-item>
{ key: 'status', label: '审核状态', type: 'select', options: [ <el-form-item label="企业名称">
{ value: 1, label: '待审核' }, <el-input v-model="filters.enterpriseName" placeholder="请输入企业名" clearable style="width: 180px" />
{ value: 2, label: '审核通过' }, </el-form-item>
{ value: 3, label: '审核拒绝' } <el-form-item label="审核状态">
] }, <el-select v-model="filters.status" placeholder="全部" clearable style="width: 140px">
{ key: 'range', label: '申请时间', type: 'daterange' } <el-option label="待审核" :value="1" />
]" <el-option label="审核通过" :value="2" />
@search="onSearch" <el-option label="审核拒绝" :value="3" />
@reset="onReset" </el-select>
/> </el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">查询</el-button>
<el-button @click="onReset">重置</el-button>
<el-button type="danger" :disabled="selection.length === 0" @click="onBatchDelete">批量删除 ({{ selection.length }})</el-button>
</el-form-item>
</el-form>
</div>
<div class="rr-card"> <div class="rr-card">
<el-table v-loading="loading" :data="list" border @selection-change="onSelectionChange" style="width: 100%">
<el-table-column type="selection" header-align="center" align="center" width="50" />
<el-table-column prop="sourceType" label="类型" header-align="center" align="center" width="90">
<template #default="{ row }">
<el-tag :type="row.sourceType === 2 ? 'primary' : 'info'" size="small">{{ row.sourceType === 2 ? '授权品牌' : '平台品牌' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="sourceEnterpriseName" label="品牌来源" header-align="center" align="center" min-width="140" show-overflow-tooltip />
<el-table-column prop="enterpriseName" label="企业名称" header-align="center" align="center" min-width="140" show-overflow-tooltip />
<el-table-column prop="brandName" label="品牌名称" header-align="center" align="center" min-width="120" show-overflow-tooltip />
<el-table-column label="品牌授权书" header-align="center" align="center" width="90">
<template #default="{ row }">
<el-image v-if="row.authUrl" :src="row.authUrl" fit="cover" :preview-src-list="[row.authUrl]" class="brand-auth" />
<span v-else class="rr-muted">--</span>
</template>
</el-table-column>
<el-table-column prop="status" label="审核状态" header-align="center" align="center" width="100">
<template #default="{ row }">
<el-tag :type="STATUS_MAP[row.status]?.type || 'info'" size="small">{{ STATUS_MAP[row.status]?.label || row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createDate" label="申请时间" header-align="center" align="center" width="170" show-overflow-tooltip />
<el-table-column label="操作" fixed="right" header-align="center" align="center" width="200">
<template #default="{ row }">
<span class="rr-actions">
<el-link v-if="row.sourceType === 1" type="primary" :underline="false" @click="onViewAuth(row)">查看授权</el-link>
<span v-if="row.sourceType === 1" class="rr-actions__sep">|</span>
<el-link v-if="row.status === 1" type="warning" :underline="false" @click="onAudit(row)">审核</el-link>
<span v-if="row.status === 1" class="rr-actions__sep">|</span>
<el-link type="danger" :underline="false" @click="onDelete(row)">删除</el-link>
</span>
</template>
</el-table-column>
</el-table>
<div class="rr-pagination"> <div class="rr-pagination">
<el-pagination <el-pagination :current-page="page" :page-sizes="[10, 20, 50, 100]" :page-size="limit" :total="total" layout="total, sizes, prev, pager, next, jumper" @size-change="onLimitChange" @current-change="onPageChange" />
:current-page="page"
:page-sizes="[10, 20, 50, 100]"
:page-size="limit"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="onLimitChange"
@current-change="onPageChange"
/>
</div> </div>
</div> </div>
@ -258,31 +161,16 @@ onMounted(load)
<el-button type="primary" @click="onSubmitAudit">确定</el-button> <el-button type="primary" @click="onSubmitAudit">确定</el-button>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="authVisible" :title="`「${authBrandName}」授权企业`" width="640px">
<el-table v-loading="authLoading" :data="authList" border max-height="400">
<el-table-column prop="enterpriseName" label="被授权企业" header-align="center" align="center" min-width="140" show-overflow-tooltip />
<el-table-column prop="sourceEnterpriseName" label="品牌来源" header-align="center" align="center" min-width="140" show-overflow-tooltip />
<el-table-column prop="status" label="审核状态" header-align="center" align="center" width="100">
<template #default="{ row }">
<el-tag :type="STATUS_MAP[row.status]?.type || 'info'" size="small">{{ STATUS_MAP[row.status]?.label || row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createDate" label="申请时间" header-align="center" align="center" width="170" />
</el-table>
<template #footer>
<el-button type="primary" @click="authVisible = false">关闭</el-button>
</template>
</el-dialog>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1500px; } .rr-page { max-width: 1500px; }
.rr-card { background: var(--el-bg-color); border-radius: 8px; border: 1px solid var(--el-border-color-light); overflow: hidden; } .rr-page-header { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
.rr-pagination { padding: 12px 20px; display: flex; justify-content: flex-end; border-top: 1px solid var(--el-border-color-light); } .rr-page-title { font-size: 22px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
.brand-auth { width: 50px; height: 50px; border-radius: 4px; border: 1px solid var(--el-border-color-light); cursor: pointer; } .rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
.rr-muted { color: var(--el-text-color-placeholder); } .rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 16px; }
.rr-actions { display: inline-flex; align-items: center; gap: 0; white-space: nowrap; } .rr-search { padding: 16px 20px; }
.rr-actions__sep { color: var(--el-border-color); margin: 0 6px; } .rr-pagination { padding: 12px 20px; display: flex; justify-content: flex-end; border-top: 1px solid var(--border); }
.brand-auth { width: 50px; height: 50px; border-radius: 4px; border: 1px solid var(--border); cursor: pointer; }
</style> </style>

View File

@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 常量配置 - W10 A1 标准化 * F006 常量配置 - 完整版 (W6 整改)
*
* W10 A1: 套 PageHeader/SearchBar 标准化模板
* *
* 沿用老 vue other/textconfig.vue 85 行: * 沿用老 vue other/textconfig.vue 85 行:
* - 搜索: 描述 * - 搜索: 描述
@ -16,8 +14,6 @@
import { ref, onMounted, reactive } from 'vue' import { ref, onMounted, reactive } from 'vue'
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 SearchBar from '@/components/SearchBar/SearchBar.vue'
interface TextConfig { interface TextConfig {
id: string id: string
@ -101,18 +97,27 @@ onMounted(load)
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="常量配置" :sub="`系统常量/协议/横幅配置 (共 ${total} 条)`"> <div class="rr-page-header">
<el-button type="primary" @click="onAdd">新增常量</el-button> <div>
</PageHeader> <h2 class="rr-page-title">常量配置</h2>
<p class="rr-page-sub">系统常量/协议/横幅配置 (共 {{ total }} 条)</p>
</div>
<div class="rr-page-actions">
<el-button type="primary" @click="onAdd">新增常量</el-button>
</div>
</div>
<SearchBar <div class="rr-card rr-search">
v-model:filters="filters" <el-form :inline="true" @keyup.enter="onSearch">
:fields="[ <el-form-item label="描述">
{ key: 'descr', label: '描述', type: 'text', placeholder: '请输入描述' } <el-input v-model="filters.descr" placeholder="请输入描述" clearable style="width: 240px" />
]" </el-form-item>
@search="onSearch" <el-form-item>
@reset="onReset" <el-button type="primary" @click="onSearch">查询</el-button>
/> <el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="rr-card"> <div class="rr-card">
<DataTable <DataTable
@ -129,7 +134,7 @@ onMounted(load)
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="total > 10" :page-sizes="[10, 20, 50, 100]" :show-pagination="total > 10"
:error="error" :error="error"
empty-text="暂无常量" empty-text="暂无常量"
borderless borderless
@ -191,6 +196,11 @@ onMounted(load)
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1400px; } .rr-page { max-width: 1400px; }
.rr-page-header { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
.rr-page-title { font-size: 22px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
.rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
.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-content-text { color: var(--ink); font-size: 13px; } .rr-content-text { color: var(--ink); font-size: 13px; }
.rr-richtext { line-height: 1.6; color: var(--ink); } .rr-richtext { line-height: 1.6; color: var(--ink); }
</style> </style>

View File

@ -83,8 +83,7 @@ async function checkApi() {
return return
} }
const res = await fetch(apiUrl.value, { const res = await fetch(apiUrl.value, {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` }
credentials: 'omit'
}) })
// W4.19 修复:nginx 没代理的业务模块会返 404 HTML 页, 不是 JSON // W4.19 修复:nginx 没代理的业务模块会返 404 HTML 页, 不是 JSON
// 检测 content-type 防止误判 // 检测 content-type 防止误判

View File

@ -0,0 +1,86 @@
<script setup lang="ts">
/**
* F006 商品店铺 - W3.x C P2
* 后端 /product/shop/page 端点状态: 404 / 400 (端点不存在或参数错)
* 走 el-result 优雅占位 + 展示参考老 /vue 后台的预期字段
*/
import { onMounted, ref } from 'vue'
import { httpGet } from '@/utils/request'
interface FieldSpec { key: string; label: string; desc: string }
const status = ref<'checking' | '404' | '200' | 'error'>('checking')
const errorMsg = ref('')
const expectedFields: FieldSpec[] = [
{ key: 'icon', label: '封面', desc: '图片缩略图' },
{ key: 'title', label: '商品名称', desc: '商品标题' },
{ key: 'enterpriseName', label: '供应商', desc: '所属企业名' },
{ key: 'firstCategoryName', label: '一级分类', desc: '一级分类名' },
{ key: 'secondCategoryName', label: '二级分类', desc: '二级分类名' },
{ key: 'thirdCategoryName', label: '三级分类', desc: '三级分类名' },
{ key: 'brandName', label: '品牌', desc: '品牌名' },
{ key: 'unitName', label: '单位', desc: '计量单位' },
{ key: 'createDate', label: '申请时间', desc: '提交时间' },
{ key: 'onShelf', label: '铺货', desc: '0=未铺货/1=已铺货' },
{ key: 'checkOpinion', label: '审核意见', desc: '审核备注' },
{ key: 'checkStatus', label: '状态', desc: '1=待审核/2=出售中/3=已驳回' }
]
onMounted(async () => {
try {
const r: any = await httpGet('/product/shop/page', { page: 1, limit: 1 })
if (r?.code === 0) status.value = '200'
else { status.value = 'error'; errorMsg.value = r?.msg || '未知错误' }
} catch (e: any) {
if (e?.response?.status === 404 || e?.message?.includes('404')) {
status.value = '404'
errorMsg.value = '后端 API 未实现 (/product/shop/page 返回 404)'
} else {
status.value = 'error'
errorMsg.value = e?.msg || e?.message || '请求失败'
}
}
})
</script>
<template>
<div class="rr-page">
<div class="rr-page-header">
<h2 class="rr-page-title">商品店铺</h2>
<p class="rr-page-sub">商品管理 (supplier/good 同款) - 数据接口未实现, 占位页展示预期字段</p>
</div>
<div class="rr-card rr-status-card">
<el-result
:icon="status === '200' ? 'success' : 'warning'"
:title="status === '200' ? 'API 已就绪' : '后端 API 未实现'"
:sub-title="status === '200' ? '商品店铺 数据接口已通, 可正常加载' : (errorMsg || '请联系后端实现 /product/shop/page 接口')"
>
<template #extra>
<el-tag v-if="status === 'checking'" type="info">检测中...</el-tag>
<el-tag v-else-if="status === '404'" type="warning">404 Not Found</el-tag>
<el-tag v-else-if="status === '200'" type="success">200 OK</el-tag>
<el-tag v-else type="danger">Error</el-tag>
</template>
</el-result>
</div>
<div class="rr-card rr-fields-card">
<h3 class="rr-section-title">预期字段 (参考老 /vue 后台)</h3>
<el-table :data="expectedFields" border size="small" :show-header="true" empty-text="无字段">
<el-table-column prop="key" label="字段名 (key)" width="220" />
<el-table-column prop="label" label="表头 (label)" width="180" />
<el-table-column prop="desc" label="说明" />
</el-table>
</div>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 1400px; }
.rr-page-header { margin-bottom: 20px; }
.rr-page-title { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; color: var(--ink); margin-bottom: 4px; }
.rr-page-sub { color: var(--ink-3); font-size: 13px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 24px; margin-bottom: 16px; }
.rr-status-card { padding: 20px 20px 8px; }
.rr-section-title { margin: 0 0 12px; font-size: 14px; font-weight: 600; color: var(--ink); }
</style>

View File

@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 营销活动管理 - W10 A7 标准化 * F006 营销活动管理
*
* W10 A7: 套 PageHeader/SearchBar 标准化模板
* *
* 后端端点 (ActivityZoneController @ /supplier/activityzone): * 后端端点 (ActivityZoneController @ /supplier/activityzone):
* GET /supplier/activityzone/page 分页列表 * GET /supplier/activityzone/page 分页列表
@ -11,8 +9,6 @@
import { onMounted, ref, reactive } from 'vue' import { onMounted, ref, reactive } from 'vue'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface Activity { interface Activity {
id: string id: string
@ -102,20 +98,24 @@ onMounted(() => { load() })
<template> <template>
<div class="rr-sys-user"> <div class="rr-sys-user">
<PageHeader title="营销活动" :sub="`共 ${total} 个活动`" /> <div class="rr-page-header">
<div class="rr-page-titles">
<h2 class="rr-page-title">营销活动</h2>
<p class="rr-page-sub">共 {{ total }} 个活动</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
</div>
</div>
<SearchBar <div class="rr-filters-row">
v-model:filters="filters" <el-input v-model="filters.activityName" placeholder="活动名搜索" clearable class="rr-username-input" size="small" @keyup.enter="onSearch" />
:fields="[ <el-select v-model="filters.status" placeholder="状态" class="rr-type-select" clearable size="small">
{ key: 'activityName', label: '活动名', type: 'text', placeholder: '活动名搜索' }, <el-option value="1" label="开启" />
{ key: 'status', label: '状态', type: 'select', options: [ <el-option value="0" label="关闭" />
{ value: 1, label: '开启' }, </el-select>
{ value: 0, label: '关闭' } <el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
] } </div>
]"
@search="onSearch"
@reset="onReset"
/>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -128,12 +128,12 @@ onMounted(() => { load() })
{ prop: 'endTime', label: '结束时间', width: 170, slot: 'endTime' }, { prop: 'endTime', label: '结束时间', width: 170, slot: 'endTime' },
{ label: '操作', width: 100, slot: 'action' } { label: '操作', width: 100, slot: 'action' }
]" ]"
:data="list" :borderless="true" :data="list"
:loading="loading" :loading="loading"
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :page-sizes="[10, 20, 50, 100]" :show-pagination="true"
:error="loadError" :error="loadError"
empty-text="暂无活动" empty-text="暂无活动"
row-key="id" row-key="id"
@ -184,7 +184,15 @@ onMounted(() => { load() })
<style scoped> <style scoped>
.rr-sys-user { padding: 16px; } .rr-sys-user { padding: 16px; }
.rr-list-card { background: var(--surface); border-radius: 12px; padding: 4px; } .rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); } .rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-muted { color: var(--ink-3); font-size: 12px; } .rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
.rr-page-actions { display: flex; gap: 8px; }
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
.rr-username-input { width: 240px; }
.rr-type-select { width: 140px; }
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
.rr-muted { color: var(--el-text-color-placeholder); font-size: 12px; }
</style> </style>

View File

@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 优惠券管理 - W10 A4 标准化 * F006 优惠券管理 - W7 P0 整改 (沿用老 vue supplier/coupon.vue 116 行)
*
* W10 A4: 套 PageHeader/SearchBar 标准化模板 (沿用老 vue supplier/coupon.vue 116 行)
* *
* 端点 /supplier/coupon/page 200 OK 真数据, 老字段: * 端点 /supplier/coupon/page 200 OK 真数据, 老字段:
* - 搜索: 优惠券名 * - 搜索: 优惠券名
@ -13,9 +11,6 @@
import { ref, onMounted, reactive } from 'vue' import { ref, onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
interface Coupon { interface Coupon {
id: string id: string
@ -124,22 +119,31 @@ onMounted(load)
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="优惠券管理" :sub="`平台/企业优惠券管理 (共 ${total} 张, 选中 ${selection.length})`"> <div class="rr-page-header">
<el-button type="primary" @click="onAdd">新增优惠券</el-button> <div>
<el-button type="danger" :disabled="selection.length === 0" @click="onBatchDelete">批量删除</el-button> <h2 class="rr-page-title">优惠券管理</h2>
</PageHeader> <p class="rr-page-sub">平台/企业优惠券管理 (共 {{ total }} 张, 选中 {{ selection.length }})</p>
</div>
<div class="rr-page-actions">
<el-button type="primary" @click="onAdd">新增优惠券</el-button>
<el-button type="danger" :disabled="selection.length === 0" @click="onBatchDelete">批量删除</el-button>
</div>
</div>
<SearchBar <div class="rr-card rr-search">
v-model:filters="filters" <el-form :inline="true" @keyup.enter="onSearch">
:fields="[ <el-form-item label="优惠券名称">
{ key: 'name', label: '优惠券名称', type: 'text', placeholder: '请输入优惠券名称' } <el-input v-model="filters.name" placeholder="请输入优惠券名称" clearable style="width: 280px" />
]" </el-form-item>
@search="onSearch" <el-form-item>
@reset="onReset" <el-button @click="onReset">重置</el-button>
/> <el-button type="primary" @click="onSearch">查询</el-button>
</el-form-item>
</el-form>
</div>
<div class="rr-card"> <div class="rr-card">
<el-table :data="list" border style="width: 100%" v-loading="loading" :empty-text="error || '暂无优惠券'" @selection-change="onSelectionChange" row-key="id" :header-cell-style="{ textAlign: 'center' }"> <el-table :data="list" border style="width: 100%" v-loading="loading" :empty-text="error || '暂无优惠券'" @selection-change="onSelectionChange" row-key="id">
<el-table-column type="selection" width="50" align="center" /> <el-table-column type="selection" width="50" align="center" />
<el-table-column type="index" label="#" width="50" align="center" /> <el-table-column type="index" label="#" width="50" align="center" />
<el-table-column prop="name" label="优惠券名称" min-width="180" show-overflow-tooltip /> <el-table-column prop="name" label="优惠券名称" min-width="180" show-overflow-tooltip />
@ -180,17 +184,13 @@ onMounted(load)
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="createTime" label="创建时间" width="180" /> <el-table-column prop="createTime" label="创建时间" width="180" />
<el-table-column label="操作" width="140" align="center" fixed="right"> <el-table-column label="操作" width="280" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<TableActions <el-button v-if="row.sourceType === 2" text type="primary" size="small" @click="onEdit(row)">编辑</el-button>
:row="row" <el-button v-if="row.onShelf === 1" text type="warning" size="small" @click="onToggleStatus(row)">停用</el-button>
:actions="[ <el-button v-else text type="success" size="small" @click="onToggleStatus(row)">启用</el-button>
{ label: '编辑', type: 'primary', when: (r: any) => r.sourceType === 2, onClick: onEdit }, <el-button text type="danger" size="small" @click="onDelete(row)">删除</el-button>
{ label: (r: any) => r.onShelf === 1 ? '停用' : '启用', type: (r: any) => r.onShelf === 1 ? 'warning' : 'success', onClick: onToggleStatus }, <el-button v-if="row.sourceType === 2" text type="info" size="small" @click="onShowRecords(row)">领取记录</el-button>
{ label: '领取记录', type: 'primary', when: (r: any) => r.sourceType === 2, onClick: onShowRecords },
{ label: '删除', type: 'danger', divided: true, onClick: onDelete }
]"
/>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -219,5 +219,15 @@ onMounted(load)
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1400px; } .rr-page { max-width: 1500px; }
.rr-page-header { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
.rr-page-title { font-size: 22px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
.rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
.rr-page-actions { display: flex; gap: 8px; }
.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-pagination { padding: 12px 20px; display: flex; justify-content: flex-end; border-top: 1px solid var(--border); }
.rr-muted { color: var(--ink-3); }
.rr-stock-ok { color: var(--primary); font-weight: 600; }
.rr-stock-out { color: var(--ink-3); }
</style> </style>

View File

@ -1,118 +0,0 @@
<script setup lang="ts">
/**
* F006 优惠券适用范围 - W16
*
* 沿用老 vue supplier/couponscope.vue 字段
*
* 端点: 4 URL 探测 (couponscope/page, /list, /search, /queryPage); 404 → FALLBACK seed
*/
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.vue'
interface CouponScopeRow { id:string; couponId:string; supplierGoodId:string; sourceType:number; createTime:string }
const ENDPOINTS = [
'/supplier/couponscope/page',
'/supplier/couponscope/list',
'/supplier/couponscope/search',
'/supplier/couponscope/queryPage'
]
const FALLBACK: CouponScopeRow[] = [
{ id:'CS001', couponId:'C202501', supplierGoodId:'SG101', sourceType:1, createTime:'2026-04-15 09:30:00' },
{ id:'CS002', couponId:'C202502', supplierGoodId:'SG102', sourceType:1, createTime:'2026-04-22 14:22:00' },
{ id:'CS003', couponId:'C202503', supplierGoodId:'', sourceType:2, createTime:'2026-05-08 11:08:30' },
{ id:'CS004', couponId:'C202504', supplierGoodId:'SG104', sourceType:1, createTime:'2026-05-15 16:48:00' },
{ id:'CS005', couponId:'C202505', supplierGoodId:'', sourceType:2, createTime:'2026-05-22 09:15:00' },
{ id:'CS006', couponId:'C202506', supplierGoodId:'SG106', sourceType:1, createTime:'2026-06-03 10:42:00' },
{ id:'CS007', couponId:'C202507', supplierGoodId:'SG107', sourceType:1, createTime:'2026-06-12 13:18:00' },
{ id:'CS008', couponId:'C202508', supplierGoodId:'', sourceType:2, createTime:'2026-06-20 11:35:00' },
{ id:'CS009', couponId:'C202509', supplierGoodId:'SG109', sourceType:1, createTime:'2026-06-28 14:55:00' },
{ id:'CS010', couponId:'C202510', supplierGoodId:'SG110', sourceType:1, createTime:'2026-07-04 09:50:00' },
{ id:'CS011', couponId:'C202511', supplierGoodId:'', sourceType:2, createTime:'2026-07-08 16:08:00' },
{ id:'CS012', couponId:'C202512', supplierGoodId:'SG112', sourceType:1, createTime:'2026-07-12 11:42:00' }
]
type Col = { type?: 'status'|'text'|'selection'; prop?: string; label: string; width?: number; slot?: string; fixed?: 'right'|true }
const columns: Col[] = [
{ type:'selection', label:'', width:50 },
{ prop:'id', label:'范围ID', width:100, fixed:true },
{ prop:'couponId', label:'优惠券ID', width:130, slot:'couponId' },
{ prop:'supplierGoodId', label:'适用商品ID', width:140, slot:'goodId' },
{ prop:'sourceType', label:'卷来源', width:100, slot:'src' },
{ prop:'createTime', label:'创建时间', width:160, slot:'time' },
{ prop:'actions', label:'操作', width:200, slot:'actions', fixed:'right' }
]
const STATUS_DEFAULTS = [
{ value:'', label:'全部' },
{ value:'1', label:'供应商来源' },
{ value:'2', label:'平台来源' }
]
const list = ref<CouponScopeRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const filterKw = ref(''); const statusTab = ref('')
const dataSource = ref<'api'|'mock'|'loading'>('loading'); const apiError = ref<string>('')
async function load() {
loading.value = true; dataSource.value = 'loading'
for (const ep of ENDPOINTS) {
try {
const data: any = await httpGet<any>(ep, { page: page.value, limit: limit.value })
const rows = (data?.list || []).map((it: any) => ({ ...it, raw: it }))
list.value = rows; total.value = Number(data?.total || rows.length); dataSource.value = 'api'; apiError.value = ''; loading.value = false; return
} catch {}
}
list.value = [...FALLBACK]; total.value = FALLBACK.length; dataSource.value = 'mock'; apiError.value = '后端 API 暂未实装, 已加载前端示例数据'; loading.value = false
}
const filtered = computed(() => {
const kw = filterKw.value.trim().toLowerCase(); let rs = list.value
if (statusTab.value) rs = rs.filter(r => String(r.sourceType) === statusTab.value)
if (kw) rs = rs.filter(r => String(r.id).includes(kw) || String(r.couponId).includes(kw) || String(r.supplierGoodId).includes(kw))
return rs
})
const searchFields: Array<{ key: string; label: string; type: 'text' }> = [{ key:'id', label:'范围ID', type:'text' },{ key:'couponId', label:'优惠券ID', type:'text' },{ key:'supplierGoodId', label:'商品ID', type:'text' }]
const filters = reactive<Record<string, any>>({})
function onSearch() { filterKw.value = String(filters.id || filters.couponId || filters.supplierGoodId || ''); page.value = 1 }
function onStatusTab(v: string) { statusTab.value = v; page.value = 1 }
async function onDelete(row: CouponScopeRow) {
try { await ElMessageBox.confirm(`确认删除适用范围「${row.id}」?`, '提示', { type: 'warning' }) } catch { return }
ElMessage.success('已删除 (前端示例)')
}
function onView(row: CouponScopeRow) { ElMessage.info(`详情 ${row.id}`) }
function onExport() { ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)') }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="优惠券适用范围" sub="优惠券可使用商品范围配置" :data-source="dataSource" :api-error="apiError">
<el-button type="primary" @click="onExport">导出明细</el-button>
</PageHeader>
<el-tabs v-model="statusTab" @tab-change="onStatusTab" class="rr-tabs">
<el-tab-pane v-for="t in STATUS_DEFAULTS" :key="t.value" :name="t.value" :label="t.label" />
</el-tabs>
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" />
<DataTable :columns="columns" :data="filtered"
:borderless="true" :loading="loading" :page="page" :limit="limit" :total="total" row-key="id"
@page-change="(p: number) => { page = p; load() }" @limit-change="(l: number) => { limit = l; page = 1; load() }">
<template #couponId="{ row }">
<el-link v-if="row.couponId" type="primary" :underline="false" @click="onView(row)">{{ row.couponId }}</el-link>
<span v-else class="rr-muted">通用券</span>
</template>
<template #goodId="{ row }">
<span v-if="row.supplierGoodId">{{ row.supplierGoodId }}</span>
<span v-else class="rr-muted">全场通用</span>
</template>
<template #src="{ row }">
<el-tag :type="row.sourceType === 1 ? 'primary' : 'success'" size="small">{{ row.sourceType === 1 ? '供应商' : '平台' }}</el-tag>
</template>
<template #time="{ row }">{{ row.createTime }}</template>
<template #actions="{ row }">
<el-button type="primary" link @click="onView(row)">详情</el-button>
<el-button type="danger" link @click="onDelete(row)">删除</el-button>
</template>
</DataTable>
</div>
</template>
<style scoped>
.rr-page { padding: 16px; }
.rr-tabs { margin-bottom: 12px; }
</style>

View File

@ -6,7 +6,7 @@
* - 搜索: 商品名/供应商/分类/品牌 * - 搜索: 商品名/供应商/分类/品牌
* - 状态 tab: 全部/待审核/已通过/已驳回/已下架 * - 状态 tab: 全部/待审核/已通过/已驳回/已下架
* - 表格列: 封面/商品/供应商/3级分类/品牌/单位/申请时间/铺货/审核意见/状态 * - 表格列: 封面/商品/供应商/3级分类/品牌/单位/申请时间/铺货/审核意见/状态
* - 操作: 详情(常驻) + 审核/上下架/改分类/删除(收纳⋯) [W17.6 主次分离] * - 操作: 详情/审核/下架/修改分类/删除
* - 批量: 批量审核/批量驳回/批量下架/批量改分类/批量删除 * - 批量: 批量审核/批量驳回/批量下架/批量改分类/批量删除
* - 详情弹窗: 含 SKU 列表 (编码/组合/封面/原价/现价/秒杀价/拼团价/库存/最小购买) * - 详情弹窗: 含 SKU 列表 (编码/组合/封面/原价/现价/秒杀价/拼团价/库存/最小购买)
* - 审核弹窗: 通过/驳回 + 审核意见 * - 审核弹窗: 通过/驳回 + 审核意见
@ -19,11 +19,7 @@
import { ref, onMounted, computed, reactive } from 'vue' import { ref, onMounted, computed, reactive } from 'vue'
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 { httpGet } from '@/utils/request'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
import ListTabs from '@/components/ListTabs/ListTabs.vue'
import { httpGet, httpPost } from '@/utils/request'
interface Good { interface Good {
id: string id: string
@ -109,12 +105,6 @@ const limit = ref(10)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const selection = ref<Good[]>([]) const selection = ref<Good[]>([])
// 状态统计 (与 tab 解耦, 只跟搜索条件关联 - 跨 tab 一致)
const checkStats = reactive({ pending: 0, approved: 0, rejected: 0 })
// W17.6: 商品总数 + SKU 总数 (实物商品下, 全量, 不分 checkStatus)
const counts = reactive({ goodCount: 0, skuCount: 0 })
const filters = reactive({ const filters = reactive({
title: '', title: '',
enterpriseName: '', enterpriseName: '',
@ -135,161 +125,74 @@ const batchRejectDialogVisible = ref(false)
const batchRejectForm = reactive({ reason: '' }) const batchRejectForm = reactive({ reason: '' })
const columns = [ const columns = [
{ type: 'selection' as const, label: '', width: 50 }, { type: 'selection' as const, width: 50 },
{ prop: 'supplierGoodId', label: '商品ID', width: 100 }, { prop: 'supplierGoodId', label: '商品ID', width: 140 },
{ prop: 'title', label: '商品名称', minWidth: 320, slot: 'title' }, { prop: 'title', label: '商品名称', minWidth: 240, slot: 'title' },
{ prop: 'enterpriseName', label: '供应商', minWidth: 180, showOverflowTooltip: true }, { prop: 'enterpriseName', label: '供应商', minWidth: 180, showOverflowTooltip: true },
{ prop: 'categoryName', label: '分类', minWidth: 180, slot: 'category' }, { prop: 'categoryName', label: '分类', minWidth: 240, slot: 'category' },
{ prop: 'brandName', label: '品牌', width: 110 }, { prop: 'brandName', label: '品牌', width: 120 },
{ prop: 'unitName', label: '单位', width: 90, minWidth: 90, align: 'center' as const }, { prop: 'unitName', label: '单位', width: 80, align: 'center' as const },
{ prop: 'onShelf', label: '铺货', width: 100, align: 'center' as const, slot: 'onShelf' }, { prop: 'onShelf', label: '铺货', width: 100, align: 'center' as const, slot: 'onShelf' },
{ prop: 'checkOpinion', label: '审核意见', minWidth: 160, showOverflowTooltip: true },
{ prop: 'checkStatus', label: '状态', width: 100, align: 'center' as const, slot: 'checkStatus' }, { prop: 'checkStatus', label: '状态', width: 100, align: 'center' as const, slot: 'checkStatus' },
{ prop: 'createDate', label: '申请时间', width: 170 }, { prop: 'createDate', label: '申请时间', width: 170 },
{ label: '操作', width: 140, align: 'center' as const, fixed: 'right' as const, slot: 'action' } { label: '操作', width: 240, align: 'center' as const, fixed: 'right' as const, slot: 'action' }
] ]
async function buildSearchParamsForStats() {
const params: any = { type: 1 }
if (filters.title) params.title = filters.title
return params
}
async function loadStats() {
try {
const base = await buildSearchParamsForStats()
const [r1, r2, r3] = await Promise.all([
httpGet('/supplier/good/pageList', { ...base, checkStatus: 1, page: 1, limit: 1 }),
httpGet('/supplier/good/pageList', { ...base, checkStatus: 2, page: 1, limit: 1 }),
httpGet('/supplier/good/pageList', { ...base, checkStatus: 3, page: 1, limit: 1 })
])
checkStats.pending = Number(r1?.total ?? 0)
checkStats.approved = Number(r2?.total ?? 0)
checkStats.rejected = Number(r3?.total ?? 0)
} catch {
checkStats.pending = 0
checkStats.approved = 0
checkStats.rejected = 0
}
}
// W17.6: 全量商品数 + 全量 SKU 数 (实物, type=1, 不分 checkStatus / 搜索)
async function loadCounts() {
try {
const r: any = await httpGet('/supplier/good/sku-count', { type: 1 })
counts.goodCount = Number(r?.goodCount ?? 0)
counts.skuCount = Number(r?.skuCount ?? 0)
} catch {
counts.goodCount = 0
counts.skuCount = 0
}
}
async function load() { async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
// W13.1: /supplier/good/pageList 返完整字段 (16133 条) — 比 search 端点更全 // 端点: /supplier/good/page 后端 404, 用 FALLBACK
const data = await httpGet<{ total: number | string; list: any[] }>('/supplier/good/pageList', { await new Promise(r => setTimeout(r, 200))
page: page.value, let rows = [...FALLBACK]
limit: limit.value, if (filters.title) rows = rows.filter(r => r.title.toLowerCase().includes(filters.title.toLowerCase()))
type: 1, // 1=实物商品 (2=服务商品, 在 ServiceGoodListView 单独展示)
...(filters.title ? { title: filters.title } : {})
})
let rows = (data.list || []).map((it: any) => ({
id: String(it.id),
supplierGoodId: it.supplierGoodId || String(it.id),
icon: it.icon || null,
title: it.title || '--',
enterpriseName: it.enterpriseName || '--',
firstCategoryName: it.firstCategoryName || '',
secondCategoryName: it.secondCategoryName || '',
thirdCategoryName: it.thirdCategoryName || '',
brandName: it.brandName || '--',
unitName: it.unitName || '--',
onShelf: it.onShelf ?? 1,
checkOpinion: it.checkOpinion || '',
checkStatus: it.checkStatus ?? 2,
createDate: it.createDate || '',
raw: it
}))
if (filters.enterpriseName) rows = rows.filter(r => r.enterpriseName.includes(filters.enterpriseName)) if (filters.enterpriseName) rows = rows.filter(r => r.enterpriseName.includes(filters.enterpriseName))
if (filters.checkStatus) rows = rows.filter(r => String(r.checkStatus) === filters.checkStatus) if (filters.checkStatus) rows = rows.filter(r => String(r.checkStatus) === filters.checkStatus)
list.value = rows list.value = rows
total.value = Number(data.total) || 0 total.value = rows.length
} catch (e: any) { } catch (e: any) {
error.value = e?.msg || e?.message || '加载失败' error.value = e?.msg || e?.message || '加载失败'
list.value = [] list.value = FALLBACK
total.value = 0 total.value = FALLBACK.length
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function onSearch() { page.value = 1; Promise.all([load(), loadStats(), loadCounts()]) } function onSearch() { page.value = 1; load() }
function onReset() { filters.title = ''; filters.enterpriseName = ''; filters.checkStatus = ''; page.value = 1; Promise.all([load(), loadStats(), loadCounts()]) } function onReset() { filters.title = ''; filters.enterpriseName = ''; filters.checkStatus = ''; page.value = 1; load() }
function onPageChange(p: number) { page.value = p; load() } function onPageChange(p: number) { page.value = p; load() }
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() } function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
function onSelectionChange(rows: Good[]) { selection.value = rows } function onSelectionChange(rows: Good[]) { selection.value = rows }
const tabItems = computed(() => [
// 全部 = 待审核 + 已通过 + 已驳回 (跨 tab 一致, 跟搜索条件关联)
{ key: '', label: '全部', count: checkStats.pending + checkStats.approved + checkStats.rejected },
{ key: '1', label: '待审核', count: checkStats.pending },
{ key: '2', label: '已通过', count: checkStats.approved },
{ key: '3', label: '已驳回', count: checkStats.rejected }
])
function onStatusTab(v: string) { filters.checkStatus = v; page.value = 1; load() } function onStatusTab(v: string) { filters.checkStatus = v; page.value = 1; load() }
function onShowDetail(row: Good) { detailData.value = row; detailVisible.value = true } function onShowDetail(row: Good) { detailData.value = row; detailVisible.value = true }
function onShowCheckDialog(row: Good) { checkData.value = row; checkForm.checkStatus = 2; checkForm.checkOpinion = ''; checkDialogVisible.value = true } function onShowCheckDialog(row: Good) { checkData.value = row; checkForm.checkStatus = 2; checkForm.checkOpinion = ''; checkDialogVisible.value = true }
async function onSubmitCheck() { function onSubmitCheck() {
if (!checkForm.checkOpinion.trim()) { ElMessage.warning('请填写审核意见'); return } if (!checkForm.checkOpinion.trim()) { ElMessage.warning('请填写审核意见'); return }
// W14.3: 真实调 /supplier/good/checkGood?checkStatus=&checkOpinion= ElMessage.success(checkForm.checkStatus === 2 ? '审核通过 (mock)' : '已驳回 (mock)')
try { checkDialogVisible.value = false
const id = checkData.value?.id || checkData.value?.raw?.id load()
const r: any = await httpGet('/supplier/good/checkGood', {
id,
checkStatus: checkForm.checkStatus,
checkOpinion: checkForm.checkOpinion || ''
})
if (r?.code === 0) {
ElMessage.success(checkForm.checkStatus === 2 ? '审核通过' : '已驳回')
checkDialogVisible.value = false
load()
} else {
ElMessage.error(r?.msg || '操作失败')
}
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
} }
async function onOnShelf(row: Good) { function onOnShelf(row: Good) {
try { await ElMessageBox.confirm(`确认${row.onShelf === 1 ? '下架' : '上架'}「${row.title}」?`, '提示', { type: 'warning' }) } catch { return } ElMessageBox.confirm(`确认${row.onShelf === 1 ? '下架' : '上架'}「${row.title}」?`, '提示', { type: 'warning' }).then(() => {
try { ElMessage.success('操作成功 (mock)')
const r: any = await httpGet('/supplier/good/onShelfGood', { id: row.id }) load()
if (r?.code === 0) { ElMessage.success(row.onShelf === 1 ? '已下架' : '已上架'); load() } }).catch(() => {})
else ElMessage.error(r?.msg || '操作失败')
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
} }
function onShowCategoryDialog(row: Good) { categoryData.value = row; categoryDialogVisible.value = true } function onShowCategoryDialog(row: Good) { categoryData.value = row; categoryDialogVisible.value = true }
async function onSubmitCategory() { function onSubmitCategory() {
try { ElMessage.success('分类已修改 (mock)')
const r: any = await httpPost('/supplier/good/update', { categoryDialogVisible.value = false
id: categoryData.value?.id || categoryData.value?.raw?.id, load()
firstCategoryId: categoryForm.firstCategoryId || undefined,
secondCategoryId: categoryForm.secondCategoryId || undefined,
thirdCategoryId: categoryForm.thirdCategoryId || undefined
})
if (r?.code === 0) { ElMessage.success('分类已修改'); categoryDialogVisible.value = false; load() }
else ElMessage.error(r?.msg || '操作失败')
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
} }
async function onDelete(row: Good) { function onDelete(row: Good) {
try { await ElMessageBox.confirm(`确认删除「${row.title}」?`, '提示', { type: 'warning' }) } catch { return } ElMessageBox.confirm(`确认删除「${row.title}」?`, '提示', { type: 'warning' }).then(() => {
try { ElMessage.success('已删除 (mock)')
const r: any = await httpGet('/supplier/good/delete', { id: row.id }) load()
if (r?.code === 0) { ElMessage.success('已删除'); load() } }).catch(() => {})
else ElMessage.error(r?.msg || '操作失败')
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '操作失败') }
} }
function onBatchCheck() { function onBatchCheck() {
@ -332,27 +235,50 @@ function onDeleteBatch() {
function onExport() { ElMessage.success('导出商品 (mock)') } function onExport() { ElMessage.success('导出商品 (mock)') }
function onAdd() { ElMessage.info('新增商品 (mock)') } function onAdd() { ElMessage.info('新增商品 (mock)') }
onMounted(() => Promise.all([load(), loadStats(), loadCounts()])) onMounted(load)
</script> </script>
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="商品管理" :sub="`共 ${counts.goodCount} 个商品,${counts.skuCount} 个SKU`"> <div class="rr-page-header">
<el-button @click="onExport">导出</el-button> <div>
<el-button type="primary" @click="onAdd">新增商品</el-button> <h2 class="rr-page-title">商品管理</h2>
</PageHeader> <p class="rr-page-sub">共 {{ total }} 个商品</p>
</div>
<div class="rr-page-actions">
<el-button @click="onExport">导出</el-button>
<el-button type="primary" @click="onAdd">新增商品</el-button>
</div>
</div>
<ListTabs v-model="filters.checkStatus" :items="tabItems" @change="onStatusTab" /> <!-- 搜索区 -->
<div class="rr-card rr-search">
<el-form :inline="true" @keyup.enter="onSearch">
<el-form-item label="商品名称">
<el-input v-model="filters.title" placeholder="请输入商品名称" clearable style="width: 200px" />
</el-form-item>
<el-form-item label="供应商">
<el-input v-model="filters.enterpriseName" placeholder="供应商名称" clearable style="width: 200px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">查询</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<SearchBar <!-- 状态 Tab -->
v-model:filters="filters" <div class="rr-tabs-row">
:fields="[ <div class="rr-tabs">
{ key: 'title', label: '商品名称', type: 'text', placeholder: '请输入商品名称' }, <div
{ key: 'enterpriseName', label: '供应商', type: 'text', placeholder: '供应商名称' } v-for="t in STATUS_TABS"
]" :key="t.value"
@search="onSearch" class="rr-tab"
@reset="onReset" :class="{ active: filters.checkStatus === t.value }"
/> @click="onStatusTab(t.value)"
>{{ t.label }}</div>
</div>
</div>
<!-- 批量操作 --> <!-- 批量操作 -->
<div class="rr-batch-bar" v-if="selection.length > 0"> <div class="rr-batch-bar" v-if="selection.length > 0">
@ -364,62 +290,8 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
<el-button type="danger" size="small" plain @click="onDeleteBatch">批量删除</el-button> <el-button type="danger" size="small" plain @click="onDeleteBatch">批量删除</el-button>
</div> </div>
<!-- 表格 (用 DataTable 套裸 el-table, 复用分页 + :borderless + :page-sizes + el-result 错误展示) --> <!-- 表格 -->
<DataTable <div class="rr-card">
:columns="columns"
:data="list"
:loading="loading"
:total="total"
:page="page"
:limit="limit"
:page-sizes="[10, 20, 50, 100]"
:show-pagination="true"
:error="error"
empty-text="暂无商品"
:borderless="true"
row-key="id"
@page-change="onPageChange"
@limit-change="onLimitChange"
@selection-change="onSelectionChange"
>
<template #title="{ row }">
<div class="rr-good-cell">
<div v-if="row.icon" class="rr-good-icon">
<el-image :src="row.icon" :preview-src-list="[row.icon]" fit="cover" style="width: 40px; height: 40px; border-radius: 4px" />
</div>
<div v-else class="rr-good-icon-placeholder">{{ (row.title || '?').charAt(0) }}</div>
<span class="rr-good-title">{{ row.title }}</span>
</div>
</template>
<template #category="{ row }">
<span>{{ [row.firstCategoryName, row.secondCategoryName, row.thirdCategoryName].filter(Boolean).join(' / ') || '--' }}</span>
</template>
<template #onShelf="{ row }">
<el-tag :type="(ONSHELF_STATUS_MAP[row.onShelf]?.type as any)" size="small" effect="light">
{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}
</el-tag>
</template>
<template #checkStatus="{ row }">
<el-tag :type="(CHECK_STATUS_MAP[row.checkStatus]?.type as any)" size="small" effect="light">
{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}
</el-tag>
</template>
<template #action="{ row }">
<TableActions
:row="row"
:actions="[
{ label: '详情', type: 'primary', onClick: onShowDetail },
{ label: '审核', type: 'primary', when: (r: any) => r.checkStatus === 1, onClick: onShowCheckDialog },
{ label: (r: any) => r.onShelf === 1 ? '下架' : '上架', type: (r: any) => r.onShelf === 1 ? 'warning' : 'success', onClick: onOnShelf },
{ label: '改分类', type: 'primary', onClick: onShowCategoryDialog },
{ label: '删除', type: 'danger', divided: true, onClick: onDelete }
]"
/>
</template>
</DataTable>
<!-- 原 el-table 区域 (暂时保留作为参考, 实际由 DataTable 接管) -->
<div v-if="false" class="rr-card">
<el-table <el-table
:data="list" :data="list"
border border
@ -477,6 +349,17 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<div class="rr-pagination">
<el-pagination
:current-page="page"
:page-sizes="[10, 20, 50, 100]"
:page-size="limit"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="onLimitChange"
@current-change="onPageChange"
/>
</div>
</div> </div>
<!-- 详情弹窗 (含 SKU 列表, 沿用老 vue 弹窗布局) --> <!-- 详情弹窗 (含 SKU 列表, 沿用老 vue 弹窗布局) -->
@ -601,9 +484,21 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1500px; } .rr-page { max-width: 1500px; }
.rr-page-header { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
.rr-page-title { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; color: var(--ink); margin: 0 0 4px 0; }
.rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
.rr-page-actions { display: flex; gap: 8px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 16px; } .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 :deep(.el-form-item) { margin-bottom: 0; margin-right: 12px; }
.rr-tabs-row { margin-bottom: 16px; display: flex; align-items: center; }
.rr-tabs { display: flex; gap: 4px; padding: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); }
.rr-tab { padding: 6px 16px; border-radius: var(--radius); font-size: 13px; color: var(--ink-2); cursor: pointer; transition: all 0.15s; }
.rr-tab:hover { color: var(--ink); background: var(--bg-soft); }
.rr-tab.active { color: white; background: var(--primary); font-weight: 500; }
.rr-batch-bar { display: flex; align-items: center; gap: 8px; padding: 10px 16px; background: var(--bg-soft); border-radius: var(--radius); margin-bottom: 12px; } .rr-batch-bar { display: flex; align-items: center; gap: 8px; padding: 10px 16px; background: var(--bg-soft); border-radius: var(--radius); margin-bottom: 12px; }
.rr-batch-tip { font-size: 13px; color: var(--ink-2); margin-right: 8px; } .rr-batch-tip { font-size: 13px; color: var(--ink-2); margin-right: 8px; }
.rr-pagination { padding: 12px 20px; display: flex; justify-content: flex-end; border-top: 1px solid var(--border); }
.rr-good-cell { display: flex; align-items: center; gap: 10px; } .rr-good-cell { display: flex; align-items: center; gap: 10px; }
.rr-good-icon-placeholder { width: 40px; height: 40px; border-radius: 4px; background: var(--bg-soft); display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-weight: 600; flex-shrink: 0; } .rr-good-icon-placeholder { width: 40px; height: 40px; border-radius: 4px; background: var(--bg-soft); display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-weight: 600; flex-shrink: 0; }
.rr-good-title { font-size: 13.5px; color: var(--ink); } .rr-good-title { font-size: 13.5px; color: var(--ink); }

View File

@ -1,116 +0,0 @@
<script setup lang="ts">
/**
* F006 浏览足迹 - W16
*
* 沿用老 vue supplier/historylog.vue 字段
*
* 端点: 4 URL 探测 (historylog/page, /list, /search, /queryPage); 404 → FALLBACK seed
*/
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.vue'
interface HistoryRow { id:string; userId:string; userType:number; goodType:number; supplierGoodId:string; createTime:string }
const ENDPOINTS = [
'/supplier/historylog/page',
'/supplier/historylog/list',
'/supplier/historylog/search',
'/supplier/historylog/queryPage'
]
const FALLBACK: HistoryRow[] = [
{ id:'H001', userId:'u10086', userType:1, goodType:1, supplierGoodId:'SG1001', createTime:'2026-07-04 14:22:11' },
{ id:'H002', userId:'u10112', userType:1, goodType:2, supplierGoodId:'SG1002', createTime:'2026-07-05 09:45:30' },
{ id:'H003', userId:'u10133', userType:2, goodType:1, supplierGoodId:'SG1003', createTime:'2026-07-05 11:18:42' },
{ id:'H004', userId:'u10144', userType:1, goodType:1, supplierGoodId:'SG1004', createTime:'2026-07-05 16:02:55' },
{ id:'H005', userId:'u10155', userType:2, goodType:2, supplierGoodId:'SG1005', createTime:'2026-07-06 08:30:18' },
{ id:'H006', userId:'u10166', userType:1, goodType:1, supplierGoodId:'SG1006', createTime:'2026-07-06 10:55:08' },
{ id:'H007', userId:'u10177', userType:1, goodType:2, supplierGoodId:'SG1007', createTime:'2026-07-06 13:42:35' },
{ id:'H008', userId:'u10188', userType:2, goodType:1, supplierGoodId:'SG1008', createTime:'2026-07-06 15:18:42' },
{ id:'H009', userId:'u10199', userType:1, goodType:1, supplierGoodId:'SG1009', createTime:'2026-07-06 17:33:21' },
{ id:'H010', userId:'u10210', userType:1, goodType:2, supplierGoodId:'SG1010', createTime:'2026-07-06 19:08:55' },
{ id:'H011', userId:'u10221', userType:2, goodType:1, supplierGoodId:'SG1011', createTime:'2026-07-07 08:22:11' },
{ id:'H012', userId:'u10232', userType:1, goodType:1, supplierGoodId:'SG1012', createTime:'2026-07-07 09:48:30' },
{ id:'H013', userId:'u10243', userType:1, goodType:2, supplierGoodId:'SG1013', createTime:'2026-07-07 11:25:48' },
{ id:'H014', userId:'u10254', userType:2, goodType:1, supplierGoodId:'SG1014', createTime:'2026-07-07 13:42:11' }
]
type Col = { type?: 'status'|'text'|'selection'; prop?: string; label: string; width?: number; slot?: string; fixed?: 'right'|true }
const columns: Col[] = [
{ type:'selection', label:'', width:50 },
{ prop:'id', label:'ID', width:90, fixed:true },
{ prop:'userId', label:'用户ID', width:110 },
{ prop:'userType', label:'用户类型', width:100, slot:'userType' },
{ prop:'goodType', label:'商品类型', width:100, slot:'goodType' },
{ prop:'supplierGoodId', label:'商品ID', width:130 },
{ prop:'createTime', label:'浏览时间', width:180, slot:'time' },
{ prop:'actions', label:'操作', width:150, slot:'actions', fixed:'right' }
]
const STATUS_DEFAULTS = [
{ value:'', label:'全部' },
{ value:'1', label:'C端用户' },
{ value:'2', label:'供应商' }
]
const list = ref<HistoryRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const filterKw = ref(''); const statusTab = ref('')
const dataSource = ref<'api'|'mock'|'loading'>('loading'); const apiError = ref<string>('')
async function load() {
loading.value = true; dataSource.value = 'loading'
for (const ep of ENDPOINTS) {
try {
const data: any = await httpGet<any>(ep, { page: page.value, limit: limit.value })
const rows = (data?.list || []).map((it: any) => ({ ...it, raw: it }))
list.value = rows; total.value = Number(data?.total || rows.length); dataSource.value = 'api'; apiError.value = ''; loading.value = false; return
} catch {}
}
list.value = [...FALLBACK]; total.value = FALLBACK.length; dataSource.value = 'mock'; apiError.value = '后端 API 暂未实装, 已加载前端示例数据'; loading.value = false
}
const filtered = computed(() => {
const kw = filterKw.value.trim().toLowerCase(); let rs = list.value
if (statusTab.value) rs = rs.filter(r => String(r.userType) === statusTab.value)
if (kw) rs = rs.filter(r => String(r.id).includes(kw) || String(r.userId).includes(kw) || String(r.supplierGoodId).includes(kw))
return rs
})
const searchFields: Array<{ key: string; label: string; type: 'text' }> = [{ key:'id', label:'ID', type:'text' },{ key:'userId', label:'用户ID', type:'text' },{ key:'supplierGoodId', label:'商品ID', type:'text' }]
const filters = reactive<Record<string, any>>({})
function onSearch() { filterKw.value = String(filters.id || filters.userId || filters.supplierGoodId || ''); page.value = 1 }
function onStatusTab(v: string) { statusTab.value = v; page.value = 1 }
async function onDelete(row: HistoryRow) {
try { await ElMessageBox.confirm(`确认删除「${row.id}」?`, '提示', { type: 'warning' }) } catch { return }
ElMessage.success('已删除 (前端示例)')
}
function onView(row: HistoryRow) { ElMessage.info(`详情 ${row.id}`) }
function onExport() { ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)') }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="浏览足迹" sub="用户浏览商品行为记录" :data-source="dataSource" :api-error="apiError">
<el-button type="primary" @click="onExport">导出明细</el-button>
</PageHeader>
<el-tabs v-model="statusTab" @tab-change="onStatusTab" class="rr-tabs">
<el-tab-pane v-for="t in STATUS_DEFAULTS" :key="t.value" :name="t.value" :label="t.label" />
</el-tabs>
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" />
<DataTable :columns="columns" :data="filtered"
:borderless="true" :loading="loading" :page="page" :limit="limit" :total="total" row-key="id"
@page-change="(p: number) => { page = p; load() }" @limit-change="(l: number) => { limit = l; page = 1; load() }">
<template #userType="{ row }">
<el-tag :type="row.userType === 1 ? 'primary' : 'success'" size="small">{{ row.userType === 1 ? 'C端用户' : '供应商' }}</el-tag>
</template>
<template #goodType="{ row }">
<el-tag :type="row.goodType === 1 ? 'warning' : 'info'" size="small">{{ row.goodType === 1 ? '实物' : '服务' }}</el-tag>
</template>
<template #time="{ row }">{{ row.createTime }}</template>
<template #actions="{ row }">
<el-button type="primary" link @click="onView(row)">详情</el-button>
<el-button type="danger" link @click="onDelete(row)">删除</el-button>
</template>
</DataTable>
</div>
</template>
<style scoped>
.rr-page { padding: 16px; }
.rr-tabs { margin-bottom: 12px; }
</style>

View File

@ -1,117 +0,0 @@
<script setup lang="ts">
/**
* F006 物流方案管理 - W16
*
* 沿用老 vue supplier/logistics.vue 字段:
* - 选择 + id + enterpriseId + isDefault + logisticsName + chargeType + isOpen + createDate
*
* 端点: /supplier/logistics/page 探测; 404 → FALLBACK seed (12 条)
*
* isDefault 0否1是 / chargeType 1=按件数 2=整单 / isOpen 0否1是
*/
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.vue'
interface LogisticsRow {
id: string; enterpriseId: string; isDefault: number; logisticsName: string; chargeType: number; isOpen: number; createDate: string
}
const DEFAULT_L: Record<number, string> = { 0: '否', 1: '是' }
const CHARGE_L: Record<number, string> = { 1: '按件数', 2: '整单' }
const ENDPOINTS = ['/supplier/logistics/page', '/supplier/logistics/list', '/supplier/logistics/search', '/supplier/logistics/queryPage']
const FALLBACK: LogisticsRow[] = [
{ id:'L001', enterpriseId:'s20015', isDefault:1, logisticsName:'标准配送 (同城48h)', chargeType:1, isOpen:1, createDate:'2026-04-12 09:30:00' },
{ id:'L002', enterpriseId:'s20016', isDefault:1, logisticsName:'经济配送 (跨省3-5天)', chargeType:2, isOpen:1, createDate:'2026-04-15 14:22:00' },
{ id:'L003', enterpriseId:'s20017', isDefault:0, logisticsName:'加急配送 (同城当日达)', chargeType:1, isOpen:1, createDate:'2026-04-20 16:48:00' },
{ id:'L004', enterpriseId:'s20018', isDefault:1, logisticsName:'大宗货运专线', chargeType:2, isOpen:1, createDate:'2026-05-02 10:15:00' },
{ id:'L005', enterpriseId:'s20019', isDefault:0, logisticsName:'仓配一体化 (本地仓发货)', chargeType:1, isOpen:0, createDate:'2026-05-08 11:35:00' },
{ id:'L006', enterpriseId:'s20020', isDefault:1, logisticsName:'冷链配送 (8-15°C)', chargeType:2, isOpen:1, createDate:'2026-05-14 08:55:00' },
{ id:'L007', enterpriseId:'s20021', isDefault:0, logisticsName:'大件专线 (超长件)', chargeType:2, isOpen:1, createDate:'2026-05-20 13:18:00' },
{ id:'L008', enterpriseId:'s20022', isDefault:1, logisticsName:'厂家直发 (48-72h)', chargeType:1, isOpen:1, createDate:'2026-06-02 09:42:00' },
{ id:'L009', enterpriseId:'s20023', isDefault:0, logisticsName:'节假日专送', chargeType:1, isOpen:0, createDate:'2026-06-10 16:08:00' },
{ id:'L010', enterpriseId:'s20024', isDefault:1, logisticsName:'海外集运', chargeType:2, isOpen:1, createDate:'2026-06-18 11:25:00' },
{ id:'L011', enterpriseId:'s20025', isDefault:0, logisticsName:'安装配送一体', chargeType:1, isOpen:1, createDate:'2026-06-28 14:38:00' },
{ id:'L012', enterpriseId:'s20026', isDefault:0, logisticsName:'试用物流 (10km内免费)', chargeType:1, isOpen:0, createDate:'2026-07-01 09:50:00' }
]
type Col = { type?: 'status' | 'text' | 'selection'; prop?: string; label: string; width?: number; slot?: string; fixed?: 'right' | true }
const columns: Col[] = [
{ type: 'selection', label: '', width: 50 },
{ prop: 'id', label: '方案ID', width: 100, fixed: true },
{ prop: 'enterpriseId', label: '所属企业ID', width: 130 },
{ type: 'status', prop: 'isDefault', label: '是否默认', width: 90 },
{ prop: 'logisticsName', label: '方案名称', width: 220 },
{ type: 'status', prop: 'chargeType', label: '计费方式', width: 110 },
{ type: 'status', prop: 'isOpen', label: '是否启用', width: 90 },
{ prop: 'createDate', label: '创建时间', width: 160, slot: 'time' },
{ prop: 'actions', label: '操作', width: 200, slot: 'actions', fixed: 'right' }
]
const STATUS_DEFAULTS = [
{ value: '', label: '全部' },
{ value: '1', label: '默认' },
{ value: '0', label: '非默认' }
]
const list = ref<LogisticsRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const filterKw = ref(''); const statusTab = ref('')
const dataSource = ref<'api' | 'mock' | 'loading'>('loading'); const apiError = ref<string>('')
async function load() {
loading.value = true; dataSource.value = 'loading'
for (const ep of ENDPOINTS) {
try {
const data: any = await httpGet<any>(ep, { page: page.value, limit: limit.value })
const rows = (data?.list || []).map((it: any) => ({ ...it, raw: it }))
list.value = rows; total.value = Number(data?.total || rows.length); dataSource.value = 'api'; apiError.value = ''; loading.value = false; return
} catch {}
}
list.value = [...FALLBACK]; total.value = FALLBACK.length; dataSource.value = 'mock'; apiError.value = '后端 API 暂未实装, 已加载前端示例数据'; loading.value = false
}
const filtered = computed(() => {
const kw = filterKw.value.trim().toLowerCase(); let rs = list.value
if (statusTab.value) rs = rs.filter(r => String(r.isDefault) === statusTab.value)
if (kw) rs = rs.filter(r => String(r.id).toLowerCase().includes(kw) || String(r.logisticsName).toLowerCase().includes(kw) || String(r.enterpriseId).toLowerCase().includes(kw))
return rs
})
const searchFields: Array<{ key: string; label: string; type: 'text' }> = [
{ key: 'id', label: '方案ID', type: 'text' },
{ key: 'logisticsName', label: '方案名称', type: 'text' },
{ key: 'enterpriseId', label: '所属企业ID', type: 'text' }
]
const filters = reactive<Record<string, any>>({})
function onSearch() {
filterKw.value = String(filters.id || filters.logisticsName || filters.enterpriseId || ''); page.value = 1
}
function onStatusTab(v: string) { statusTab.value = v; page.value = 1 }
async function onDelete(row: LogisticsRow) {
try { await ElMessageBox.confirm(`确认删除「${row.logisticsName}」?`, '提示', { type: 'warning' }) } catch { return }
ElMessage.success('已删除 (前端示例)')
}
function onView(row: LogisticsRow) { ElMessage.info(`打开详情 ${row.id}`) }
function onExport() { ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)') }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="物流方案管理" sub="供应商物流配送方案配置" :data-source="dataSource" :api-error="apiError">
<el-button type="primary" @click="onExport">导出明细</el-button>
</PageHeader>
<el-tabs v-model="statusTab" @tab-change="onStatusTab" class="rr-tabs">
<el-tab-pane v-for="t in STATUS_DEFAULTS" :key="t.value" :name="t.value" :label="t.label" />
</el-tabs>
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" />
<DataTable :columns="columns" :data="filtered"
:borderless="true" :loading="loading" :page="page" :limit="limit" :total="total" row-key="id"
@page-change="(p: number) => { page = p; load() }" @limit-change="(l: number) => { limit = l; page = 1; load() }">
<template #time="{ row }">{{ row.createDate }}</template>
<template #actions="{ row }">
<el-button type="primary" link @click="onView(row)">查看</el-button>
<el-button type="danger" link @click="onDelete(row)">删除</el-button>
</template>
</DataTable>
</div>
</template>
<style scoped>
.rr-page { padding: 16px; }
.rr-tabs { margin-bottom: 12px; }
</style>

View File

@ -1,128 +0,0 @@
<script setup lang="ts">
/**
* F006 订单评价管理 - W16
*
* 沿用老 vue supplier/ordercomment.vue 字段
*
* 端点: 4 URL 探测 (ordercomment/page, /list, /search, /queryPage); 404 → FALLBACK seed
*/
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
interface CommentRow { id:string; userId:string; parentId:string; goodType:number; skuDetailId:string; orderGoodId:string; supplierId:string; supplierGoodId:string; star:number; content:string; createTime:string }
const ENDPOINTS = [
'/supplier/ordercomment/page',
'/supplier/ordercomment/list',
'/supplier/ordercomment/search',
'/supplier/ordercomment/queryPage'
]
const FALLBACK: CommentRow[] = [
{ id:'OC001', userId:'u10086', parentId:'', goodType:1, skuDetailId:'SKU101', orderGoodId:'OG201', supplierId:'s20015', supplierGoodId:'SG101', star:5, content:'商品质量很好, 配送也快, 满意!', createTime:'2026-07-01 14:32:11' },
{ id:'OC002', userId:'u10112', parentId:'', goodType:1, skuDetailId:'SKU102', orderGoodId:'OG202', supplierId:'s20016', supplierGoodId:'SG102', star:4, content:'包装一般, 商品本身没问题', createTime:'2026-07-02 09:18:42' },
{ id:'OC003', userId:'u10133', parentId:'OC001', goodType:1, skuDetailId:'SKU103', orderGoodId:'OG203', supplierId:'s20017', supplierGoodId:'SG103', star:5, content:'感谢您的好评!', createTime:'2026-07-02 16:45:00' },
{ id:'OC004', userId:'u10144', parentId:'', goodType:2, skuDetailId:'SKU104', orderGoodId:'OG204', supplierId:'s20018', supplierGoodId:'SG104', star:5, content:'安装师傅态度很好', createTime:'2026-07-03 11:02:18' },
{ id:'OC005', userId:'u10155', parentId:'', goodType:1, skuDetailId:'SKU105', orderGoodId:'OG205', supplierId:'s20019', supplierGoodId:'SG105', star:4, content:'规格与描述相符', createTime:'2026-07-03 15:25:30' },
{ id:'OC006', userId:'u10166', parentId:'OC002', goodType:1, skuDetailId:'SKU106', orderGoodId:'OG206', supplierId:'s20016', supplierGoodId:'SG102', star:5, content:'已改进包装, 感谢建议', createTime:'2026-07-04 09:12:55' },
{ id:'OC007', userId:'u10177', parentId:'', goodType:1, skuDetailId:'SKU107', orderGoodId:'OG207', supplierId:'s20020', supplierGoodId:'SG107', star:4, content:'整体满意', createTime:'2026-07-04 16:48:22' },
{ id:'OC008', userId:'u10188', parentId:'', goodType:2, skuDetailId:'SKU108', orderGoodId:'OG208', supplierId:'s20021', supplierGoodId:'SG108', star:5, content:'量房很专业', createTime:'2026-07-05 08:30:18' },
{ id:'OC009', userId:'u10199', parentId:'', goodType:1, skuDetailId:'SKU109', orderGoodId:'OG209', supplierId:'s20022', supplierGoodId:'SG109', star:3, content:'颜色有偏差, 退换顺利', createTime:'2026-07-05 13:25:48' },
{ id:'OC010', userId:'u10210', parentId:'', goodType:1, skuDetailId:'SKU110', orderGoodId:'OG210', supplierId:'s20023', supplierGoodId:'SG110', star:5, content:'送货快', createTime:'2026-07-05 17:33:21' },
{ id:'OC011', userId:'u10221', parentId:'OC009', goodType:1, skuDetailId:'SKU111', orderGoodId:'OG211', supplierId:'s20022', supplierGoodId:'SG109', star:5, content:'补发正确颜色', createTime:'2026-07-06 09:42:18' },
{ id:'OC012', userId:'u10232', parentId:'', goodType:1, skuDetailId:'SKU112', orderGoodId:'OG212', supplierId:'s20024', supplierGoodId:'SG112', star:4, content:'批量优惠可以, 物流稍慢', createTime:'2026-07-06 14:22:11' },
{ id:'OC013', userId:'u10243', parentId:'', goodType:2, skuDetailId:'SKU113', orderGoodId:'OG213', supplierId:'s20025', supplierGoodId:'SG113', star:5, content:'自助下单良好', createTime:'2026-07-06 19:08:55' },
{ id:'OC014', userId:'u10254', parentId:'', goodType:1, skuDetailId:'SKU114', orderGoodId:'OG214', supplierId:'s20026', supplierGoodId:'SG114', star:5, content:'商品不错, 配送好', createTime:'2026-07-07 09:25:48' },
{ id:'OC015', userId:'u10265', parentId:'', goodType:1, skuDetailId:'SKU115', orderGoodId:'OG215', supplierId:'s20015', supplierGoodId:'SG101', star:3, content:'便宜但慢', createTime:'2026-07-07 13:42:11' }
]
type Col = { type?: 'status'|'text'|'selection'; prop?: string; label: string; width?: number; slot?: string; fixed?: 'right'|true }
const columns: Col[] = [
{ type:'selection', label:'', width:50 },
{ prop:'id', label:'评价ID', width:100, fixed:true },
{ prop:'orderGoodId', label:'小订单ID', width:130 },
{ prop:'supplierGoodId', label:'商品ID', width:130 },
{ prop:'userId', label:'评价人', width:110 },
{ prop:'star', label:'评分', width:80, slot:'star' },
{ prop:'content', label:'内容', width:280, slot:'content' },
{ prop:'createTime', label:'时间', width:160, slot:'time' },
{ prop:'actions', label:'操作', width:140, slot:'actions', fixed:'right' }
]
const STATUS_DEFAULTS = [
{ value:'', label:'全部' },
{ value:'5', label:'5星' },
{ value:'4', label:'4星' },
{ value:'3', label:'3星' },
{ value:'1', label:'≤2星' }
]
const list = ref<CommentRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const filterKw = ref(''); const statusTab = ref('')
const dataSource = ref<'api'|'mock'|'loading'>('loading'); const apiError = ref<string>('')
async function load() {
loading.value = true; dataSource.value = 'loading'
for (const ep of ENDPOINTS) {
try {
const data: any = await httpGet<any>(ep, { page: page.value, limit: limit.value })
const rows = (data?.list || []).map((it: any) => ({ ...it, raw: it }))
list.value = rows; total.value = Number(data?.total || rows.length); dataSource.value = 'api'; apiError.value = ''; loading.value = false; return
} catch {}
}
list.value = [...FALLBACK]; total.value = FALLBACK.length; dataSource.value = 'mock'; apiError.value = '后端 API 暂未实装, 已加载前端示例数据'; loading.value = false
}
const filtered = computed(() => {
const kw = filterKw.value.trim().toLowerCase(); let rs = list.value
if (statusTab.value) { if (statusTab.value === '1') rs = rs.filter(r => r.star < 3); else rs = rs.filter(r => String(r.star) === statusTab.value) }
if (kw) rs = rs.filter(r => String(r.id).includes(kw) || String(r.content).includes(kw) || String(r.userId).includes(kw))
return rs
})
const searchFields: Array<{ key: string; label: string; type: 'text' }> = [{ key:'id', label:'评价ID', type:'text' },{ key:'userId', label:'评价人ID', type:'text' },{ key:'content', label:'内容关键词', type:'text' }]
const filters = reactive<Record<string, any>>({})
function onSearch() { filterKw.value = String(filters.id || filters.userId || filters.content || ''); page.value = 1 }
function onStatusTab(v: string) { statusTab.value = v; page.value = 1 }
async function onDelete(row: CommentRow) {
try { await ElMessageBox.confirm(`确认删除评价「${row.id}」?`, '提示', { type: 'warning' }) } catch { return }
ElMessage.success('已删除 (前端示例)')
}
function onReply(row: CommentRow) { ElMessage.info(`回复 ${row.id}`) }
function onView(row: CommentRow) { ElMessage.info(`详情 ${row.id}`) }
function onExport() { ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)') }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="订单评价管理" sub="订单商品的用户评价管理" :data-source="dataSource" :api-error="apiError">
<el-button type="primary" @click="onExport">导出明细</el-button>
</PageHeader>
<el-tabs v-model="statusTab" @tab-change="onStatusTab" class="rr-tabs">
<el-tab-pane v-for="t in STATUS_DEFAULTS" :key="t.value" :name="t.value" :label="t.label" />
</el-tabs>
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" />
<DataTable :columns="columns" :data="filtered"
:borderless="true" :loading="loading" :page="page" :limit="limit" :total="total" row-key="id"
@page-change="(p: number) => { page = p; load() }" @limit-change="(l: number) => { limit = l; page = 1; load() }">
<template #star="{ row }">
<el-rate v-model="row.star" :max="5" disabled />
</template>
<template #content="{ row }">
<span :title="row.content">{{ (row.content || '').slice(0, 28) }}{{ (row.content || '').length > 28 ? '...' : '' }}</span>
</template>
<template #time="{ row }">{{ row.createTime }}</template>
<template #actions="{ row }">
<TableActions
:row="row"
:actions="[
{ label: '详情', type: 'primary', onClick: onView },
{ label: '回复', type: 'primary', when: (r: any) => !r.parentId, onClick: onReply },
{ label: '删除', type: 'danger', onClick: onDelete }
]"
/>
</template>
</DataTable>
</div>
</template>
<style scoped>
.rr-page { padding: 16px; }
.rr-tabs { margin-bottom: 12px; }
</style>

View File

@ -1,122 +0,0 @@
<script setup lang="ts">
/**
* F006 推广员管理 - W16
*
* 沿用老 vue supplier/promoter.vue 字段
*
* 端点: 4 URL 探测 (promoter/page, /list, /search, /queryPage); 404 → FALLBACK seed
*/
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
interface PromoterRow { id:string; enterpriseId:string; enterpriseName:string; promoterName:string; promoterRadio:number; promoterGood:string; beginTime:string; endTime:string; createTime:string }
const ENDPOINTS = [
'/supplier/promoter/page',
'/supplier/promoter/list',
'/supplier/promoter/search',
'/supplier/promoter/queryPage'
]
const FALLBACK: PromoterRow[] = [
{ id:'P001', enterpriseId:'s20015', enterpriseName:'坚美建筑材料有限公司', promoterName:'张推广', promoterRadio:0.05, promoterGood:'国标槽钢/角钢系列', beginTime:'2026-04-01', endTime:'2026-12-31', createTime:'2026-04-01 09:30:00' },
{ id:'P002', enterpriseId:'s20016', enterpriseName:'华建门窗厂', promoterName:'李经纪人', promoterRadio:0.08, promoterGood:'断桥铝门窗', beginTime:'2026-04-15', endTime:'2026-10-30', createTime:'2026-04-15 14:22:00' },
{ id:'P003', enterpriseId:'s20017', enterpriseName:'融鑫管业', promoterName:'王工长', promoterRadio:0.06, promoterGood:'PPR给水管', beginTime:'2026-05-01', endTime:'2026-12-31', createTime:'2026-05-01 11:08:30' },
{ id:'P004', enterpriseId:'s20018', enterpriseName:'东方五金机械', promoterName:'赵渠道', promoterRadio:0.10, promoterGood:'电动工具', beginTime:'2026-05-10', endTime:'2026-09-30', createTime:'2026-05-10 16:48:00' },
{ id:'P005', enterpriseId:'s20019', enterpriseName:'美洁涂料', promoterName:'陈渠道', promoterRadio:0.07, promoterGood:'环保涂料', beginTime:'2026-05-20', endTime:'2026-12-31', createTime:'2026-05-20 09:15:00' },
{ id:'P006', enterpriseId:'s20020', enterpriseName:'万家灯具', promoterName:'周照明师', promoterRadio:0.12, promoterGood:'LED灯具', beginTime:'2026-06-01', endTime:'2026-12-31', createTime:'2026-06-01 10:42:00' },
{ id:'P007', enterpriseId:'s20021', enterpriseName:'顺达物流', promoterName:'吴同城', promoterRadio:0.05, promoterGood:'同城配送服务', beginTime:'2026-06-10', endTime:'2026-12-31', createTime:'2026-06-10 13:18:00' },
{ id:'P008', enterpriseId:'s20022', enterpriseName:'华建门窗厂', promoterName:'林工长', promoterRadio:0.08, promoterGood:'铝合金门窗', beginTime:'2026-06-20', endTime:'2026-12-31', createTime:'2026-06-20 11:35:00' },
{ id:'P009', enterpriseId:'s20023', enterpriseName:'众成厨具', promoterName:'黄销售', promoterRadio:0.06, promoterGood:'厨房用品', beginTime:'2026-07-01', endTime:'2026-12-31', createTime:'2026-07-01 09:50:00' },
{ id:'P010', enterpriseId:'s20024', enterpriseName:'东方五金机械', promoterName:'何专业', promoterRadio:0.09, promoterGood:'角磨机', beginTime:'2026-07-05', endTime:'2026-12-31', createTime:'2026-07-05 14:25:00' },
{ id:'P011', enterpriseId:'s20025', enterpriseName:'坚美建筑材料', promoterName:'钱工长', promoterRadio:0.07, promoterGood:'国标钢材', beginTime:'2026-07-08', endTime:'2026-12-31', createTime:'2026-07-08 11:42:00' },
{ id:'P012', enterpriseId:'s20026', enterpriseName:'美洁涂料', promoterName:'孙工长', promoterRadio:0.06, promoterGood:'外墙腻子粉', beginTime:'2026-07-12', endTime:'2026-12-31', createTime:'2026-07-12 16:08:00' }
]
type Col = { type?: 'status'|'text'|'selection'; prop?: string; label: string; width?: number; slot?: string; fixed?: 'right'|true }
const columns: Col[] = [
{ type:'selection', label:'', width:50 },
{ prop:'id', label:'推广员ID', width:100, fixed:true },
{ prop:'enterpriseName', label:'所属供应商', width:200 },
{ prop:'promoterName', label:'推广员姓名', width:120 },
{ prop:'promoterRadio', label:'返佣比例', width:110, slot:'radio' },
{ prop:'promoterGood', label:'推广商品', width:200 },
{ prop:'beginTime', label:'开始时间', width:120, slot:'date1' },
{ prop:'endTime', label:'结束时间', width:120, slot:'date2' },
{ prop:'createTime', label:'创建时间', width:160, slot:'time' },
{ prop:'actions', label:'操作', width:140, slot:'actions', fixed:'right' }
]
const STATUS_DEFAULTS = [
{ value:'', label:'全部' },
{ value:'1', label:'推广中' },
{ value:'0', label:'已结束' }
]
const list = ref<PromoterRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const filterKw = ref(''); const statusTab = ref('')
const dataSource = ref<'api'|'mock'|'loading'>('loading'); const apiError = ref<string>('')
async function load() {
loading.value = true; dataSource.value = 'loading'
for (const ep of ENDPOINTS) {
try {
const data: any = await httpGet<any>(ep, { page: page.value, limit: limit.value })
const rows = (data?.list || []).map((it: any) => ({ ...it, raw: it }))
list.value = rows; total.value = Number(data?.total || rows.length); dataSource.value = 'api'; apiError.value = ''; loading.value = false; return
} catch {}
}
list.value = [...FALLBACK]; total.value = FALLBACK.length; dataSource.value = 'mock'; apiError.value = '后端 API 暂未实装, 已加载前端示例数据'; loading.value = false
}
const filtered = computed(() => {
const kw = filterKw.value.trim().toLowerCase(); let rs = list.value
if (statusTab.value === '1') rs = rs.filter(r => new Date(r.endTime) >= new Date('2026-07-07'))
if (kw) rs = rs.filter(r => String(r.promoterName).includes(kw) || String(r.enterpriseName).includes(kw) || String(r.promoterGood).includes(kw))
return rs
})
const searchFields: Array<{ key: string; label: string; type: 'text' }> = [{ key:'promoterName', label:'推广员姓名', type:'text' },{ key:'enterpriseName', label:'供应商', type:'text' },{ key:'promoterGood', label:'推广商品', type:'text' }]
const filters = reactive<Record<string, any>>({})
function onSearch() { filterKw.value = String(filters.promoterName || filters.enterpriseName || filters.promoterGood || ''); page.value = 1 }
function onStatusTab(v: string) { statusTab.value = v; page.value = 1 }
async function onDelete(row: PromoterRow) {
try { await ElMessageBox.confirm(`确认删除推广员「${row.promoterName}」?`, '提示', { type: 'warning' }) } catch { return }
ElMessage.success('已删除 (前端示例)')
}
function onEdit(row: PromoterRow) { ElMessage.info(`编辑 ${row.id}`) }
function onView(row: PromoterRow) { ElMessage.info(`详情 ${row.id}`) }
function onExport() { ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)') }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="推广员管理" sub="商品推广返佣管理" :data-source="dataSource" :api-error="apiError">
<el-button type="primary" @click="onExport">导出明细</el-button>
</PageHeader>
<el-tabs v-model="statusTab" @tab-change="onStatusTab" class="rr-tabs">
<el-tab-pane v-for="t in STATUS_DEFAULTS" :key="t.value" :name="t.value" :label="t.label" />
</el-tabs>
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" />
<DataTable :columns="columns" :data="filtered"
:borderless="true" :loading="loading" :page="page" :limit="limit" :total="total" row-key="id"
@page-change="(p: number) => { page = p; load() }" @limit-change="(l: number) => { limit = l; page = 1; load() }">
<template #radio="{ row }">{{ (row.promoterRadio * 100).toFixed(1) }}%</template>
<template #date1="{ row }">{{ row.beginTime }}</template>
<template #date2="{ row }">{{ row.endTime }}</template>
<template #time="{ row }">{{ row.createTime }}</template>
<template #actions="{ row }">
<TableActions
:row="row"
:actions="[
{ label: '详情', type: 'primary', onClick: onView },
{ label: '编辑', type: 'primary', onClick: onEdit },
{ label: '删除', type: 'danger', onClick: onDelete }
]"
/>
</template>
</DataTable>
</div>
</template>
<style scoped>
.rr-page { padding: 16px; }
.rr-tabs { margin-bottom: 12px; }
</style>

View File

@ -10,14 +10,9 @@
* - 批量: 审核/驳回/上架/下架/改分类/删除 * - 批量: 审核/驳回/上架/下架/改分类/删除
* - 详情弹窗: 含服务属性 + 服务区域 + 价格日历 * - 详情弹窗: 含服务属性 + 服务区域 + 价格日历
*/ */
import { ref, onMounted, reactive, computed } from 'vue' import { ref, onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import ListTabs from '@/components/ListTabs/ListTabs.vue'
import DataTable from '@/components/DataTable/DataTable.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
interface ServiceGood { interface ServiceGood {
id: string id: string
@ -68,34 +63,6 @@ const selection = ref<ServiceGood[]>([])
const filters = reactive({ title: '', enterpriseName: '', checkStatus: '' }) const filters = reactive({ title: '', enterpriseName: '', checkStatus: '' })
// 状态统计 (与 tab 解耦, 跨 tab 一致)
const checkStats = reactive({ pending: 0, approved: 0, rejected: 0 })
// W17.6: 服务商品总数 + SKU 总数 (服务商品 type=2, 全量, 不分 checkStatus)
const counts = reactive({ goodCount: 0, skuCount: 0 })
const columns = [
{ type: 'selection' as const, label: '', width: 50 },
{ prop: 'serviceGoodId', label: '服务ID', width: 100 },
{ prop: 'title', label: '服务名称', minWidth: 320, slot: 'title' },
{ prop: 'enterpriseName', label: '供应商', minWidth: 180 },
{ prop: 'categoryName', label: '分类', width: 160 },
{ prop: 'serviceArea', label: '服务区域', width: 120, align: 'center' as const },
{ prop: 'price', label: '价格', width: 160, align: 'right' as const, slot: 'price' },
{ prop: 'onShelf', label: '铺货', width: 100, align: 'center' as const, slot: 'onShelf' },
{ prop: 'checkStatus', label: '状态', width: 100, align: 'center' as const, slot: 'checkStatus' },
{ prop: 'createDate', label: '申请时间', width: 170 },
{ label: '操作', width: 140, align: 'center' as const, fixed: 'right' as const, slot: 'action' }
]
const tabItems = computed(() => [
// 全部 = 待审核 + 已通过 + 已驳回
{ key: '', label: '全部', count: checkStats.pending + checkStats.approved + checkStats.rejected },
{ key: '1', label: '待审核', count: checkStats.pending },
{ key: '2', label: '已通过', count: checkStats.approved },
{ key: '3', label: '已驳回', count: checkStats.rejected }
])
const detailVisible = ref(false) const detailVisible = ref(false)
const detailData = ref<ServiceGood | null>(null) const detailData = ref<ServiceGood | null>(null)
const checkDialogVisible = ref(false) const checkDialogVisible = ref(false)
@ -107,83 +74,26 @@ const batchCategoryDialogVisible = ref(false)
const batchRejectDialogVisible = ref(false) const batchRejectDialogVisible = ref(false)
const batchRejectForm = reactive({ reason: '' }) const batchRejectForm = reactive({ reason: '' })
async function buildSearchParamsForStats() {
const params: any = { type: 2 }
if (filters.title) params.title = filters.title
if (filters.enterpriseName) params.enterpriseName = filters.enterpriseName
return params
}
async function loadStats() {
try {
const base = await buildSearchParamsForStats()
const [r1, r2, r3] = await Promise.all([
httpGet('/supplier/good/pageList', { ...base, checkStatus: 1, page: 1, limit: 1 }),
httpGet('/supplier/good/pageList', { ...base, checkStatus: 2, page: 1, limit: 1 }),
httpGet('/supplier/good/pageList', { ...base, checkStatus: 3, page: 1, limit: 1 })
])
checkStats.pending = Number(r1?.total ?? 0)
checkStats.approved = Number(r2?.total ?? 0)
checkStats.rejected = Number(r3?.total ?? 0)
} catch {
checkStats.pending = 0
checkStats.approved = 0
checkStats.rejected = 0
}
}
// W17.6: 全量服务商品数 + 全量 SKU 数 (服务, type=2, 不分 checkStatus / 搜索)
async function loadCounts() {
try {
const r: any = await httpGet('/supplier/good/sku-count', { type: 2 })
counts.goodCount = Number(r?.goodCount ?? 0)
counts.skuCount = Number(r?.skuCount ?? 0)
} catch {
counts.goodCount = 0
counts.skuCount = 0
}
}
async function load() { async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
// W13.2: /supplier/good/pageList?type=2 是服务商品端点 (与实物商品共用 supplier_good 表, type 区分) await new Promise(r => setTimeout(r, 200))
const data = await httpGet<{ total: number | string; list: any[] }>('/supplier/good/pageList', { let rows = [...FALLBACK]
page: page.value,
limit: limit.value,
type: 2
})
let rows = (data.list || []).map((it: any) => ({
id: String(it.id),
serviceGoodId: it.supplierGoodId || String(it.id),
icon: it.icon || null,
title: it.title || '--',
enterpriseName: it.enterpriseName || '--',
categoryName: [it.firstCategoryName, it.secondCategoryName, it.thirdCategoryName].filter(Boolean).join(' / ') || '--',
serviceArea: it.enterpriseAddress || '--',
price: it.remark || '--', // 服务的"价格"实际在 remark 字段 (家政类)
checkStatus: it.checkStatus ?? 2,
onShelf: it.onShelf ?? 1,
createDate: it.createDate || '',
unit: '--',
checkOpinion: it.checkOpinion || '',
raw: it
}))
if (filters.title) rows = rows.filter(r => r.title.includes(filters.title)) if (filters.title) rows = rows.filter(r => r.title.includes(filters.title))
if (filters.enterpriseName) rows = rows.filter(r => r.enterpriseName.includes(filters.enterpriseName)) if (filters.enterpriseName) rows = rows.filter(r => r.enterpriseName.includes(filters.enterpriseName))
if (filters.checkStatus) rows = rows.filter(r => String(r.checkStatus) === filters.checkStatus) if (filters.checkStatus) rows = rows.filter(r => String(r.checkStatus) === filters.checkStatus)
list.value = rows list.value = rows
total.value = Number(data.total) || 0 total.value = rows.length
} catch (e: any) { } catch (e: any) {
error.value = e?.msg || e?.message || '加载失败' error.value = e?.msg || '加载失败'
list.value = [] list.value = FALLBACK
total.value = 0 total.value = FALLBACK.length
} finally { loading.value = false } } finally { loading.value = false }
} }
function onSearch() { page.value = 1; Promise.all([load(), loadStats(), loadCounts()]) } function onSearch() { page.value = 1; load() }
function onReset() { filters.title = ''; filters.enterpriseName = ''; filters.checkStatus = ''; page.value = 1; Promise.all([load(), loadStats(), loadCounts()]) } function onReset() { filters.title = ''; filters.enterpriseName = ''; filters.checkStatus = ''; load() }
function onPageChange(p: number) { page.value = p; load() } function onPageChange(p: number) { page.value = p; load() }
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() } function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
function onSelectionChange(rows: ServiceGood[]) { selection.value = rows } function onSelectionChange(rows: ServiceGood[]) { selection.value = rows }
@ -207,27 +117,42 @@ function onDeleteBatch() { if (!selection.value.length) return ElMessage.warning
function onExport() { ElMessage.success('导出服务商品 (mock)') } function onExport() { ElMessage.success('导出服务商品 (mock)') }
function onAdd() { ElMessage.info('新增服务商品 (mock)') } function onAdd() { ElMessage.info('新增服务商品 (mock)') }
onMounted(() => Promise.all([load(), loadStats(), loadCounts()])) onMounted(load)
</script> </script>
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="服务商品管理" :sub="`共 ${counts.goodCount} 个服务商品,${counts.skuCount} 个SKU`"> <div class="rr-page-header">
<el-button @click="onExport">导出</el-button> <div>
<el-button type="primary" @click="onAdd">新增服务</el-button> <h2 class="rr-page-title">服务商品管理</h2>
</PageHeader> <p class="rr-page-sub">共 {{ total }} 个服务商品</p>
</div>
<div class="rr-page-actions">
<el-button @click="onExport">导出</el-button>
<el-button type="primary" @click="onAdd">新增服务</el-button>
</div>
</div>
<SearchBar <div class="rr-card rr-search">
v-model:filters="filters" <el-form :inline="true" @keyup.enter="onSearch">
:fields='[ <el-form-item label="服务名称">
{ key: "title", label: "服务名称", type: "text", placeholder: "服务名称", clearable: true }, <el-input v-model="filters.title" placeholder="服务名称" clearable style="width: 200px" />
{ key: "enterpriseName", label: "供应商", type: "text", placeholder: "供应商", clearable: true } </el-form-item>
]' <el-form-item label="供应商">
@search="onSearch" <el-input v-model="filters.enterpriseName" placeholder="供应商" clearable style="width: 200px" />
@reset="load" </el-form-item>
/> <el-form-item>
<el-button type="primary" @click="onSearch">查询</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<ListTabs v-model="filters.checkStatus" :items="tabItems" @change="onStatusTab" /> <div class="rr-tabs-row">
<div class="rr-tabs">
<div v-for="t in STATUS_TABS" :key="t.value" class="rr-tab" :class="{ active: filters.checkStatus === t.value }" @click="onStatusTab(t.value)">{{ t.label }}</div>
</div>
</div>
<div class="rr-batch-bar" v-if="selection.length > 0"> <div class="rr-batch-bar" v-if="selection.length > 0">
<span class="rr-batch-tip">已选 {{ selection.length }} 项</span> <span class="rr-batch-tip">已选 {{ selection.length }} 项</span>
@ -238,52 +163,51 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
<el-button type="danger" size="small" plain @click="onDeleteBatch">批量删除</el-button> <el-button type="danger" size="small" plain @click="onDeleteBatch">批量删除</el-button>
</div> </div>
<DataTable <div class="rr-card">
:columns="columns" <el-table :data="list" border style="width: 100%" v-loading="loading" :empty-text="error || '暂无数据'" @selection-change="onSelectionChange">
:data="list" <el-table-column type="selection" width="50" align="center" />
:loading="loading" <el-table-column prop="serviceGoodId" label="服务ID" width="140" align="center" />
:total="total" <el-table-column label="服务名称" min-width="240">
:page="page" <template #default="{ row }">
:limit="limit" <div class="rr-good-cell">
:page-sizes="[10, 20, 50, 100]" <div v-if="row.icon" class="rr-good-icon"><el-image :src="row.icon" :preview-src-list="[row.icon]" fit="cover" style="width: 40px; height: 40px; border-radius: 4px" /></div>
:show-pagination="true" <div v-else class="rr-good-icon-placeholder">{{ (row.title || '?').charAt(0) }}</div>
:error="error" <span>{{ row.title }}</span>
empty-text="暂无数据" </div>
:borderless="true" </template>
row-key="id" </el-table-column>
@page-change="onPageChange" <el-table-column prop="enterpriseName" label="供应商" min-width="180" show-overflow-tooltip />
@limit-change="onLimitChange" <el-table-column prop="categoryName" label="分类" width="160" show-overflow-tooltip />
@selection-change="onSelectionChange" <el-table-column prop="serviceArea" label="服务区域" width="120" align="center" />
> <el-table-column label="价格" width="140" align="right">
<template #title="{ row }"> <template #default="{ row }">¥ {{ row.price.toLocaleString() }} / {{ row.unit }}</template>
<div class="rr-good-cell"> </el-table-column>
<div v-if="row.icon" class="rr-good-icon"><el-image :src="row.icon" :preview-src-list="[row.icon]" fit="cover" style="width: 40px; height: 40px; border-radius: 4px" /></div> <el-table-column label="铺货" width="100" align="center">
<div v-else class="rr-good-icon-placeholder">{{ (row.title || '?').charAt(0) }}</div> <template #default="{ row }">
<span>{{ row.title }}</span> <el-tag :type="(ONSHELF_STATUS_MAP[row.onShelf]?.type as any)" size="small" effect="light">{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}</el-tag>
</div> </template>
</template> </el-table-column>
<template #price="{ row }"> <el-table-column prop="checkOpinion" label="审核意见" min-width="160" show-overflow-tooltip />
<span class="rr-price">¥ {{ Number(row.price || 0).toLocaleString() }} / {{ row.unit }}</span> <el-table-column label="状态" width="100" align="center">
</template> <template #default="{ row }">
<template #onShelf="{ row }"> <el-tag :type="(CHECK_STATUS_MAP[row.checkStatus]?.type as any)" size="small" effect="light">{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}</el-tag>
<el-tag :type="(ONSHELF_STATUS_MAP[row.onShelf]?.type as any)" size="small" effect="light">{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}</el-tag> </template>
</template> </el-table-column>
<template #checkStatus="{ row }"> <el-table-column label="申请时间" prop="createDate" width="170" />
<el-tag :type="(CHECK_STATUS_MAP[row.checkStatus]?.type as any)" size="small" effect="light">{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}</el-tag> <el-table-column label="操作" width="280" align="center" fixed="right">
</template> <template #default="{ row }">
<template #action="{ row }"> <el-button text type="primary" size="small" @click="onShowDetail(row)">详情</el-button>
<TableActions <el-button v-if="row.checkStatus === 1" text type="primary" size="small" @click="onShowCheck(row)">审核</el-button>
:row="row" <el-button text :type="row.onShelf === 1 ? 'warning' : 'success'" size="small" @click="onOnShelf(row)">{{ row.onShelf === 1 ? '下架' : '上架' }}</el-button>
:actions="[ <el-button text type="primary" size="small" @click="onShowCategory(row)">改分类</el-button>
{ label: '详情', type: 'primary', onClick: onShowDetail }, <el-button text type="danger" size="small" @click="onDelete(row)">删除</el-button>
{ label: '审核', type: 'primary', when: (r: any) => r.checkStatus === 1, onClick: onShowCheck }, </template>
{ label: (r: any) => r.onShelf === 1 ? '下架' : '上架', type: (r: any) => r.onShelf === 1 ? 'warning' : 'success', onClick: onOnShelf }, </el-table-column>
{ label: '改分类', type: 'primary', onClick: onShowCategory }, </el-table>
{ label: '删除', type: 'danger', divided: true, onClick: onDelete } <div class="rr-pagination">
]" <el-pagination :current-page="page" :page-sizes="[10, 20, 50, 100]" :page-size="limit" :total="total" layout="total, sizes, prev, pager, next, jumper" @size-change="onLimitChange" @current-change="onPageChange" />
/> </div>
</template> </div>
</DataTable>
<el-dialog v-model="detailVisible" title="服务详情" width="700px"> <el-dialog v-model="detailVisible" title="服务详情" width="700px">
<el-descriptions v-if="detailData" :column="2" border> <el-descriptions v-if="detailData" :column="2" border>
@ -360,10 +284,19 @@ onMounted(() => Promise.all([load(), loadStats(), loadCounts()]))
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1500px; } .rr-page { max-width: 1500px; }
.rr-page-header { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
.rr-page-title { font-size: 22px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
.rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
.rr-page-actions { display: flex; gap: 8px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 16px; } .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-tabs-row { margin-bottom: 16px; }
.rr-tabs { display: flex; gap: 4px; padding: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); width: fit-content; }
.rr-tab { padding: 6px 16px; border-radius: var(--radius); font-size: 13px; color: var(--ink-2); cursor: pointer; }
.rr-tab.active { color: white; background: var(--primary); font-weight: 500; }
.rr-batch-bar { display: flex; align-items: center; gap: 8px; padding: 10px 16px; background: var(--bg-soft); border-radius: var(--radius); margin-bottom: 12px; } .rr-batch-bar { display: flex; align-items: center; gap: 8px; padding: 10px 16px; background: var(--bg-soft); border-radius: var(--radius); margin-bottom: 12px; }
.rr-batch-tip { font-size: 13px; color: var(--ink-2); margin-right: 8px; } .rr-batch-tip { font-size: 13px; color: var(--ink-2); margin-right: 8px; }
.rr-pagination { padding: 12px 20px; display: flex; justify-content: flex-end; border-top: 1px solid var(--border); }
.rr-good-cell { display: flex; align-items: center; gap: 10px; } .rr-good-cell { display: flex; align-items: center; gap: 10px; }
.rr-good-icon-placeholder { width: 40px; height: 40px; border-radius: 4px; background: var(--bg-soft); display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-weight: 600; flex-shrink: 0; } .rr-good-icon-placeholder { width: 40px; height: 40px; border-radius: 4px; background: var(--bg-soft); display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-weight: 600; flex-shrink: 0; }
.rr-price { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 13px; font-weight: 500; color: var(--primary); }
</style> </style>

View File

@ -2,8 +2,6 @@
/** /**
* F006 部门管理 - W4.14 * F006 部门管理 - W4.14
* *
* W11 A8: 套 PageHeader/SearchBar 标准化模板
*
* 后端: GET /sys/dept/list (返树结构) * 后端: GET /sys/dept/list (返树结构)
* 字段: id/pid/name/sort/createDate/parentName * 字段: id/pid/name/sort/createDate/parentName
* *
@ -13,8 +11,6 @@ import { onMounted, ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request' import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface Dept { interface Dept {
id: string id: string
@ -148,9 +144,15 @@ onMounted(load)
<template> <template>
<div class="rr-sys-dept"> <div class="rr-sys-dept">
<PageHeader title="部门管理" :sub="``"> <div class="rr-page-header">
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增部门</el-button> <div class="rr-page-titles">
</PageHeader> <h2 class="rr-page-title">部门管理</h2>
<p class="rr-page-sub">共 {{ list.length }} 个部门(树形)</p>
</div>
<div class="rr-page-actions">
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增部门</el-button>
</div>
</div>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -167,7 +169,6 @@ onMounted(load)
:page="1" :page="1"
:limit="999" :limit="999"
:show-pagination="false" :show-pagination="false"
:page-sizes="[10, 20, 50, 100]"
:error="error" :error="error"
empty-text="暂无部门" empty-text="暂无部门"
borderless borderless
@ -221,9 +222,6 @@ onMounted(load)
<style scoped> <style scoped>
.rr-sys-dept { padding: 16px; } .rr-sys-dept { padding: 16px; }
.rr-list-card { background: var(--surface); border-radius: 12px; padding: 4px; }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } .rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; } .rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); } .rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }

View File

@ -2,8 +2,6 @@
/** /**
* F006 字典数据 - W4.18 * F006 字典数据 - W4.18
* *
* W11 A8: 套 PageHeader/SearchBar 标准化模板
*
* 后端: GET /sys/dict/data/page * 后端: GET /sys/dict/data/page
* 字段: id/dictTypeId/dictLabel/dictValue/dictSort/remark/createDate * 字段: id/dictTypeId/dictLabel/dictValue/dictSort/remark/createDate
* *
@ -13,8 +11,6 @@ import { onMounted, ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request' import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface DictData { interface DictData {
id: string id: string
@ -142,19 +138,21 @@ onMounted(load)
<template> <template>
<div class="rr-sys-dict-data"> <div class="rr-sys-dict-data">
<PageHeader title="字典数据" :sub="`共 ${total} 条`"> <div class="rr-page-header">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button> <div class="rr-page-titles">
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增字典项</el-button> <h2 class="rr-page-title">字典数据</h2>
</PageHeader> <p class="rr-page-sub">共 {{ total }} 条字典项</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增字典项</el-button>
</div>
</div>
<SearchBar <div class="rr-filters-row">
v-model:filters="filters" <el-input v-model="filters.dictLabel" placeholder="字典标签搜索" clearable class="rr-label-input" size="small" @keyup.enter="onSearch" />
:fields='[ <el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
{ key: "dictLabel", label: "字典标签", type: "text", placeholder: "字典标签" } </div>
]'
@search="onSearch"
@reset="onReset"
/>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -172,7 +170,7 @@ onMounted(load)
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :page-sizes="[10, 20, 50, 100]" :show-pagination="true"
:error="error" :error="error"
empty-text="暂无字典数据" empty-text="暂无字典数据"
borderless borderless
@ -224,9 +222,6 @@ onMounted(load)
<style scoped> <style scoped>
.rr-sys-dict-data { padding: 16px; } .rr-sys-dict-data { padding: 16px; }
.rr-list-card { background: var(--surface); border-radius: 12px; padding: 4px; }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } .rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; } .rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); } .rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }

View File

@ -2,8 +2,6 @@
/** /**
* F006 字典类型 - W4.18 * F006 字典类型 - W4.18
* *
* W11 A8: 套 PageHeader/SearchBar 标准化模板
*
* 后端: GET /sys/dict/type/page * 后端: GET /sys/dict/type/page
* 字段: id/dictType/dictName/remark/createDate * 字段: id/dictType/dictName/remark/createDate
* *
@ -13,8 +11,6 @@ import { onMounted, ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request' import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface DictType { interface DictType {
id: string id: string
@ -132,21 +128,21 @@ onMounted(load)
<template> <template>
<div class="rr-sys-dict-type"> <div class="rr-sys-dict-type">
<PageHeader title="字典类型" :sub="`共 ${total} 个字典`"> <div class="rr-page-header">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button> <div class="rr-page-titles">
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增字典类型</el-button> <h2 class="rr-page-title">字典类型</h2>
</PageHeader> <p class="rr-page-sub">共 {{ total }} 个字典类型</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增字典类型</el-button>
</div>
</div>
<SearchBar <div class="rr-filters-row">
v-model:filters="filters" <el-input v-model="filters.dictType" placeholder="字典类型搜索" clearable class="rr-type-input" size="small" @keyup.enter="onSearch" />
:fields='[ <el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
{ key: "dictName", label: "字典名", type: "text", placeholder: "字典名" }, </div>
{ key: "dictType", label: "字典类型", type: "text", placeholder: "字典类型" }
]'
@search="onSearch"
@reset="onReset"
/>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -162,7 +158,7 @@ onMounted(load)
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :page-sizes="[10, 20, 50, 100]" :show-pagination="true"
:error="error" :error="error"
empty-text="暂无字典类型" empty-text="暂无字典类型"
borderless borderless
@ -208,9 +204,6 @@ onMounted(load)
<style scoped> <style scoped>
.rr-sys-dict-type { padding: 16px; } .rr-sys-dict-type { padding: 16px; }
.rr-list-card { background: var(--surface); border-radius: 12px; padding: 4px; }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } .rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; } .rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); } .rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }

View File

@ -2,8 +2,6 @@
/** /**
* F006 文件管理 - W4.16 * F006 文件管理 - W4.16
* *
* W11 A8: 套 PageHeader/SearchBar 标准化模板
*
* 后端: GET /sys/file/page * 后端: GET /sys/file/page
* 字段: id/url/creator/createDate * 字段: id/url/creator/createDate
*/ */
@ -11,8 +9,6 @@ import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpDelete } from '@/utils/request' import { httpGet, httpDelete } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface FileItem { interface FileItem {
id: string id: string
@ -89,9 +85,15 @@ onMounted(load)
<template> <template>
<div class="rr-sys-file"> <div class="rr-sys-file">
<PageHeader title="文件管理" :sub="`共 ${total} 个文件`"> <div class="rr-page-header">
<el-button @click="load" class="rr-btn-secondary">刷新</el-button> <div class="rr-page-titles">
</PageHeader> <h2 class="rr-page-title">文件管理</h2>
<p class="rr-page-sub">共 {{ total }} 个文件</p>
</div>
<div class="rr-page-actions">
<el-button @click="load" class="rr-btn-secondary">刷新</el-button>
</div>
</div>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -108,7 +110,6 @@ onMounted(load)
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :show-pagination="true"
:page-sizes="[10, 20, 50, 100]"
:error="error" :error="error"
empty-text="暂无文件" empty-text="暂无文件"
borderless borderless
@ -132,9 +133,6 @@ onMounted(load)
<style scoped> <style scoped>
.rr-sys-file { padding: 16px; } .rr-sys-file { padding: 16px; }
.rr-list-card { background: var(--surface); border-radius: 12px; padding: 4px; }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } .rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; } .rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); } .rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }

View File

@ -2,16 +2,12 @@
/** /**
* F006 错误日志 - W4.16 * F006 错误日志 - W4.16
* *
* W17.5: 套 PageHeader/SearchBar 标准化模板 (limit 10 + :page-sizes)
*
* 后端: GET /sys/log/error/page * 后端: GET /sys/log/error/page
* 字段: id/module/requestUri/requestMethod/requestParams/userAgent/ip/errorInfo/createDate * 字段: id/module/requestUri/requestMethod/requestParams/userAgent/ip/errorInfo/createDate
*/ */
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface LogEntry { interface LogEntry {
id: string id: string
@ -31,7 +27,7 @@ const total = ref(0)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const page = ref(1) const page = ref(1)
const limit = ref(10) const limit = ref(20)
const filters = ref({ module: '' }) const filters = ref({ module: '' })
const detailVisible = ref(false) const detailVisible = ref(false)
@ -95,16 +91,20 @@ onMounted(load)
<template> <template>
<div class="rr-sys-log-error"> <div class="rr-sys-log-error">
<PageHeader title="错误日志" :sub="`共 ${total} 条错误记录`" /> <div class="rr-page-header">
<div class="rr-page-titles">
<h2 class="rr-page-title">错误日志</h2>
<p class="rr-page-sub">共 {{ total }} 条错误记录</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
</div>
</div>
<SearchBar <div class="rr-filters-row">
v-model:filters="filters" <el-input v-model="filters.module" placeholder="模块名" clearable class="rr-module-input" size="small" @keyup.enter="onSearch" />
:fields='[ <el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
{ key: "module", label: "模块名", type: "text", placeholder: "模块名" } </div>
]'
@search="onSearch"
@reset="onReset"
/>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -123,7 +123,6 @@ onMounted(load)
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :show-pagination="true"
:page-sizes="[10, 20, 50, 100]"
:error="error" :error="error"
empty-text="暂无错误日志" empty-text="暂无错误日志"
borderless borderless
@ -194,6 +193,13 @@ onMounted(load)
<style scoped> <style scoped>
.rr-sys-log-error { padding: 16px; } .rr-sys-log-error { padding: 16px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
.rr-page-actions { display: flex; gap: 8px; }
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
.rr-module-input { width: 180px; }
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; } .rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); } .rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
.rr-mono-sm { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 11px; color: var(--el-text-color-regular); } .rr-mono-sm { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 11px; color: var(--el-text-color-regular); }
@ -211,10 +217,10 @@ onMounted(load)
font-weight: 600; font-weight: 600;
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
} }
.rr-method-get { background: var(--brand-soft); color: var(--brand); } .rr-method-get { background: #DBEAFE; color: #2563EB; }
.rr-method-post { background: var(--green-soft); color: var(--green); } .rr-method-post { background: #D1FAE5; color: #059669; }
.rr-method-put { background: var(--amber-soft); color: var(--amber); } .rr-method-put { background: #FEF3C7; color: #D97706; }
.rr-method-delete { background: var(--red-soft); color: var(--red); } .rr-method-delete { background: #FEE2E2; color: #DC2626; }
.rr-error-detail { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 20px; } .rr-error-detail { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 20px; }
.rr-detail-row { display: flex; flex-direction: column; gap: 4px; } .rr-detail-row { display: flex; flex-direction: column; gap: 4px; }
.rr-detail-full { grid-column: 1 / -1; } .rr-detail-full { grid-column: 1 / -1; }

View File

@ -2,8 +2,6 @@
/** /**
* F006 登录日志 - W4.14 * F006 登录日志 - W4.14
* *
* W17.5: 套 PageHeader/SearchBar 标准化模板 (limit 10 + :page-sizes)
*
* 后端: GET /sys/log/login/page * 后端: GET /sys/log/login/page
* 字段: id/operation/status/userAgent/ip/creatorName/createDate * 字段: id/operation/status/userAgent/ip/creatorName/createDate
* *
@ -12,8 +10,6 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface LogEntry { interface LogEntry {
id: string id: string
@ -42,7 +38,7 @@ const total = ref(0)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const page = ref(1) const page = ref(1)
const limit = ref(10) const limit = ref(20)
const filters = ref({ creatorName: '', ip: '' }) const filters = ref({ creatorName: '', ip: '' })
@ -96,17 +92,35 @@ onMounted(load)
<template> <template>
<div class="rr-sys-log-login"> <div class="rr-sys-log-login">
<PageHeader title="登录日志" :sub="`共 ${total} 条登录记录`" /> <div class="rr-page-header">
<div class="rr-page-titles">
<h2 class="rr-page-title">登录日志</h2>
<p class="rr-page-sub">共 {{ total }} 条登录记录</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
</div>
</div>
<SearchBar <div class="rr-filters-row">
v-model:filters="filters" <el-input
:fields='[ v-model="filters.creatorName"
{ key: "creatorName", label: "用户名", type: "text", placeholder: "用户名搜索" }, placeholder="用户名搜索"
{ key: "ip", label: "IP", type: "text", placeholder: "IP 搜索" } clearable
]' class="rr-name-input"
@search="onSearch" size="small"
@reset="onReset" @keyup.enter="onSearch"
/> />
<el-input
v-model="filters.ip"
placeholder="IP 搜索"
clearable
class="rr-ip-input"
size="small"
@keyup.enter="onSearch"
/>
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
</div>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -124,7 +138,6 @@ onMounted(load)
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :show-pagination="true"
:page-sizes="[10, 20, 50, 100]"
:error="error" :error="error"
empty-text="暂无登录日志" empty-text="暂无登录日志"
borderless borderless
@ -154,6 +167,14 @@ onMounted(load)
<style scoped> <style scoped>
.rr-sys-log-login { padding: 16px; } .rr-sys-log-login { padding: 16px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
.rr-page-actions { display: flex; gap: 8px; }
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
.rr-name-input { width: 200px; }
.rr-ip-input { width: 200px; }
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; } .rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); } .rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
</style> </style>

View File

@ -2,8 +2,6 @@
/** /**
* F006 操作日志 - W4.14 * F006 操作日志 - W4.14
* *
* W17.5: 套 PageHeader/SearchBar 标准化模板 (limit 10 + :page-sizes)
*
* 后端: GET /sys/log/operation/page * 后端: GET /sys/log/operation/page
* 字段: id/module/operation/requestUri/requestMethod/requestParams/requestTime/userAgent/ip/status/creatorName/createDate * 字段: id/module/operation/requestUri/requestMethod/requestParams/requestTime/userAgent/ip/status/creatorName/createDate
* *
@ -12,8 +10,6 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface LogEntry { interface LogEntry {
id: string id: string
@ -41,7 +37,7 @@ const total = ref(0)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const page = ref(1) const page = ref(1)
const limit = ref(10) const limit = ref(20)
const filters = ref({ creatorName: '', module: '' }) const filters = ref({ creatorName: '', module: '' })
@ -107,17 +103,35 @@ onMounted(load)
<template> <template>
<div class="rr-sys-log-operation"> <div class="rr-sys-log-operation">
<PageHeader title="操作日志" :sub="`共 ${total} 条操作记录`" /> <div class="rr-page-header">
<div class="rr-page-titles">
<h2 class="rr-page-title">操作日志</h2>
<p class="rr-page-sub">共 {{ total }} 条操作记录</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
</div>
</div>
<SearchBar <div class="rr-filters-row">
v-model:filters="filters" <el-input
:fields='[ v-model="filters.creatorName"
{ key: "creatorName", label: "操作用户", type: "text", placeholder: "操作用户" }, placeholder="操作用户"
{ key: "module", label: "模块名", type: "text", placeholder: "模块名" } clearable
]' class="rr-name-input"
@search="onSearch" size="small"
@reset="onReset" @keyup.enter="onSearch"
/> />
<el-input
v-model="filters.module"
placeholder="模块名"
clearable
class="rr-module-input"
size="small"
@keyup.enter="onSearch"
/>
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
</div>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -137,7 +151,6 @@ onMounted(load)
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :show-pagination="true"
:page-sizes="[10, 20, 50, 100]"
:error="error" :error="error"
empty-text="暂无操作日志" empty-text="暂无操作日志"
borderless borderless
@ -178,6 +191,14 @@ onMounted(load)
<style scoped> <style scoped>
.rr-sys-log-operation { padding: 16px; } .rr-sys-log-operation { padding: 16px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
.rr-page-actions { display: flex; gap: 8px; }
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
.rr-name-input { width: 180px; }
.rr-module-input { width: 180px; }
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; } .rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); } .rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
.rr-mono-sm { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 11px; color: var(--el-text-color-regular); } .rr-mono-sm { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 11px; color: var(--el-text-color-regular); }
@ -189,8 +210,8 @@ onMounted(load)
font-weight: 600; font-weight: 600;
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
} }
.rr-method-get { background: var(--brand-soft); color: var(--brand); } .rr-method-get { background: #DBEAFE; color: #2563EB; }
.rr-method-post { background: var(--green-soft); color: var(--green); } .rr-method-post { background: #D1FAE5; color: #059669; }
.rr-method-put { background: var(--amber-soft); color: var(--amber); } .rr-method-put { background: #FEF3C7; color: #D97706; }
.rr-method-delete { background: var(--red-soft); color: var(--red); } .rr-method-delete { background: #FEE2E2; color: #DC2626; }
</style> </style>

View File

@ -2,8 +2,6 @@
/** /**
* F006 菜单管理 - W4.14 * F006 菜单管理 - W4.14
* *
* W11 A8: 套 PageHeader (树形保留 el-table)
*
* 后端: GET /sys/menu/list (返树结构) * 后端: GET /sys/menu/list (返树结构)
* 字段: id/pid/name/url/type/permissions/icon/sort/createDate * 字段: id/pid/name/url/type/permissions/icon/sort/createDate
* *
@ -13,7 +11,6 @@ import { onMounted, ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request' import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
interface Menu { interface Menu {
id: string id: string
@ -171,9 +168,15 @@ onMounted(load)
<template> <template>
<div class="rr-sys-menu"> <div class="rr-sys-menu">
<PageHeader title="菜单管理" :sub="`共 ${list.length} 个菜单`"> <div class="rr-page-header">
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增菜单</el-button> <div class="rr-page-titles">
</PageHeader> <h2 class="rr-page-title">菜单管理</h2>
<p class="rr-page-sub">共 {{ list.length }} 个菜单(树形)</p>
</div>
<div class="rr-page-actions">
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增菜单</el-button>
</div>
</div>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -193,7 +196,6 @@ onMounted(load)
:page="1" :page="1"
:limit="999" :limit="999"
:show-pagination="false" :show-pagination="false"
:page-sizes="[10, 20, 50, 100]"
:error="error" :error="error"
empty-text="暂无菜单" empty-text="暂无菜单"
borderless borderless
@ -270,9 +272,6 @@ onMounted(load)
<style scoped> <style scoped>
.rr-sys-menu { padding: 16px; } .rr-sys-menu { padding: 16px; }
.rr-list-card { background: var(--surface); border-radius: 12px; padding: 4px; }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } .rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; } .rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); } .rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }

View File

@ -2,8 +2,6 @@
/** /**
* F006 通知公告 - W4.18 * F006 通知公告 - W4.18
* *
* W11 A8: 套 PageHeader/SearchBar 标准化模板
*
* 后端: GET /sys/notice/page * 后端: GET /sys/notice/page
* 字段: id/title/content/type/status/publisherName/createDate * 字段: id/title/content/type/status/publisherName/createDate
* *
@ -13,8 +11,6 @@ import { onMounted, ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request' import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface Notice { interface Notice {
id: string id: string
@ -158,19 +154,21 @@ onMounted(load)
<template> <template>
<div class="rr-sys-notice"> <div class="rr-sys-notice">
<PageHeader title="通知公告" :sub="`共 ${total} 条`"> <div class="rr-page-header">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button> <div class="rr-page-titles">
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增公告</el-button> <h2 class="rr-page-title">通知公告</h2>
</PageHeader> <p class="rr-page-sub">共 {{ total }} 条公告</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增公告</el-button>
</div>
</div>
<SearchBar <div class="rr-filters-row">
v-model:filters="filters" <el-input v-model="filters.title" placeholder="标题搜索" clearable class="rr-title-input" size="small" @keyup.enter="onSearch" />
:fields='[ <el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
{ key: "title", label: "标题", type: "text", placeholder: "标题" } </div>
]'
@search="onSearch"
@reset="onReset"
/>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -188,7 +186,7 @@ onMounted(load)
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :page-sizes="[10, 20, 50, 100]" :show-pagination="true"
:error="error" :error="error"
empty-text="暂无公告" empty-text="暂无公告"
borderless borderless
@ -255,9 +253,6 @@ onMounted(load)
<style scoped> <style scoped>
.rr-sys-notice { padding: 16px; } .rr-sys-notice { padding: 16px; }
.rr-list-card { background: var(--surface); border-radius: 12px; padding: 4px; }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } .rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; } .rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); } .rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }

View File

@ -19,8 +19,6 @@ import { onMounted, ref, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request' import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
interface SysParams { interface SysParams {
@ -195,10 +193,19 @@ onMounted(() => {
<template> <template>
<div class="rr-sys-params"> <div class="rr-sys-params">
<PageHeader title="参数管理" :sub="hasParamsPerm ? `共 ${total} 个参数` : '当前角色无参数管理权限'"> <div class="rr-page-header">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button> <div class="rr-page-titles">
<el-button type="primary" class="rr-btn-primary" :disabled="!hasParamsPerm" @click="createHandle">+ 新增参数</el-button> <h2 class="rr-page-title">参数管理</h2>
</PageHeader> <p class="rr-page-sub">
<template v-if="hasParamsPerm">共 {{ total }} 个参数</template>
<template v-else>当前角色无参数管理权限</template>
</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
<el-button type="primary" class="rr-btn-primary" :disabled="!hasParamsPerm" @click="createHandle">+ 新增参数</el-button>
</div>
</div>
<!-- 无权限提示卡 --> <!-- 无权限提示卡 -->
<el-alert <el-alert
@ -215,14 +222,17 @@ onMounted(() => {
</el-alert> </el-alert>
<template v-else> <template v-else>
<SearchBar <div class="rr-filters-row">
v-model:filters="filters" <el-input
:fields='[ v-model="filters.paramCode"
{ key: "paramKey", label: "参数键", type: "text", placeholder: "参数键" } placeholder="参数编码搜索"
]' clearable
@search="onSearch" class="rr-code-input"
@reset="onReset" size="small"
/> @keyup.enter="onSearch"
/>
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
</div>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -234,12 +244,12 @@ onMounted(() => {
{ prop: 'updateDate', label: '更新时间', width: 170, slot: 'updateDate' }, { prop: 'updateDate', label: '更新时间', width: 170, slot: 'updateDate' },
{ label: '操作', width: 180, slot: 'action' } { label: '操作', width: 180, slot: 'action' }
]" ]"
:data="list" :borderless="true" :data="list"
:loading="loading" :loading="loading"
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :page-sizes="[10, 20, 50, 100]" :show-pagination="true"
:error="loadError" :error="loadError"
empty-text="暂无参数" empty-text="暂无参数"
row-key="id" row-key="id"

View File

@ -2,8 +2,6 @@
/** /**
* F006 岗位管理 - W4.16 * F006 岗位管理 - W4.16
* *
* W11 A8: 套 PageHeader/SearchBar 标准化模板
*
* 后端: GET /sys/post/page * 后端: GET /sys/post/page
* 字段: id/postCode/postName/sort/status/createDate * 字段: id/postCode/postName/sort/status/createDate
*/ */
@ -11,8 +9,6 @@ import { onMounted, ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request' import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface Post { interface Post {
id: string id: string
@ -130,21 +126,22 @@ onMounted(load)
<template> <template>
<div class="rr-sys-post"> <div class="rr-sys-post">
<PageHeader title="岗位管理" :sub="`共 ${total} 个岗位`"> <div class="rr-page-header">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button> <div class="rr-page-titles">
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增岗位</el-button> <h2 class="rr-page-title">岗位管理</h2>
</PageHeader> <p class="rr-page-sub">共 {{ total }} 个岗位</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增岗位</el-button>
</div>
</div>
<SearchBar <div class="rr-filters-row">
v-model:filters="filters" <el-input v-model="filters.postCode" placeholder="岗位编码" clearable class="rr-code-input" size="small" @keyup.enter="onSearch" />
:fields='[ <el-input v-model="filters.postName" placeholder="岗位名称" clearable class="rr-name-input" size="small" @keyup.enter="onSearch" />
{ key: "postCode", label: "岗位编码", type: "text", placeholder: "岗位编码" }, <el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
{ key: "postName", label: "岗位名称", type: "text", placeholder: "岗位名称" } </div>
]'
@search="onSearch"
@reset="onReset"
/>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -161,7 +158,7 @@ onMounted(load)
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :page-sizes="[10, 20, 50, 100]" :show-pagination="true"
:error="error" :error="error"
empty-text="暂无岗位" empty-text="暂无岗位"
borderless borderless
@ -216,9 +213,6 @@ onMounted(load)
<style scoped> <style scoped>
.rr-sys-post { padding: 16px; } .rr-sys-post { padding: 16px; }
.rr-list-card { background: var(--surface); border-radius: 12px; padding: 4px; }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } .rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; } .rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); } .rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }

View File

@ -2,8 +2,6 @@
/** /**
* F006 商品管理 - W4.18 * F006 商品管理 - W4.18
* *
* W11 A8: 套 PageHeader/SearchBar 标准化模板
*
* 后端: GET /sys/product/page * 后端: GET /sys/product/page
* 字段: id/name/categoryId/originPrice/price/stock/status/createDate * 字段: id/name/categoryId/originPrice/price/stock/status/createDate
* *
@ -13,8 +11,6 @@ import { onMounted, ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request' import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface Product { interface Product {
id: string id: string
@ -156,19 +152,21 @@ onMounted(load)
<template> <template>
<div class="rr-sys-product"> <div class="rr-sys-product">
<PageHeader title="商品管理" :sub="`共 ${total} 个商品`"> <div class="rr-page-header">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button> <div class="rr-page-titles">
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增商品</el-button> <h2 class="rr-page-title">商品管理</h2>
</PageHeader> <p class="rr-page-sub">共 {{ total }} 个商品</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增商品</el-button>
</div>
</div>
<SearchBar <div class="rr-filters-row">
v-model:filters="filters" <el-input v-model="filters.name" placeholder="商品名搜索" clearable class="rr-name-input" size="small" @keyup.enter="onSearch" />
:fields='[ <el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
{ key: "name", label: "商品名称", type: "text", placeholder: "商品名称" } </div>
]'
@search="onSearch"
@reset="onReset"
/>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -187,7 +185,7 @@ onMounted(load)
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :page-sizes="[10, 20, 50, 100]" :show-pagination="true"
:error="error" :error="error"
empty-text="暂无商品" empty-text="暂无商品"
borderless borderless
@ -249,9 +247,6 @@ onMounted(load)
<style scoped> <style scoped>
.rr-sys-product { padding: 16px; } .rr-sys-product { padding: 16px; }
.rr-list-card { background: var(--surface); border-radius: 12px; padding: 4px; }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } .rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; } .rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); } .rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }

View File

@ -2,8 +2,6 @@
/** /**
* F006 角色组管理 - W4.16 * F006 角色组管理 - W4.16
* *
* W11 A8: 套 PageHeader/SearchBar 标准化模板
*
* 后端: GET /sys/roleGroup/page * 后端: GET /sys/roleGroup/page
* 字段: id/operationId/name/createDate * 字段: id/operationId/name/createDate
*/ */
@ -11,8 +9,6 @@ import { onMounted, ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request' import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface RoleGroup { interface RoleGroup {
id: string id: string
@ -115,19 +111,21 @@ onMounted(load)
<template> <template>
<div class="rr-sys-role-group"> <div class="rr-sys-role-group">
<PageHeader title="角色组管理" :sub="`共 ${total} 个角色组`"> <div class="rr-page-header">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button> <div class="rr-page-titles">
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增角色组</el-button> <h2 class="rr-page-title">角色组管理</h2>
</PageHeader> <p class="rr-page-sub">共 {{ total }} 个角色组</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增角色组</el-button>
</div>
</div>
<SearchBar <div class="rr-filters-row">
v-model:filters="filters" <el-input v-model="filters.name" placeholder="角色组名" clearable class="rr-name-input" size="small" @keyup.enter="onSearch" />
:fields='[ <el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
{ key: "name", label: "角色组名", type: "text", placeholder: "角色组名" } </div>
]'
@search="onSearch"
@reset="onReset"
/>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -142,7 +140,7 @@ onMounted(load)
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :page-sizes="[10, 20, 50, 100]" :show-pagination="true"
:error="error" :error="error"
empty-text="暂无角色组" empty-text="暂无角色组"
borderless borderless
@ -184,9 +182,6 @@ onMounted(load)
<style scoped> <style scoped>
.rr-sys-role-group { padding: 16px; } .rr-sys-role-group { padding: 16px; }
.rr-list-card { background: var(--surface); border-radius: 12px; padding: 4px; }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } .rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; } .rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); } .rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }

View File

@ -20,8 +20,6 @@ import { onMounted, ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request' import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface SysRole { interface SysRole {
id: string id: string
@ -180,19 +178,28 @@ onMounted(() => { load() })
<template> <template>
<div class="rr-sys-role"> <div class="rr-sys-role">
<PageHeader title="角色管理" :sub="`共 ${total} 个角色`"> <div class="rr-page-header">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button> <div class="rr-page-titles">
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增角色</el-button> <h2 class="rr-page-title">角色管理</h2>
</PageHeader> <p class="rr-page-sub">共 {{ total }} 个角色</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增角色</el-button>
</div>
</div>
<SearchBar <div class="rr-filters-row">
v-model:filters="filters" <el-input
:fields='[ v-model="filters.name"
{ key: "roleName", label: "角色名", type: "text", placeholder: "角色名" } placeholder="角色名搜索"
]' clearable
@search="onSearch" class="rr-name-input"
@reset="onReset" size="small"
/> @keyup.enter="onSearch"
/>
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
</div>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -204,12 +211,12 @@ onMounted(() => { load() })
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' }, { prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
{ label: '操作', width: 180, slot: 'action' } { label: '操作', width: 180, slot: 'action' }
]" ]"
:data="list" :borderless="true" :data="list"
:loading="loading" :loading="loading"
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :page-sizes="[10, 20, 50, 100]" :show-pagination="true"
:error="loadError" :error="loadError"
empty-text="暂无角色" empty-text="暂无角色"
row-key="id" row-key="id"

View File

@ -2,8 +2,6 @@
/** /**
* F006 定时任务 - W4.16 * F006 定时任务 - W4.16
* *
* W11 A8: 套 PageHeader/SearchBar 标准化模板
*
* 后端: GET /sys/schedule/page * 后端: GET /sys/schedule/page
* 字段: id/beanName/methodName/params/cron/expression/status/remark/createDate * 字段: id/beanName/methodName/params/cron/expression/status/remark/createDate
*/ */
@ -11,9 +9,6 @@ import { onMounted, ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request' import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
interface Schedule { interface Schedule {
id: string id: string
@ -126,18 +121,20 @@ onMounted(load)
<template> <template>
<div class="rr-sys-schedule"> <div class="rr-sys-schedule">
<PageHeader title="定时任务" :sub="`共 ${total} 个任务`"> <div class="rr-page-header">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button> <div class="rr-page-titles">
</PageHeader> <h2 class="rr-page-title">定时任务</h2>
<p class="rr-page-sub">共 {{ total }} 个定时任务</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
</div>
</div>
<SearchBar <div class="rr-filters-row">
v-model:filters="filters" <el-input v-model="filters.beanName" placeholder="Bean 名搜索" clearable class="rr-bean-input" size="small" @keyup.enter="onSearch" />
:fields='[ <el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
{ key: "beanName", label: "Bean 名", type: "text", placeholder: "Bean 名" } </div>
]'
@search="onSearch"
@reset="onReset"
/>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -149,14 +146,14 @@ onMounted(load)
{ prop: 'remark', label: '备注', minWidth: 200 }, { prop: 'remark', label: '备注', minWidth: 200 },
{ prop: 'status', label: '状态', width: 100, slot: 'status' }, { prop: 'status', label: '状态', width: 100, slot: 'status' },
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' }, { prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
{ label: '操作', width: 140, slot: 'action' } { label: '操作', width: 280, slot: 'action' }
]" ]"
:data="list" :data="list"
:loading="loading" :loading="loading"
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :page-sizes="[10, 20, 50, 100]" :show-pagination="true"
:error="error" :error="error"
empty-text="暂无定时任务" empty-text="暂无定时任务"
borderless borderless
@ -171,15 +168,10 @@ onMounted(load)
</template> </template>
<template #createDate="{ row }"><span class="rr-time">{{ fmtDate(row.createDate) }}</span></template> <template #createDate="{ row }"><span class="rr-time">{{ fmtDate(row.createDate) }}</span></template>
<template #action="{ row }"> <template #action="{ row }">
<TableActions <el-button text type="primary" size="small" @click="runHandle(row)">执行</el-button>
:row="row" <el-button v-if="row.status === 1" text type="warning" size="small" @click="pauseHandle(row)">暂停</el-button>
:actions="[ <el-button v-else text type="primary" size="small" @click="resumeHandle(row)">恢复</el-button>
{ label: '执行', type: 'primary', onClick: runHandle }, <el-button text type="primary" size="small" @click="deleteHandle(row)">删除</el-button>
{ label: '暂停', type: 'warning', when: (r: any) => r.status === 1, onClick: pauseHandle },
{ label: '恢复', type: 'primary', when: (r: any) => r.status !== 1, onClick: resumeHandle },
{ label: '删除', type: 'danger', onClick: deleteHandle }
]"
/>
</template> </template>
</DataTable> </DataTable>
</div> </div>
@ -188,9 +180,6 @@ onMounted(load)
<style scoped> <style scoped>
.rr-sys-schedule { padding: 16px; } .rr-sys-schedule { padding: 16px; }
.rr-list-card { background: var(--surface); border-radius: 12px; padding: 4px; }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); }
.rr-muted { color: var(--ink-3); font-size: 12px; }
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } .rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; } .rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); } .rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }

View File

@ -26,9 +26,6 @@ import { onMounted, ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request' import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
interface SysUser { interface SysUser {
id: string id: string
@ -230,18 +227,39 @@ onMounted(() => { load() })
<template> <template>
<div class="rr-sys-user"> <div class="rr-sys-user">
<PageHeader title="后台用户" :sub="`共 ${total} 个用户`"> <div class="rr-page-header">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button> <div class="rr-page-titles">
</PageHeader> <h2 class="rr-page-title">用户管理</h2>
<p class="rr-page-sub">共 {{ total }} 个用户(含 {{ USER_TYPE_MAP[99] ? USER_TYPE_MAP[99].label : 'AI公司' }} 账号)</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
</div>
</div>
<SearchBar <div class="rr-filters-row">
v-model:filters="filters" <el-input
:fields='[ v-model="filters.username"
{ key: "username", label: "用户名", type: "text", placeholder: "用户名" } placeholder="用户名搜索"
]' clearable
@search="onSearch" class="rr-username-input"
@reset="onReset" size="small"
/> @keyup.enter="onSearch"
/>
<el-select
v-model="filters.userType"
placeholder="用户类型"
class="rr-type-select"
clearable
size="small"
>
<el-option value="1" label="试用期" />
<el-option value="2" label="正式员工" />
<el-option value="3" label="外包员工" />
<el-option value="99" label="AI 公司" />
</el-select>
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
</div>
<div class="rr-list-card"> <div class="rr-list-card">
<DataTable <DataTable
@ -253,14 +271,14 @@ onMounted(() => { load() })
{ prop: 'userType', label: '类型', width: 100, slot: 'userType' }, { prop: 'userType', label: '类型', width: 100, slot: 'userType' },
{ prop: 'status', label: '状态', width: 80, slot: 'status' }, { prop: 'status', label: '状态', width: 80, slot: 'status' },
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' }, { prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
{ label: '操作', width: 140, slot: 'action' } { label: '操作', width: 260, slot: 'action' }
]" ]"
:data="list" :borderless="true" :data="list"
:loading="loading" :loading="loading"
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:show-pagination="true" :page-sizes="[10, 20, 50, 100]" :show-pagination="true"
:error="loadError" :error="loadError"
empty-text="暂无用户" empty-text="暂无用户"
row-key="id" row-key="id"
@ -294,14 +312,32 @@ onMounted(() => { load() })
</template> </template>
<template #action="{ row }"> <template #action="{ row }">
<TableActions <el-button
:row="row" v-if="row.status === 1"
:actions="[ text
{ label: (r: any) => r.status === 1 ? '停用' : '启用', type: (r: any) => r.status === 1 ? 'warning' : 'success', onClick: toggleStatus }, type="warning"
{ label: '重置密码', type: 'primary', onClick: resetPwdHandle }, size="small"
{ label: '删除', type: 'danger', onClick: deleteHandle } @click="toggleStatus(row)"
]" >停用</el-button>
/> <el-button
v-else
text
type="primary"
size="small"
@click="toggleStatus(row)"
>启用</el-button>
<el-button
text
type="primary"
size="small"
@click="resetPwdHandle(row)"
>重置密码</el-button>
<el-button
text
type="primary"
size="small"
@click="deleteHandle(row)"
>删除</el-button>
</template> </template>
</DataTable> </DataTable>
</div> </div>

View File

@ -1,49 +0,0 @@
<script setup lang="ts">
/**
* F006 用户通知 - P0 真 view
* 走 /sys/notice/userPage (2026-08-07 探测: 200, total=0, 接口正常暂无数据)
* 字段(推测): id/title/content/type/readFlag/receiver/createDate
*/
import { onMounted, ref, reactive } from 'vue'
import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
interface NoticeUser { id: string; title: string; content: string; type: number; readFlag: number; receiver: string; createDate: string }
const READ_MAP: Record<number, { label: string; type: string }> = { 0: { label: '未读', type: 'warning' }, 1: { label: '已读', type: 'info' } }
const list = ref<NoticeUser[]>([])
const total = ref(0)
const loading = ref(false)
const query = reactive({ page: 1, limit: 10 })
const columns = [
{ prop: 'id', label: 'ID', width: 80 },
{ prop: 'title', label: '标题', minWidth: 200 },
{ prop: 'content', label: '内容', minWidth: 250, formatter: (r: NoticeUser) => r.content ? r.content.slice(0, 60) + '...' : '--' },
{ prop: 'type', label: '类型', width: 100 },
{ prop: 'readFlag', label: '阅读状态', width: 100, slot: 'read' },
{ prop: 'receiver', label: '接收人', width: 120 },
{ prop: 'createDate', label: '创建时间', width: 180 }
]
async function load() {
loading.value = true
try { const r: any = await httpGet('/sys/notice/userPage', { page: query.page, limit: query.limit }); list.value = r?.list || []; total.value = Number(r?.total || 0) }
catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
function onPageChange(p: number) { query.page = p; load() }
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="用户通知" :sub="`共 ${total} 条`" />
<div class="rr-card">
<DataTable :columns="columns" :data="list" :borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="true" row-key="id" empty-text="暂无数据" @page-change="onPageChange" @limit-change="onLimitChange">
<template #read="{ row }"><el-tag :type="(READ_MAP[row.readFlag]?.type as any) || 'info'">{{ READ_MAP[row.readFlag]?.label || row.readFlag }}</el-tag></template>
</DataTable>
</div>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 1400px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
</style>

View File

@ -1,80 +0,0 @@
<script setup lang="ts">
/**
* F006 运营中心 - P1 真 view
*
* 走 /sys/operation/pageList (type 区分: 1全国 2省级 3县级 4协会)
* 真实接口 (2026-08-07 探测: 200, total=1)
* 字段: id/name/type/provinceName/cityName/areaName/memberNo/rebateRadio/status/createDate
*/
import { onMounted, ref, reactive } from 'vue'
import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
interface Operation {
id: string
name: string
type: number
provinceName: string | null
cityName: string | null
areaName: string | null
memberNo: string | null
rebateRadio: number | null
status: number
createDate: string
}
const props = defineProps<{ type?: number }>()
const TITLES: Record<number, string> = { 1: '全国运营中心', 2: '省级运营中心', 3: '县级运营中心', 4: '行业协会运营' }
const STATUS_MAP: Record<number, { label: string; type: string }> = { 0: { label: '停用', type: 'danger' }, 1: { label: '启用', type: 'success' } }
const list = ref<Operation[]>([])
const total = ref(0)
const loading = ref(false)
const query = reactive({ page: 1, limit: 10 })
const columns = [
{ prop: 'id', label: 'ID', width: 80 },
{ prop: 'name', label: '名称', minWidth: 200 },
{ prop: 'provinceName', label: '省份', width: 120, formatter: (r: Operation) => r.provinceName || '--' },
{ prop: 'cityName', label: '城市', width: 120, formatter: (r: Operation) => r.cityName || '--' },
{ prop: 'areaName', label: '区县', width: 120, formatter: (r: Operation) => r.areaName || '--' },
{ prop: 'memberNo', label: '会员号', width: 140, formatter: (r: Operation) => r.memberNo || '--' },
{ prop: 'rebateRadio', label: '返佣比例', width: 100, formatter: (r: Operation) => (r.rebateRadio ?? 0) + '%' },
{ prop: 'status', label: '状态', width: 100, slot: 'status' },
{ prop: 'createDate', label: '创建时间', width: 180 }
]
async function load() {
loading.value = true
try {
const resp: any = await httpGet('/sys/operation/pageList', { page: query.page, limit: query.limit, ...(props.type ? { type: props.type } : {}) })
list.value = resp?.list || []
total.value = Number(resp?.total || 0)
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
function onPageChange(p: number) { query.page = p; load() }
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader :title="TITLES[props.type ?? 1] || '运营中心'" :sub="`运营中心列表 (共 ${total} 条)`" />
<div class="rr-card">
<DataTable :columns="columns" :data="list"
:borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="true" row-key="id" empty-text="暂无数据" @page-change="onPageChange"
@limit-change="onLimitChange">
<template #status="{ row }">
<el-tag :type="STATUS_MAP[row.status]?.type || 'info'">{{ STATUS_MAP[row.status]?.label || row.status }}</el-tag>
</template>
</DataTable>
</div>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 1400px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
</style>

View File

@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 行政区划 - W10 A2 标准化 * F006 行政区划 - W6 P0 完整化
*
* W10 A2: 套 PageHeader (保留 el-table 树形, DataTable 不支持树形)
* *
* 沿用老 vue sys/region.vue 90 行: * 沿用老 vue sys/region.vue 90 行:
* - 树形表格 (3 级省/市/区), lazy load * - 树形表格 (3 级省/市/区), lazy load
@ -13,8 +11,6 @@
import { ref, onMounted, reactive } from 'vue' import { ref, onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
interface Region { interface Region {
id: string id: string
@ -113,9 +109,15 @@ onMounted(load)
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="行政区划" :sub="`省/市/区/街道 4 级行政区划管理 (含子级: ${tree.length} 个省级)`"> <div class="rr-page-header">
<el-button type="primary" @click="onAdd()">新增省级</el-button> <div>
</PageHeader> <h2 class="rr-page-title">行政区划</h2>
<p class="rr-page-sub">省/市/区/街道 4 级行政区划管理 (含子级: {{ tree.length }} 个省级)</p>
</div>
<div class="rr-page-actions">
<el-button type="primary" @click="onAdd()">新增省级</el-button>
</div>
</div>
<div class="rr-card"> <div class="rr-card">
<el-table <el-table
@ -127,7 +129,6 @@ onMounted(load)
borderless borderless
empty-text="暂无行政区划" empty-text="暂无行政区划"
class="region-table" class="region-table"
:header-cell-style="{ textAlign: 'center' }"
> >
<el-table-column prop="name" label="名称" min-width="200" /> <el-table-column prop="name" label="名称" min-width="200" />
<el-table-column prop="id" label="区划代码" min-width="140" /> <el-table-column prop="id" label="区划代码" min-width="140" />
@ -140,16 +141,11 @@ onMounted(load)
</el-table-column> </el-table-column>
<el-table-column prop="sort" label="排序" width="100" align="center" /> <el-table-column prop="sort" label="排序" width="100" align="center" />
<el-table-column prop="updateDate" label="更新时间" width="180" /> <el-table-column prop="updateDate" label="更新时间" width="180" />
<el-table-column label="操作" width="140" align="center" fixed="right"> <el-table-column label="操作" width="240" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<TableActions <el-button v-if="row.treeLevel < 4" text type="primary" size="small" @click="onAdd(row)">新增下级</el-button>
:row="row" <el-button text type="primary" size="small" @click="onEdit(row)">修改</el-button>
:actions="[ <el-button text type="danger" size="small" @click="onDelete(row)">删除</el-button>
{ label: '修改', type: 'primary', onClick: onEdit },
{ label: '新增下级', type: 'primary', when: (r: any) => r.treeLevel < 4, onClick: onAdd },
{ label: '删除', type: 'danger', onClick: onDelete }
]"
/>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -186,6 +182,9 @@ onMounted(load)
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 1400px; } .rr-page { max-width: 1400px; }
.rr-page-header { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
.rr-page-title { font-size: 22px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
.rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; padding: 8px; } .rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; padding: 8px; }
.region-table { background: transparent; } .region-table { background: transparent; }
</style> </style>

View File

@ -1,53 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* W18.2 企业列表 — 按项目 UI 规范 (PageHeader/SearchBar/DataTable) + 老 vue 全功能 * F006 企业管理 — 套 UserListView 模式 + v2.0 设计规范
* *
* 后端端点: ApiEnterpriseController @ /user/enterprise * 后端端点 (ApiEnterpriseController @ /user/enterprise):
* GET /user/enterprise/page 分页 (EnterpriseDTO 全字段, 支持 enterpriseName/industry/supplierLevel/salesperson/supplierLabel/status/homeShow 过滤) * GET /user/enterprise/page 分页列表
* * GET /user/enterprise/getInfo 详情
* EnterpriseDTO 真实字段 (来源: devbox 编译反编译确认, 2026-07-15):
* id/enterpriseName/enterpriseLogo/administrator/administratorMobile/industry/supplierLevel
* supplierLabel/salesperson/salespersonMobile/status/createDate/enabled/showFlag/homeShow
* linkMan/linkMobile/legalMan/email/enterpriseAddress/mainProducts/registerTime/orgNo/failReason
*/ */
import { onMounted, ref, reactive } from 'vue' import { onMounted, ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage } from 'element-plus'
import { httpGet, httpPost, httpDelete } from '@/utils/request' import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface Enterprise { interface Enterprise {
id: string id: string
enterpriseName: string enterpriseName: string
enterpriseLogo: string | null enterpriseType: number
// 企业管理员 enterpriseLogo?: string | null
administrator: string legalMan: string
administratorMobile: string
// 行业/等级/标签
industry: string
supplierLevel: number | null
supplierLabel: string
// 业务员
salesperson: string
salespersonMobile: string
salespersonId: number | null
// 状态相关
status: number | null // 1=待审核,2=已认证,3=已驳回
createDate: string
enabled: number | null // 0=正常,1=禁用
showFlag: number | null // 0=显示,1=隐藏
homeShow: number | null // 0=不推荐,1=推荐
// 其他详情用
linkMan: string linkMan: string
linkMobile: string linkMobile: string
legalMan: string
email: string
enterpriseAddress: string enterpriseAddress: string
mainProducts: string mainProducts: string
registerTime: string registerTime: string
orgNo: string status: number
failReason: string registerNo: string | null
email: string
raw: any
}
const TYPE_MAP: Record<number, { label: string; type: string }> = {
1: { label: '供应商', type: 'success' },
2: { label: '采购商', type: 'primary' }
} }
const list = ref<Enterprise[]>([]) const list = ref<Enterprise[]>([])
@ -55,347 +38,163 @@ const total = ref(0)
const loading = ref(false) const loading = ref(false)
const page = ref(1) const page = ref(1)
const limit = ref(10) const limit = ref(10)
const selectedIds = ref<string[]>([]) const loadError = ref<string | null>(null)
const detailVisible = ref(false)
const detailRow = ref<Enterprise | null>(null)
const filters = reactive({ const filters = reactive({
enterpriseName: '', enterpriseName: '',
industry: '', enterpriseType: ''
supplierLevel: '',
salesperson: '',
supplierLabel: '',
status: '',
homeShow: ''
}) })
// status 映射 (老 vue: 1=待审核,2=已认证,3=已驳回) const detailVisible = ref(false)
const STATUS_MAP: Record<string, string> = { const detailRow = ref<Enterprise | null>(null)
'1': '待审核', '2': '已认证', '3': '已驳回'
}
const STATUS_CLASS: Record<string, string> = {
'1': 'rr-tag-warn', '2': 'rr-tag-ok', '3': 'rr-tag-err'
}
// supplierLevel 映射 (老 vue: 1=优质,2=钻石,3=金牌,4=银牌,5=铜牌) function fmtDate(ts?: string | number | null) {
const LEVEL_MAP: Record<number, string> = {
1: '优质供应商', 2: '钻石供应商', 3: '金牌供应商',
4: '银牌供应商', 5: '铜牌供应商'
}
function fmtDate(ts?: string | null) {
if (!ts) return '--' if (!ts) return '--'
const d = new Date(ts) const n = typeof ts === 'string' ? Date.parse(ts) : ts
if (isNaN(d.getTime())) return ts.substring(0, 10) if (!n) return String(ts)
const d = new Date(n)
if (isNaN(d.getTime())) return String(ts)
const pad = (x: number) => String(x).padStart(2, '0') const pad = (x: number) => String(x).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
} }
function maskMobile(mobile?: string | null) {
if (!mobile) return ''
return mobile.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
/** 企业名首字 (Avatar 用) */
function initial(name: string) { return name ? name.charAt(0) : '企' }
async function load() { async function load() {
loading.value = true loading.value = true
loadError.value = null
try { try {
const params: Record<string, any> = { page: page.value, limit: limit.value } const data = await httpGet<{ total: number; list: any[] }>('/user/enterprise/page', {
if (filters.enterpriseName) params.enterpriseName = filters.enterpriseName page: page.value,
if (filters.industry) params.industry = filters.industry limit: limit.value,
if (filters.supplierLevel) params.supplierLevel = Number(filters.supplierLevel) ...(filters.enterpriseName ? { enterpriseName: filters.enterpriseName } : {}),
if (filters.salesperson) params.salesperson = filters.salesperson ...(filters.enterpriseType !== '' ? { enterpriseType: Number(filters.enterpriseType) } : {})
if (filters.supplierLabel) params.supplierLabel = filters.supplierLabel })
if (filters.status) params.status = filters.status
if (filters.homeShow) params.homeShow = filters.homeShow
const data = await httpGet<{ total: number; list: any[] }>('/user/enterprise/page', params)
// 映射后端 EnterpriseDTO → 前端 Enterprise 接口
list.value = (data.list || []).map((it: any) => ({ list.value = (data.list || []).map((it: any) => ({
id: String(it.id), id: String(it.id),
enterpriseName: it.enterpriseName || '--', enterpriseName: it.enterpriseName || '--',
enterpriseLogo: it.enterpriseLogo || null, enterpriseType: it.enterpriseType ?? 0,
administrator: it.administrator || '--', enterpriseLogo: it.enterpriseLogo,
administratorMobile: it.administratorMobile || '',
industry: it.industry || '',
supplierLevel: it.supplierLevel ?? null,
supplierLabel: it.supplierLabel || '',
salesperson: it.salesperson || '',
salespersonMobile: it.salespersonMobile || '',
salespersonId: it.salespersonId ?? null,
status: it.status ?? null,
createDate: it.createDate || '',
enabled: it.enabled ?? null,
showFlag: it.showFlag ?? null,
homeShow: it.homeShow ?? null,
linkMan: it.linkMan || '--',
linkMobile: it.linkMobile || '',
legalMan: it.legalMan || '--', legalMan: it.legalMan || '--',
email: it.email || '', linkMan: it.linkMan || '--',
enterpriseAddress: it.enterpriseAddress || '', linkMobile: it.linkMobile || '--',
mainProducts: it.mainProducts || '', enterpriseAddress: it.enterpriseAddress || '--',
registerTime: it.registerTime || '', mainProducts: it.mainProducts || '--',
orgNo: it.orgNo || '', registerTime: it.registerTime || '--',
failReason: it.failReason || '' status: it.status ?? 0,
registerNo: it.orgNo || null,
email: it.email || '--',
raw: it
})) }))
total.value = data.total || 0 total.value = data.total || 0
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.msg || e?.message || '加载企业列表失败') loadError.value = e?.msg || e?.message || '加载失败'
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function onSearch() { page.value = 1; load() } function onSearch() { page.value = 1; load() }
function onReset() { function onReset() { filters.enterpriseName = ''; filters.enterpriseType = ''; page.value = 1; load() }
filters.enterpriseName = ''; filters.industry = ''; filters.supplierLevel = ''
filters.salesperson = ''; filters.supplierLabel = ''; filters.status = ''; filters.homeShow = ''
page.value = 1; load()
}
function onPageChange(p: number) { page.value = p; load() } function onPageChange(p: number) { page.value = p; load() }
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() } function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
function onSelectionChange(rows: any[]) { selectedIds.value = rows.map((r: any) => r.id) }
// ========== 批量操作 ==========
function importHandle() { ElMessage.info('导入功能待实现') }
function batchAuditHandle() {
if (!selectedIds.value.length) { ElMessage.warning('请先选择企业'); return }
ElMessage.info('批量审核功能待实现')
}
function batchLabelHandle() {
if (!selectedIds.value.length) { ElMessage.warning('请先选择企业'); return }
ElMessage.info('批量设置标签待实现')
}
function batchAssignSalesperson() {
if (!selectedIds.value.length) { ElMessage.warning('请先选择企业'); return }
ElMessage.info('分配业务员待实现')
}
async function deleteHandle() {
if (!selectedIds.value.length) { ElMessage.warning('请先选择企业'); return }
try { await ElMessageBox.confirm(`确认删除 ${selectedIds.value.length} 家企业?`, '提示') } catch { return }
let ok = 0
for (const id of selectedIds.value) {
try { await httpDelete('/user/enterprise', { ids: id }); ok++ } catch {/* */}
}
ElMessage.success(`删除 ${ok} 家企业`); load()
}
function exportSupplierDetail() { ElMessage.info('导出待实现') }
// ========== 单行操作 ==========
function showDetail(row: Enterprise) { detailRow.value = row; detailVisible.value = true } function showDetail(row: Enterprise) { detailRow.value = row; detailVisible.value = true }
function changeAdminHandle(_row: Enterprise) { ElMessage.info('更换管理员待实现') }
async function handleMore(cmd: string, row: Enterprise) { onMounted(() => { load() })
try {
switch (cmd) {
case 'audit': ElMessage.info('审核待实现'); break
case 'label': ElMessage.info('标签待实现'); break
case 'rating': ElMessage.info('评级待实现'); break
case 'enabled':
await httpPost('/user/enterprise/enabled', { id: row.id, enabled: row.enabled === 1 ? 0 : 1 })
ElMessage.success(row.enabled === 1 ? '已禁用' : '已启用'); load()
break
case 'showFlag':
await httpPost('/user/enterprise/showEnterprise', { id: row.id, showFlag: row.showFlag === 1 ? 0 : 1 })
ElMessage.success(row.showFlag === 1 ? '已显示' : '已隐藏'); load()
break
case 'homeShow':
await httpPost('/user/enterprise/homeShow', { id: row.id, homeShow: row.homeShow === 1 ? 0 : 1 })
ElMessage.success(row.homeShow === 1 ? '已取消推荐' : '已推荐'); load()
break
case 'assign': ElMessage.info('分配业务员待实现'); break
case 'deleteRow':
try { await ElMessageBox.confirm('确认删除该企业?', '提示') } catch { return }
await httpDelete('/user/enterprise', { ids: row.id })
ElMessage.success('已删除'); load()
break
}
} catch {/* */}
}
onMounted(() => load())
</script> </script>
<template> <template>
<div class="rr-page"> <div class="rr-sys-user">
<PageHeader title="企业列表" :sub="`共 ${total} 家企业`" /> <div class="rr-page-header">
<div class="rr-page-titles">
<h2 class="rr-page-title">企业管理</h2>
<p class="rr-page-sub">共 {{ total }} 家企业</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
</div>
</div>
<SearchBar <div class="rr-filters-row">
v-model:filters="filters" <el-input v-model="filters.enterpriseName" placeholder="企业名搜索" clearable class="rr-username-input" size="small" @keyup.enter="onSearch" />
:fields="[ <el-select v-model="filters.enterpriseType" placeholder="企业类型" class="rr-type-select" clearable size="small">
{ key: 'enterpriseName', label: '企业名称', type: 'text', placeholder: '企业名称' }, <el-option value="1" label="供应商" />
{ key: 'industry', label: '行业类型', type: 'text', placeholder: '行业类型' }, <el-option value="2" label="采购商" />
{ key: 'supplierLevel', label: '供应商等级', type: 'select', placeholder: '供应商等级', </el-select>
options: [ <el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
{ value: 1, label: '优质供应商' }, </div>
{ value: 2, label: '钻石供应商' },
{ value: 3, label: '金牌供应商' },
{ value: 4, label: '银牌供应商' },
{ value: 5, label: '铜牌供应商' }
]
}
]"
@search="onSearch"
@reset="onReset"
/>
<div class="rr-list-card"> <div class="rr-list-card">
<!-- 批量操作栏 -->
<div class="rr-batch-bar">
<el-button size="small" type="success" @click="importHandle">导入</el-button>
<el-button size="small" type="danger" @click="batchAuditHandle">批量审核</el-button>
<el-button size="small" @click="batchLabelHandle">批量设置标签</el-button>
<el-button size="small" type="warning" @click="batchAssignSalesperson">批量分配业务员</el-button>
<el-button size="small" type="danger" @click="deleteHandle">删除</el-button>
<el-button size="small" type="primary" @click="exportSupplierDetail">导出供应商信息明细</el-button>
</div>
<DataTable <DataTable
:columns="[ :columns="[
{ type: 'selection', width: 48, label: '' }, { prop: 'enterpriseName', label: '企业名称', width: 220 },
{ label: '企业名称', minWidth: 220, slot: 'enterpriseName' }, { prop: 'enterpriseType', label: '类型', width: 100, slot: 'enterpriseType' },
{ label: '企业管理员/手机号', minWidth: 200, slot: 'adminInfo' }, { prop: 'legalMan', label: '法人', width: 120 },
{ label: '行业类型/供应商等级', minWidth: 200, slot: 'levelInfo' }, { prop: 'linkMan', label: '联系人', width: 120 },
{ prop: 'supplierLabel', label: '标签', width: 120, align: 'center' }, { prop: 'linkMobile', label: '联系电话', width: 130 },
{ label: '业务员', minWidth: 160, slot: 'salespersonInfo' }, { prop: 'enterpriseAddress', label: '地址', minWidth: 180 },
{ label: '状态', width: 100, slot: 'status' }, { prop: 'registerTime', label: '注册时间', width: 170, slot: 'registerTime' },
{ label: '入驻时间', width: 110, slot: 'createDate' }, { label: '操作', width: 100, slot: 'action' }
{ label: '操作', width: 200, slot: 'action', fixed: 'right', align: 'center' }
]" ]"
:data="list" :data="list"
:loading="loading" :loading="loading"
:total="total" :total="total"
:page="page" :page="page"
:limit="limit" :limit="limit"
:borderless="true" :show-pagination="true"
:page-sizes="[10, 20, 50, 100]" :error="loadError"
row-key="id"
empty-text="暂无企业" empty-text="暂无企业"
row-key="id"
@page-change="onPageChange" @page-change="onPageChange"
@limit-change="onLimitChange" @limit-change="onLimitChange"
@selection-change="onSelectionChange"
> >
<!-- 企业名称(带头像) --> <template #enterpriseType="{ row }">
<template #enterpriseName="{ row, index }"> <el-tag v-if="TYPE_MAP[row.enterpriseType]" :type="TYPE_MAP[row.enterpriseType].type as any" size="small" effect="light">
<div style="display:flex;align-items:center;gap:8px"> {{ TYPE_MAP[row.enterpriseType].label }}
<span :style="{ display:'inline-flex',alignItems:'center',justifyContent:'center', </el-tag>
width:'32px',height:'32px',borderRadius:'50%',flexShrink:0,fontSize:'14px',fontWeight:500,
background:index%2===0?'#fce4ec':'#e3f2fd',color:index%2===0?'#c62828':'#1565c0'
}">{{ initial(row.enterpriseName) }}</span>
<span style="font-weight:500">{{ row.enterpriseName }}</span>
</div>
</template>
<!-- 企业管理员/手机号(两行) -->
<template #adminInfo="{ row }">
<div style="display:flex;flex-direction:column;gap:2px">
<span>{{ row.administrator }}</span>
<span style="font-size:12px;color:var(--ink-3)">{{ maskMobile(row.administratorMobile) }}</span>
</div>
</template>
<!-- 行业类型/供应商等级(两行) -->
<template #levelInfo="{ row }">
<div style="display:flex;flex-direction:column;gap:2px">
<span>{{ row.industry || '-' }}</span>
<span style="font-size:12px;color:var(--ink-3)">{{ LEVEL_MAP[row.supplierLevel as number] || '-' }}</span>
</div>
</template>
<!-- 业务员 -->
<template #salespersonInfo="{ row }">
<div v-if="row.salesperson" style="font-size:12px;color:var(--ink-3)">
{{ row.salesperson }}({{ maskMobile(row.salespersonMobile) }})
</div>
<div v-else style="font-size:12px;color:var(--ink-3);font-style:italic">暂未分配</div>
</template>
<!-- 状态标签 -->
<template #status="{ row }">
<span v-if="STATUS_MAP[String(row.status)]" :class="['rr-status-tag', STATUS_CLASS[String(row.status)] || '']">
{{ STATUS_MAP[String(row.status)] }}
</span>
<span v-else class="rr-muted">--</span> <span v-else class="rr-muted">--</span>
</template> </template>
<template #registerTime="{ row }">
<!-- 入驻时间 --> <span class="rr-time">{{ fmtDate(row.registerTime) }}</span>
<template #createDate="{ row }">
<span style="font-family:'SF Mono',Menlo,monospace;font-size:12px">{{ fmtDate(row.createDate) }}</span>
</template> </template>
<!-- 操作列 -->
<template #action="{ row }"> <template #action="{ row }">
<div style="display:flex;align-items:center;gap:2px"> <el-button text type="primary" size="small" @click="showDetail(row)">详情</el-button>
<el-button type="primary" size="small" text @click="showDetail(row)">详情</el-button>
<el-button type="primary" size="small" text @click="changeAdminHandle(row)">更换管理员</el-button>
<el-dropdown trigger="click" placement="bottom-end" @command="(cmd:string) => handleMore(cmd, row)">
<el-button type="primary" size="small" text><el-icon><MoreFilled /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="row.status===1" command="audit">审核</el-dropdown-item>
<el-dropdown-item command="label">标签</el-dropdown-item>
<el-dropdown-item command="rating">评级</el-dropdown-item>
<el-dropdown-item command="enabled">{{ row.enabled===1?'启用':'禁用' }}</el-dropdown-item>
<el-dropdown-item command="showFlag">{{ row.showFlag===1?'显示企业':'隐藏企业' }}</el-dropdown-item>
<el-dropdown-item command="homeShow">{{ row.homeShow===1?'取消推荐':'推荐显示' }}</el-dropdown-item>
<el-dropdown-item command="assign">分配业务员</el-dropdown-item>
<el-dropdown-item command="deleteRow">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template> </template>
</DataTable> </DataTable>
</div> </div>
<!-- 详情弹窗 (EnterpriseDTO 全字段) --> <!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="企业详情" width="700px" :close-on-click-modal="false" destroy-on-close> <el-dialog v-model="detailVisible" title="企业详情" width="600" :style="{ borderRadius: '12px', overflow: 'hidden' }" align-center destroy-on-close>
<template v-if="detailRow"> <template v-if="detailRow">
<el-descriptions :column="2" border size="small"> <el-descriptions :column="2" border size="default">
<el-descriptions-item label="企业名称">{{ detailRow.enterpriseName }}</el-descriptions-item> <el-descriptions-item label="企业名称">{{ detailRow.enterpriseName }}</el-descriptions-item>
<el-descriptions-item label="管理员">{{ detailRow.administrator }}</el-descriptions-item> <el-descriptions-item label="类型">{{ TYPE_MAP[detailRow.enterpriseType]?.label || '--' }}</el-descriptions-item>
<el-descriptions-item label="管理员手机">{{ maskMobile(detailRow.administratorMobile) }}</el-descriptions-item> <el-descriptions-item label="法人">{{ detailRow.legalMan }}</el-descriptions-item>
<el-descriptions-item label="行业">{{ detailRow.industry || '-' }}</el-descriptions-item> <el-descriptions-item label="邮箱">{{ detailRow.email }}</el-descriptions-item>
<el-descriptions-item label="供应商等级">{{ LEVEL_MAP[detailRow.supplierLevel as number] || '-' }}</el-descriptions-item> <el-descriptions-item label="联系人">{{ detailRow.linkMan }}</el-descriptions-item>
<el-descriptions-item label="标签">{{ detailRow.supplierLabel || '-' }}</el-descriptions-item> <el-descriptions-item label="联系电话">{{ detailRow.linkMobile }}</el-descriptions-item>
<el-descriptions-item label="业务员">{{ detailRow.salesperson || '暂未分配' }}</el-descriptions-item> <el-descriptions-item label="地址" :span="2">{{ detailRow.enterpriseAddress }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ STATUS_MAP[String(detailRow.status)] || '-' }}</el-descriptions-item> <el-descriptions-item label="主营产品" :span="2">{{ detailRow.mainProducts }}</el-descriptions-item>
<el-descriptions-item v-if="detailRow.status===3" label="驳回原因" :span="2">{{ detailRow.failReason || '-' }}</el-descriptions-item>
<el-descriptions-item label="入驻时间">{{ fmtDate(detailRow.createDate) }}</el-descriptions-item>
<el-descriptions-item label="法人代表">{{ detailRow.legalMan }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ maskMobile(detailRow.linkMobile) }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ detailRow.email || '-' }}</el-descriptions-item>
<el-descriptions-item label="地址" :span="2">{{ detailRow.enterpriseAddress || '-' }}</el-descriptions-item>
<el-descriptions-item label="主营产品" :span="2">{{ detailRow.mainProducts || '-' }}</el-descriptions-item>
<el-descriptions-item label="工商号">{{ detailRow.orgNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册时间">{{ fmtDate(detailRow.registerTime) }}</el-descriptions-item> <el-descriptions-item label="注册时间">{{ fmtDate(detailRow.registerTime) }}</el-descriptions-item>
<el-descriptions-item label="工商号">{{ detailRow.registerNo || '--' }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</template> </template>
<template #footer> <template #footer>
<el-button @click="detailVisible=false">关闭</el-button> <el-button @click="detailVisible = false">关闭</el-button>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped>
.rr-page { padding: 16px; } .rr-sys-user { padding: 16px; }
.rr-list-card { .rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
background: var(--surface); border-radius: 12px; overflow: hidden; .rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
} .rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.rr-batch-bar { .rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
display: flex; gap: 8px; flex-wrap: wrap; padding: 12px 16px; .rr-page-actions { display: flex; gap: 8px; }
border-bottom: 1px solid var(--border); .rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
} .rr-username-input { width: 240px; }
.rr-status-tag { .rr-type-select { width: 140px; }
display: inline-block; padding: 2px 10px; border-radius: 4px; font-size: 12px; font-weight: 500; .rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
} .rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
.rr-tag-warn { background: #fff3e0; color: #e65100; } .rr-muted { color: var(--el-text-color-placeholder); font-size: 12px; }
.rr-tag-ok { background: #e8f5e9; color: #2e7d32; }
.rr-tag-err { background: #fce4ec; color: #c62828; }
.rr-muted { color: var(--ink-3); font-size: 12px; }
</style> </style>

View File

@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 财务管理 (余额明细) - W10 A5 标准化 * F006 财务管理 — 余额明细
*
* W10 A5: 套 PageHeader/SearchBar 标准化模板
* *
* 后端端点 (BalanceDetailController @ /user/balancedetail): * 后端端点 (BalanceDetailController @ /user/balancedetail):
* GET /user/balancedetail/page 分页列表 * GET /user/balancedetail/page 分页列表
@ -11,8 +9,6 @@
import { onMounted, ref, reactive } from 'vue' import { onMounted, ref, reactive } from 'vue'
import { httpGet } from '@/utils/request' import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface BalanceDetail { interface BalanceDetail {
id: string id: string
@ -102,24 +98,29 @@ onMounted(() => { load() })
</script> </script>
<template> <template>
<div class="rr-page"> <div class="rr-sys-user">
<PageHeader title="财务管理" :sub="`共 ${total} 条明细`" /> <div class="rr-page-header">
<div class="rr-page-titles">
<h2 class="rr-page-title">财务管理</h2>
<p class="rr-page-sub">共 {{ total }} 条明细</p>
</div>
<div class="rr-page-actions">
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
</div>
</div>
<SearchBar <div class="rr-filters-row">
v-model:filters="filters" <el-select v-model="filters.type" placeholder="类型筛选" class="rr-type-select" clearable size="small">
:fields="[ <el-option value="1" label="收入" />
{ key: 'type', label: '类型', type: 'select', options: [ <el-option value="2" label="支出" />
{ value: 1, label: '收入' }, <el-option value="3" label="提现" />
{ value: 2, label: '支出' }, <el-option value="4" label="冻结" />
{ value: 3, label: '提现' }, </el-select>
{ value: 4, label: '冻结' } <el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
] } </div>
]"
@search="onSearch"
@reset="onReset"
/>
<DataTable <div class="rr-list-card">
<DataTable
:columns="[ :columns="[
{ prop: 'enterpriseName', label: '企业', width: 180 }, { prop: 'enterpriseName', label: '企业', width: 180 },
{ prop: 'type', label: '类型', width: 90, slot: 'type' }, { prop: 'type', label: '类型', width: 90, slot: 'type' },
@ -137,8 +138,6 @@ onMounted(() => { load() })
:show-pagination="true" :show-pagination="true"
:error="loadError" :error="loadError"
empty-text="暂无明细" empty-text="暂无明细"
:borderless="true"
:page-sizes="[10, 20, 50, 100]"
row-key="id" row-key="id"
@page-change="onPageChange" @page-change="onPageChange"
@limit-change="onLimitChange" @limit-change="onLimitChange"
@ -159,12 +158,21 @@ onMounted(() => { load() })
<span class="rr-time">{{ fmtDate(row.createDate) }}</span> <span class="rr-time">{{ fmtDate(row.createDate) }}</span>
</template> </template>
</DataTable> </DataTable>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.rr-page { max-width: 1500px; } .rr-sys-user { padding: 16px; }
.rr-money { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 13px; font-weight: 500; color: var(--ink); } .rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--ink-2); } .rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
.rr-muted { color: var(--ink-3); font-size: 12px; } .rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
.rr-page-actions { display: flex; gap: 8px; }
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
.rr-type-select { width: 140px; }
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
.rr-money { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 13px; font-weight: 500; color: var(--el-text-color-primary); }
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
.rr-muted { color: var(--el-text-color-placeholder); font-size: 12px; }
</style> </style>

View File

@ -1,289 +0,0 @@
<script setup lang="ts">
/**
* F006 用户表单弹窗 (新建/修改 共用) - W17.4.4 优化
*
* 后端:
* - 新建: POST /user/user/save (UserDTO body)
* - 详情: GET /user/user/getUserInfo?id=...
* - 修改: POST /user/user/update (UserFeignDTO body, 字段子集)
*
* 字段 (W17.4.4 优化):
* nickName 昵称 (必填)
* mobile 手机号 (必填, 11 位)
* password 密码 (新建必填, 修改可选-留空表示不修改)
* sex 性别 0男1女 (选填)
* enabled 禁用标识 0正常1禁用 (选填, 默认正常)
* enterpriseId 关联企业 (单选, 取 tb_user.enterpriseId; 真多对多需后端加 user_enterprise_bind 表, 当前未做)
*
* 已去掉:
* - header (头像 URL): 改用默认头像, 此处无意义
* - isEnterprise (是否企业认证): 由企业认证流程自动带过来, 不允许手工改
*/
import { ref, reactive, watch, computed, nextTick, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { httpGet, httpPost } from '@/utils/request'
interface UserForm {
id: number | null
nickName: string
mobile: string
password: string
sex: '' | '0' | '1'
enterpriseId: number | null
enabled: '' | '0' | '1'
}
interface EnterpriseOpt { id: number; name: string }
const props = defineProps<{
visible: boolean
userId: number | string | null
}>()
const emit = defineEmits<{
(e: 'update:visible', v: boolean): void
(e: 'success'): void
}>()
const mode = computed<'create' | 'update'>(() => (props.userId ? 'update' : 'create'))
const loading = ref(false)
const submitting = ref(false)
const formRef = ref()
const enterpriseOptions = ref<EnterpriseOpt[]>([])
const enterpriseLoading = ref(false)
const form = reactive<UserForm>({
id: null,
nickName: '',
mobile: '',
password: '',
sex: '',
enterpriseId: null,
enabled: '0',
})
const rules = {
nickName: [{ required: true, message: '请输入用户昵称', trigger: 'blur' }],
mobile: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
],
password: [
{
validator(_: any, value: string, cb: (err?: Error) => void) {
if (mode.value === 'create' && !value) {
return cb(new Error('新建用户必须输入密码'))
}
if (value && (value.length < 6 || value.length > 20)) {
return cb(new Error('密码长度 6-20 位'))
}
cb()
},
trigger: 'blur'
}
]
}
watch(
() => props.visible,
async (v) => {
if (!v) return
resetForm()
await loadEnterprises()
if (props.userId) {
await loadDetail()
}
}
)
function resetForm() {
form.id = null
form.nickName = ''
form.mobile = ''
form.password = ''
form.sex = ''
form.enterpriseId = null
form.enabled = '0'
}
/**
* 加载企业下拉选项 (弹窗打开时拉一次, 后续复用)
*
* 实现: 从 /user/user/page 用户列表聚合 (enterpriseId, enterpriseName) 去重
* 原因: gateway.json 当前没配 /enterprise/** 路由, /enterprise/list 返 404;
* 而 user-server jar 内 ApiEnterpriseController 也没正确暴露路径
* (推测 jar 是旧版本编译, controller 路径没生效)
* 替代: 用户表里有 enterpriseId + enterpriseName 字段, 直接聚合即可
* 兜底: 后端失败时给空数组
*/
async function loadEnterprises() {
if (enterpriseOptions.value.length > 0) return
enterpriseLoading.value = true
try {
const data: any = await httpGet('/user/user/page', { page: 1, limit: 10000 })
const arr = Array.isArray(data?.list) ? data.list : (Array.isArray(data) ? data : [])
const seen = new Map<number, string>()
for (const u of arr) {
const eid = Number(u.enterpriseId ?? 0)
if (eid > 0 && !seen.has(eid)) {
seen.set(eid, u.enterpriseName || `企业 #${eid}`)
}
}
enterpriseOptions.value = Array.from(seen.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.id - b.id)
} catch {
enterpriseOptions.value = []
} finally {
enterpriseLoading.value = false
}
}
async function loadDetail() {
loading.value = true
try {
const data: any = await httpGet('/user/user/getUserInfo', { id: props.userId })
if (data) {
form.id = Number(data.id ?? props.userId)
form.nickName = data.nickName || ''
form.mobile = data.mobile || ''
form.password = ''
form.sex = (data.sex === 0 || data.sex === 1 ? String(data.sex) : '') as UserForm['sex']
form.enterpriseId = data.enterpriseId ? Number(data.enterpriseId) : null
form.enabled = (data.enabled === 0 || data.enabled === 1 ? String(data.enabled) : '0') as UserForm['enabled']
}
await nextTick()
formRef.value?.clearValidate()
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '加载用户详情失败')
close()
} finally {
loading.value = false
}
}
function close() {
emit('update:visible', false)
}
async function submit() {
try {
await formRef.value?.validate()
} catch {
return
}
submitting.value = true
try {
const payload: Record<string, any> = {
id: form.id,
nickName: form.nickName.trim(),
mobile: form.mobile.trim()
}
if (form.password) payload.password = form.password
if (form.sex !== '') payload.sex = Number(form.sex)
if (form.enterpriseId) payload.enterpriseId = Number(form.enterpriseId)
if (form.enabled !== '') payload.enabled = Number(form.enabled)
if (mode.value === 'create') {
await httpPost('/user/user/save', payload)
ElMessage.success('新建成功')
} else {
await httpPost('/user/user/update', payload)
ElMessage.success('修改成功')
}
emit('success')
close()
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || (mode.value === 'create' ? '新建失败' : '修改失败'))
} finally {
submitting.value = false
}
}
</script>
<template>
<el-dialog
:model-value="visible"
:title="mode === 'create' ? '新建用户' : '修改用户'"
width="560"
:style="{ borderRadius: '12px', overflow: 'hidden' }"
align-center
destroy-on-close
:close-on-click-modal="false"
@update:model-value="(v: boolean) => emit('update:visible', v)"
@closed="resetForm"
>
<div v-loading="loading">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
label-position="right"
>
<el-form-item label="用户昵称" prop="nickName">
<el-input v-model="form.nickName" placeholder="请输入用户昵称" maxlength="50" show-word-limit clearable />
</el-form-item>
<el-form-item label="手机号" prop="mobile">
<el-input v-model="form.mobile" placeholder="请输入手机号" maxlength="11" clearable />
</el-form-item>
<el-form-item label="登录密码" prop="password">
<el-input
v-model="form.password"
type="password"
show-password
:placeholder="mode === 'create' ? '请输入密码 (6-20 位)' : '留空表示不修改密码'"
maxlength="20"
clearable
/>
</el-form-item>
<el-form-item label="性别">
<el-radio-group v-model="form.sex">
<el-radio value="">未知</el-radio>
<el-radio value="0">男</el-radio>
<el-radio value="1">女</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="关联企业">
<el-select
v-model="form.enterpriseId"
placeholder="请选择关联企业 (可选)"
clearable
filterable
:loading="enterpriseLoading"
style="width: 100%"
>
<el-option
v-for="opt in enterpriseOptions"
:key="opt.id"
:label="opt.name"
:value="opt.id"
/>
</el-select>
</el-form-item>
<el-form-item label="禁用标识">
<el-radio-group v-model="form.enabled">
<el-radio value="0">正常</el-radio>
<el-radio value="1">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">
{{ mode === 'create' ? '确认新建' : '确认修改' }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
</style>

View File

@ -1,141 +1,57 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* F006 用户管理 (前台用户列表) - W17 设计规范 v2 完整版 * F006 用户管理 (前台用户列表) - W3.x C P0.3
* 走 /user/user/page 后端
* *
* 按 ui-preview-v2.html 模板: 9 列 + 6 搜索 + 4 操作 + 状态 tab * 字段 (从 API 实测):
* id, nickName, mobile, header (头像 URL)
* total=2284 真实用户
*
* 注意: 老 sys/UserListView 是后台管理员 (/sys/user/*), 这页是前台用户
*/ */
import { onMounted, ref, reactive, computed } from 'vue' import { onMounted, ref, reactive } from 'vue'
import { http, httpGet, httpPost } from '@/utils/request' import { httpGet } from '@/utils/request'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import DataTable from '@/components/DataTable/DataTable.vue' import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import ListTabs from '@/components/ListTabs/ListTabs.vue'
import UserFormDialog from './UserFormDialog.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
interface FrontUser { interface FrontUser {
id: string id: string
nickName: string nickName: string
mobile: string mobile: string
header?: string | null header?: string | null
isEnterprise: number
enterpriseName?: string | null
enterpriseId?: string | null
accountBalance: number
enabled: number
createDate: number
isDel: number
raw: any raw: any
} }
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 dialogVisible = ref(false)
const editingUserId = ref<string | number | null>(null)
const exporting = ref(false)
const query = reactive({ const query = reactive({
page: 1, page: 1,
limit: 10, limit: 20,
userId: '', keyword: ''
nickName: '',
mobile: '',
enterpriseName: '',
isEnterprise: '' as '' | '0' | '1',
enabled: '' as '' | '0' | '1'
}) })
const columns: any[] = [ const columns = [
{ type: 'selection', width: 50, fixed: 'left', align: 'center' }, { prop: 'id', label: 'ID', width: 80 },
{ prop: 'id', label: '用户ID', width: 100, align: 'center' }, { prop: 'nickName', label: '昵称', minWidth: 160 },
{ prop: 'nickName', label: '用户昵称', minWidth: 160, align: 'center' }, { prop: 'mobile', label: '手机号', width: 140 },
{ prop: 'mobile', label: '手机号', width: 140, align: 'center' },
{ {
prop: 'isEnterprise', prop: 'header',
label: '是否企业认证', label: '头像',
width: 140, width: 80,
align: 'center', formatter: (row: FrontUser) => row.header ? '有' : '无'
formatter: (row: FrontUser) => row.isEnterprise === 1 ? '是' : '否' }
},
{
prop: 'enterpriseName',
label: '关联企业',
minWidth: 200,
align: 'center',
formatter: (row: FrontUser) => row.enterpriseName || '--'
},
{
prop: 'accountBalance',
label: '平台账户余额',
width: 140,
align: 'right',
formatter: (row: FrontUser) => Number(row.accountBalance || 0).toFixed(2)
},
{
prop: 'enabled',
label: '禁用标识',
width: 110,
align: 'center',
slot: 'enabled'
},
{
prop: 'createDate',
label: '注册时间',
width: 180,
align: 'center',
formatter: (row: FrontUser) => fmtDate(row.createDate)
},
{ label: '操作', width: 140, slot: 'action', fixed: 'right', align: 'center' }
] ]
function fmtDate(ts: number | null | undefined) { async function load() {
if (!ts) return '--'
const d = new Date(Number(ts))
if (isNaN(d.getTime())) return '--'
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/**
* 构建除 enabled (tab) 外的搜索参数 (用于 stats 查询)
* stats 必须与 tab 解耦, 只反映当前搜索条件下的全量计数
*/
function buildSearchParams() {
const params: any = {}
if (query.userId) params.id = query.userId
if (query.nickName) params.nickName = query.nickName
if (query.mobile) params.mobile = query.mobile
if (query.enterpriseName) params.enterpriseName = query.enterpriseName
if (query.isEnterprise !== '') params.isEnterprise = Number(query.isEnterprise)
return params
}
async function loadList() {
loading.value = true loading.value = true
try { try {
const params: any = { page: query.page, limit: query.limit, ...buildSearchParams() } const resp: any = await httpGet('/user/user/page', { page: query.page, limit: query.limit })
if (query.enabled !== '') params.enabled = Number(query.enabled)
const resp: any = await httpGet('/user/user/page', params)
if (resp && Array.isArray(resp.list)) { if (resp && Array.isArray(resp.list)) {
list.value = resp.list.map((it: any) => ({ list.value = resp.list.map((it: any) => ({
id: it.id, id: it.id,
nickName: it.nickName || '--', nickName: it.nickName || '--',
mobile: it.mobile || '--', mobile: it.mobile || '--',
header: it.header || null, header: it.header || null,
isEnterprise: Number(it.isEnterprise ?? 0),
enterpriseName: it.enterpriseName || null,
enterpriseId: it.enterpriseId || null,
accountBalance: Number(it.accountBalance ?? 0),
enabled: Number(it.enabled ?? 0),
createDate: Number(it.createDate ?? 0),
isDel: Number(it.isDel ?? 0),
raw: it raw: it
})) }))
total.value = Number(resp.total || 0) total.value = Number(resp.total || 0)
@ -143,221 +59,55 @@ async function loadList() {
list.value = [] list.value = []
total.value = 0 total.value = 0
} }
} catch (e: any) { } catch (e) {
list.value = [] list.value = []
total.value = 0 total.value = 0
ElMessage.error(e?.msg || e?.message || '加载失败')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
/**
* 加载 stats (normal/disabled 全量计数)
* 与 tab 无关, 只跟搜索条件 (userId/nickName/mobile/isEnterprise/enterpriseName) 关联
* 通过 limit=1 + page=1 拿到 total (后端只 count 不返列表, 开销可忽略)
*/
async function loadStats() {
try {
const baseParams = buildSearchParams()
const [respNormal, respDisabled] = await Promise.all([
httpGet('/user/user/page', { ...baseParams, enabled: 0, page: 1, limit: 1 }),
httpGet('/user/user/page', { ...baseParams, enabled: 1, page: 1, limit: 1 })
])
enabledStats.normal = Number(respNormal?.total ?? 0)
enabledStats.disabled = Number(respDisabled?.total ?? 0)
} catch {
enabledStats.normal = 0
enabledStats.disabled = 0
}
}
async function load() {
await Promise.all([loadList(), loadStats()])
}
const tabItems = computed(() => [
// 全部 = 正常 + 已禁用 (跨 tab 一致, 与搜索条件关联)
{ key: 'all', label: '全部', count: enabledStats.normal + enabledStats.disabled },
{ key: 'normal', label: '正常', count: enabledStats.normal },
{ key: 'disabled', label: '已禁用', count: enabledStats.disabled }
])
function onTabChange(key: string) {
// 后端 enabled 语义: 0=正常 1=禁用
if (key === 'all') query.enabled = ''
else if (key === 'normal') query.enabled = '0'
else if (key === 'disabled') query.enabled = '1'
query.page = 1
loadList() // 切 tab 只重载列表, stats 已与 tab 解耦
}
function onPageChange(p: number) { query.page = p; load() } function onPageChange(p: number) { query.page = p; load() }
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
function onSearch() { query.page = 1; load() } function onSearch() { query.page = 1; load() }
function onReset() {
query.userId = ''; query.nickName = ''; query.mobile = ''
query.enterpriseName = ''; query.isEnterprise = ''; query.enabled = ''
activeTab.value = 'all'
query.page = 1
load()
}
function onEdit(row: FrontUser) {
editingUserId.value = row.id
dialogVisible.value = true
}
function onCreate() {
editingUserId.value = null
dialogVisible.value = true
}
function onDialogSuccess() {
load()
}
/**
* 导出当前搜索条件下的用户列表为 Excel
* 后端: GET /user/user/export (返 Excel 文件流, Content-Type: application/vnd.ms-excel)
* 走 http 实例 (自动带 Bearer token), responseType: 'blob' 接二进制流
*/
async function exportExcel() {
exporting.value = true
try {
const params = buildSearchParams()
if (query.enabled !== '') params.enabled = Number(query.enabled)
const resp = await http.get<Blob>('/user/user/export', {
params,
responseType: 'blob'
})
const blob = resp.data
if (!blob || blob.size === 0) {
ElMessage.error('导出内容为空')
return
}
// 优先从响应头取文件名 (后端 ExcelUtils 会设置)
const dispo = resp.headers?.['content-disposition'] || resp.headers?.['Content-Disposition']
let filename = `用户列表_${new Date().toISOString().slice(0, 10)}.xls`
if (typeof dispo === 'string') {
const m = dispo.match(/filename\*?=(?:UTF-8\u0027\u0027)?["']?([^;"']+)["']?/i)
if (m && m[1]) {
try { filename = decodeURIComponent(m[1]) } catch { filename = m[1] }
}
}
const url = URL.createObjectURL(new Blob([blob]))
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success(`已导出 ${filename}`)
} catch (e: any) {
const msg = e?.msg || e?.message || '导出失败'
ElMessage.error(msg)
} finally {
exporting.value = false
}
}
async function toggleEnabled(row: FrontUser) {
// 当前 enabled: 0=正常 1=禁用; 要切换到对端
const next = row.enabled === 0 ? 1 : 0
const action = next === 1 ? '禁用' : '启用'
try {
await ElMessageBox.confirm(`确认${action}用户「${row.nickName}」?`, `${action}确认`, { type: 'warning' })
} catch { return }
try {
await httpPost('/user/user/update', { id: row.id, enabled: next })
ElMessage.success(`${action}成功`)
row.enabled = next
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || `${action}失败`)
}
}
async function deleteUser(row: FrontUser) {
try {
await ElMessageBox.confirm(`确认删除用户「${row.nickName}」?该操作不可恢复`, '删除确认', { type: 'warning' })
} catch { return }
try {
await httpPost('/user/user/delete', { id: row.id })
ElMessage.success('删除成功')
load()
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '删除失败')
}
}
onMounted(load) onMounted(load)
</script> </script>
<template> <template>
<div class="rr-page"> <div class="rr-page">
<PageHeader title="用户列表" :sub="`管理前台注册用户,共 ${enabledStats.normal + enabledStats.disabled} 个账号`"> <div class="rr-page-header">
<el-button @click="exportExcel" :loading="exporting">导出</el-button> <div>
<el-button type="primary" @click="onCreate"> <h2 class="rr-page-title">用户列表</h2>
<el-icon style="margin-right: 4px; vertical-align: -2px;"><Plus /></el-icon> <p class="rr-page-sub">前台注册用户(共 {{ total }} 个)</p>
新建用户 </div>
</el-button> </div>
</PageHeader>
<UserFormDialog
v-model:visible="dialogVisible"
:user-id="editingUserId"
@success="onDialogSuccess"
/>
<ListTabs v-model="activeTab" :items="tabItems" @change="onTabChange" /> <div class="rr-card">
<div class="rr-toolbar">
<SearchBar <el-input v-model="query.keyword" placeholder="搜索手机号/昵称" clearable style="width: 280px;" @keyup.enter="onSearch" />
v-model:filters="query" <el-button type="primary" :icon="'Search'" @click="onSearch">搜索</el-button>
:fields="[ </div>
{ key: 'userId', label: '用户ID', type: 'text', placeholder: '用户ID' }, <DataTable
{ key: 'nickName', label: '用户昵称', type: 'text', placeholder: '用户昵称' }, :columns="columns"
{ key: 'mobile', label: '手机号', type: 'text', placeholder: '手机号' }, :data="list"
{ key: 'isEnterprise', label: '是否企业认证', type: 'select', placeholder: '所有状态', width: '160px', :loading="loading"
options: [{ label: '已认证', value: '1' }, { label: '未认证', value: '0' }] }, :total="total"
{ key: 'enterpriseName', label: '企业名称', type: 'text', placeholder: '企业名称' } :page="query.page"
]" :limit="query.limit"
@search="onSearch" :show-pagination="true"
@reset="onReset" row-key="id"
/> empty-text="暂无用户"
@page-change="onPageChange"
<DataTable />
:columns="columns" </div>
:data="list"
:loading="loading"
:total="total"
:page="query.page"
:limit="query.limit"
:page-sizes="[10, 20, 50, 100]"
:show-pagination="true"
:borderless="true"
row-key="id"
empty-text="暂无用户"
@page-change="onPageChange"
@limit-change="onLimitChange"
>
<template #enabled="{ row }">
<el-tag v-if="row.enabled === 0" 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 }">
<TableActions
:row="row"
:actions="[
{ label: '修改', type: 'primary', onClick: onEdit },
{ label: (r: any) => r.enabled === 0 ? '禁用' : '启用', type: (r: any) => r.enabled === 0 ? 'warning' : 'success', onClick: toggleEnabled },
{ label: '删除', type: 'danger', onClick: deleteUser }
]"
/>
</template>
</DataTable>
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.rr-page { max-width: 100%; } .rr-page { max-width: 1400px; }
.rr-page-header { margin-bottom: 20px; }
.rr-page-title { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; color: var(--ink); margin-bottom: 4px; }
.rr-page-sub { color: var(--ink-3); font-size: 13px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
.rr-toolbar { display: flex; align-items: center; gap: 8px; padding: 14px 20px; border-bottom: 1px solid var(--border); }
</style> </style>

View File

@ -1,106 +0,0 @@
<script setup lang="ts">
/**
* F006 企业等级管理 - W16
*
* 沿用老 vue user/enterprise/enterprise-level.vue 字段
*
* 端点: 4 URL 探测 (enterpriselevel/page, /list, /search, /queryPage); 404 → FALLBACK seed
*/
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.vue'
import TableActions from '@/components/TableActions/TableActions.vue'
interface LevelRow { id:string; enterpriseLevel:string; scoreFrom:number; scoreTo:number; createTime:string }
const ENDPOINTS = [
'/user/enterpriseLevel/page',
'/user/enterpriseLevel/list'
]
const FALLBACK: LevelRow[] = [
{ id:'L001', enterpriseLevel:'A (战略级)', scoreFrom:90, scoreTo:100, createTime:'2026-01-05 10:00:00' },
{ id:'L002', enterpriseLevel:'B (核心级)', scoreFrom:80, scoreTo:89, createTime:'2026-01-05 10:05:00' },
{ id:'L003', enterpriseLevel:'C (成长级)', scoreFrom:70, scoreTo:79, createTime:'2026-01-05 10:10:00' },
{ id:'L004', enterpriseLevel:'D (入门级)', scoreFrom:60, scoreTo:69, createTime:'2026-01-05 10:15:00' },
{ id:'L005', enterpriseLevel:'E (观察级)', scoreFrom:0, scoreTo:59, createTime:'2026-01-05 10:20:00' }
]
type Col = { type?: 'status'|'text'|'selection'; prop?: string; label: string; width?: number; slot?: string; fixed?: 'right'|true }
const columns: Col[] = [
{ type:'selection', label:'', width:50 },
{ prop:'id', label:'ID', width:80, fixed:true },
{ prop:'enterpriseLevel', label:'等级', width:140 },
{ prop:'scoreFrom', label:'起始分', width:100 },
{ prop:'scoreTo', label:'截止分', width:100 },
{ prop:'actions', label:'操作', width:140, slot:'actions', fixed:'right' }
]
const STATUS_DEFAULTS = [
{ value:'', label:'全部' }
]
const list = ref<LevelRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const filterKw = ref(''); const statusTab = ref('')
const dataSource = ref<'api'|'mock'|'loading'>('loading'); const apiError = ref<string>('')
async function load() {
loading.value = true; dataSource.value = 'loading'
for (const ep of ENDPOINTS) {
try {
const data: any = await httpGet<any>(ep, { page: page.value, limit: limit.value })
const rows = (data?.list || []).map((it: any) => ({ ...it, raw: it }))
list.value = rows; total.value = Number(data?.total || rows.length); dataSource.value = 'api'; apiError.value = ''; loading.value = false; return
} catch {}
}
list.value = [...FALLBACK]; total.value = FALLBACK.length; dataSource.value = 'mock'; apiError.value = '后端 API 暂未实装, 已加载前端示例数据'; loading.value = false
}
const filtered = computed(() => {
const kw = filterKw.value.trim().toLowerCase(); let rs = list.value
if (kw) rs = rs.filter(r => String(r.id).includes(kw) || String(r.enterpriseLevel).includes(kw))
return rs
})
const searchFields: Array<{ key: string; label: string; type: 'text' }> = [
{ key:'id', label:'ID', type:'text' },
{ key:'enterpriseLevel', label:'等级', type:'text' }
]
const filters = reactive<Record<string, any>>({})
function onSearch() { filterKw.value = String(filters.id || filters.enterpriseLevel || ''); page.value = 1 }
function onStatusTab(v: string) { statusTab.value = v; page.value = 1 }
function onView(row: LevelRow) { ElMessage.info(`查看 ${row.id}`) }
async function onEdit(row: LevelRow) {
ElMessage.success('编辑成功 (前端示例)')
}
async function onDelete(row: LevelRow) {
try { await ElMessageBox.confirm(`确认删除等级「${row.enterpriseLevel}」?`, '提示', { type: 'warning' }) } catch { return }
ElMessage.success('已删除 (前端示例)')
}
function onExport() { ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)') }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="企业等级管理" sub="供应商等级与对应分数段配置" :data-source="dataSource" :api-error="apiError">
<el-button type="primary" @click="onExport">导出明细</el-button>
</PageHeader>
<el-tabs v-model="statusTab" @tab-change="onStatusTab" class="rr-tabs">
<el-tab-pane v-for="t in STATUS_DEFAULTS" :key="t.value" :name="t.value" :label="t.label" />
</el-tabs>
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" />
<DataTable :columns="columns" :data="filtered"
:borderless="true" :loading="loading" :page="page" :limit="limit" :total="total" row-key="id"
@page-change="(p: number) => { page = p; load() }" @limit-change="(l: number) => { limit = l; page = 1; load() }">
<template #actions="{ row }">
<TableActions
:row="row"
:actions="[
{ label: '查看', type: 'primary', onClick: onView },
{ label: '编辑', type: 'primary', onClick: onEdit },
{ label: '删除', type: 'danger', onClick: onDelete }
]"
/>
</template>
</DataTable>
</div>
</template>
<style scoped>
.rr-page { padding: 16px; }
.rr-tabs { margin-bottom: 12px; }
</style>

View File

@ -1,117 +0,0 @@
<script setup lang="ts">
/**
* F006 供应商评级管理 - W16
*
* 沿用老 vue user/enterprise/enterprise-rating.vue 字段
*
* 端点: 4 URL 探测 (enterpriserating/page, /list, /search, /queryPage); 404 → FALLBACK seed
*/
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.vue'
interface RatingRow { id:string; enterpriseId:string; enterpriseName:string; supplierLevel:string; clientScore:number; performanceScore:number; awardScore:number; finalScore:number; deadline:string }
const ENDPOINTS = [
'/user/performancescorerecord/page',
'/user/performancescorerecord/list'
]
const FALLBACK: RatingRow[] = [
{ id:'R001', enterpriseId:'s20015', enterpriseName:'坚美建筑材料有限公司', supplierLevel:'A 战略级', clientScore:96, performanceScore:92, awardScore:5, finalScore:193, deadline:'2026-06-30 23:59:59' },
{ id:'R002', enterpriseId:'s20016', enterpriseName:'华建门窗厂', supplierLevel:'B 核心级', clientScore:88, performanceScore:85, awardScore:3, finalScore:176, deadline:'2026-06-30 23:59:59' },
{ id:'R003', enterpriseId:'s20017', enterpriseName:'融鑫管业', supplierLevel:'B 核心级', clientScore:84, performanceScore:88, awardScore:0, finalScore:172, deadline:'2026-06-30 23:59:59' },
{ id:'R004', enterpriseId:'s20018', enterpriseName:'东方五金机械', supplierLevel:'A 战略级', clientScore:92, performanceScore:90, awardScore:8, finalScore:190, deadline:'2026-06-30 23:59:59' },
{ id:'R005', enterpriseId:'s20019', enterpriseName:'美洁涂料', supplierLevel:'C 成长级', clientScore:78, performanceScore:76, awardScore:-2, finalScore:152, deadline:'2026-06-30 23:59:59' },
{ id:'R006', enterpriseId:'s20020', enterpriseName:'万家灯具', supplierLevel:'B 核心级', clientScore:86, performanceScore:82, awardScore:2, finalScore:170, deadline:'2026-06-30 23:59:59' },
{ id:'R007', enterpriseId:'s20021', enterpriseName:'顺达物流', supplierLevel:'D 入门级', clientScore:65, performanceScore:68, awardScore:0, finalScore:133, deadline:'2026-06-30 23:59:59' },
{ id:'R008', enterpriseId:'s20022', enterpriseName:'众成厨具', supplierLevel:'C 成长级', clientScore:75, performanceScore:72, awardScore:-3, finalScore:144, deadline:'2026-06-30 23:59:59' },
{ id:'R009', enterpriseId:'s20023', enterpriseName:'坚美建筑材料', supplierLevel:'A 战略级', clientScore:94, performanceScore:91, awardScore:6, finalScore:191, deadline:'2026-06-30 23:59:59' },
{ id:'R010', enterpriseId:'s20024', enterpriseName:'华建门窗厂', supplierLevel:'A 战略级', clientScore:90, performanceScore:89, awardScore:4, finalScore:183, deadline:'2026-06-30 23:59:59' },
{ id:'R011', enterpriseId:'s20025', enterpriseName:'融鑫管业', supplierLevel:'C 成长级', clientScore:74, performanceScore:78, awardScore:0, finalScore:152, deadline:'2026-06-30 23:59:59' },
{ id:'R012', enterpriseId:'s20026', enterpriseName:'美洁涂料', supplierLevel:'D 入门级', clientScore:62, performanceScore:65, awardScore:-1, finalScore:126, deadline:'2026-06-30 23:59:59' }
]
type Col = { type?: 'status'|'text'|'selection'; prop?: string; label: string; width?: number; slot?: string; fixed?: 'right'|true }
const columns: Col[] = [
{ type:'selection', label:'', width:50 },
{ prop:'id', label:'评级ID', width:90, fixed:true },
{ prop:'enterpriseName', label:'供应商', width:200 },
{ prop:'supplierLevel', label:'等级', width:120, slot:'level' },
{ prop:'clientScore', label:'客户评价', width:100 },
{ prop:'performanceScore', label:'履约评价', width:100 },
{ prop:'awardScore', label:'奖惩分', width:90, slot:'award' },
{ prop:'finalScore', label:'最终得分', width:100, slot:'final' },
{ prop:'deadline', label:'评级时间', width:170, slot:'deadline' },
{ prop:'actions', label:'操作', width:180, slot:'actions', fixed:'right' }
]
const STATUS_DEFAULTS = [
{ value:'', label:'全部' }
]
const list = ref<RatingRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const filterKw = ref(''); const statusTab = ref('')
const dataSource = ref<'api'|'mock'|'loading'>('loading'); const apiError = ref<string>('')
async function load() {
loading.value = true; dataSource.value = 'loading'
for (const ep of ENDPOINTS) {
try {
const data: any = await httpGet<any>(ep, { page: page.value, limit: limit.value })
const rows = (data?.list || []).map((it: any) => ({ ...it, raw: it }))
list.value = rows; total.value = Number(data?.total || rows.length); dataSource.value = 'api'; apiError.value = ''; loading.value = false; return
} catch {}
}
list.value = [...FALLBACK]; total.value = FALLBACK.length; dataSource.value = 'mock'; apiError.value = '后端 API 暂未实装, 已加载前端示例数据'; loading.value = false
}
const filtered = computed(() => {
const kw = filterKw.value.trim().toLowerCase(); let rs = list.value
if (kw) rs = rs.filter(r => String(r.enterpriseName).includes(kw))
return rs
})
const searchFields: Array<{ key: string; label: string; type: 'text' }> = [
{ key:'enterpriseName', label:'供应商', type:'text' }
]
const filters = reactive<Record<string, any>>({})
function onSearch() { filterKw.value = String(filters.enterpriseName || ''); page.value = 1 }
function onStatusTab(v: string) { statusTab.value = v; page.value = 1 }
function onView(row: RatingRow) { ElMessage.info(`详情 ${row.id}`) }
async function onEdit(row: RatingRow) {
ElMessage.success('已发起调整 (前端示例)')
}
function onExport() { ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)') }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="供应商评级管理" sub="供应商基于多维度打分的最终评级" :data-source="dataSource" :api-error="apiError">
<el-button type="primary" @click="onExport">导出明细</el-button>
</PageHeader>
<el-tabs v-model="statusTab" @tab-change="onStatusTab" class="rr-tabs">
<el-tab-pane v-for="t in STATUS_DEFAULTS" :key="t.value" :name="t.value" :label="t.label" />
</el-tabs>
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" />
<DataTable :columns="columns" :data="filtered"
:borderless="true" :loading="loading" :page="page" :limit="limit" :total="total" row-key="id"
@page-change="(p: number) => { page = p; load() }" @limit-change="(l: number) => { limit = l; page = 1; load() }">
<template #level="{ row }">
<el-tag :type="row.supplierLevel.startsWith('A') ? 'success' : row.supplierLevel.startsWith('B') ? 'primary' : row.supplierLevel.startsWith('C') ? 'warning' : 'info'" size="small">{{ row.supplierLevel }}</el-tag>
</template>
<template #award="{ row }">
<span :style="{ color: row.awardScore > 0 ? '#67c23a' : row.awardScore < 0 ? '#f56c6c' : '#999' }">
{{ row.awardScore > 0 ? '+' : '' }}{{ row.awardScore }}
</span>
</template>
<template #final="{ row }">
<span style="font-weight: 600;">{{ row.finalScore }}</span>
</template>
<template #deadline="{ row }">{{ row.deadline }}</template>
<template #actions="{ row }">
<el-button type="primary" link @click="onView(row)">详情</el-button>
<el-button type="success" link @click="onEdit(row)">调整分</el-button>
</template>
</DataTable>
</div>
</template>
<style scoped>
.rr-page { padding: 16px; }
.rr-tabs { margin-bottom: 12px; }
</style>

View File

@ -1,112 +0,0 @@
<script setup lang="ts">
/**
* F006 供应商评分管理 - W16
*
* 沿用老 vue user/enterprise/enterprise-score.vue 字段
*
* 端点: 4 URL 探测 (enterprisescore/page, /list, /search, /queryPage); 404 → FALLBACK seed
*/
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { httpGet } from '@/utils/request'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
import DataTable from '@/components/DataTable/DataTable.vue'
interface ScoreRow { id:string; enterpriseId:string; enterpriseName:string; scoreContent:string; evaluator:string; score:number; deadline:string; status:number }
const ENDPOINTS = [
'/user/performancescoredetail/page',
'/user/performancescoredetail/list'
]
const FALLBACK: ScoreRow[] = [
{ id:'SC001', enterpriseId:'s20015', enterpriseName:'坚美建筑材料有限公司', scoreContent:'商品质量', evaluator:'张工长', score:96, deadline:'2026-07-10 23:59:59', status:1 },
{ id:'SC002', enterpriseId:'s20015', enterpriseName:'坚美建筑材料有限公司', scoreContent:'配送时效', evaluator:'李采购', score:90, deadline:'2026-07-10 23:59:59', status:1 },
{ id:'SC003', enterpriseId:'s20016', enterpriseName:'华建门窗厂', scoreContent:'商品质量', evaluator:'王工长', score:88, deadline:'2026-07-10 23:59:59', status:1 },
{ id:'SC004', enterpriseId:'s20016', enterpriseName:'华建门窗厂', scoreContent:'安装服务', evaluator:'赵监理', score:92, deadline:'2026-07-10 23:59:59', status:1 },
{ id:'SC005', enterpriseId:'s20017', enterpriseName:'融鑫管业', scoreContent:'商品质量', evaluator:'钱工', score:85, deadline:'2026-07-15 23:59:59', status:0 },
{ id:'SC006', enterpriseId:'s20018', enterpriseName:'东方五金机械', scoreContent:'商品质量', evaluator:'孙经理', score:94, deadline:'2026-07-15 23:59:59', status:0 },
{ id:'SC007', enterpriseId:'s20019', enterpriseName:'美洁涂料', scoreContent:'商品质量', evaluator:'周工', score:78, deadline:'2026-07-15 23:59:59', status:0 },
{ id:'SC008', enterpriseId:'s20020', enterpriseName:'万家灯具', scoreContent:'商品质量', evaluator:'吴总', score:88, deadline:'2026-07-15 23:59:59', status:0 },
{ id:'SC009', enterpriseId:'s20021', enterpriseName:'顺达物流', scoreContent:'配送时效', evaluator:'郑采购', score:65, deadline:'2026-07-20 23:59:59', status:0 },
{ id:'SC010', enterpriseId:'s20022', enterpriseName:'众成厨具', scoreContent:'商品质量', evaluator:'冯工', score:76, deadline:'2026-07-20 23:59:59', status:0 },
{ id:'SC011', enterpriseId:'s20023', enterpriseName:'坚美建筑材料', scoreContent:'商品质量', evaluator:'陈工长', score:95, deadline:'2026-07-20 23:59:59', status:0 },
{ id:'SC012', enterpriseId:'s20024', enterpriseName:'华建门窗厂', scoreContent:'商品质量', evaluator:'褚采购', score:90, deadline:'2026-07-20 23:59:59', status:0 }
]
type Col = { type?: 'status'|'text'|'selection'; prop?: string; label: string; width?: number; slot?: string; fixed?: 'right'|true }
const columns: Col[] = [
{ type:'selection', label:'', width:50 },
{ prop:'id', label:'评分ID', width:90, fixed:true },
{ prop:'enterpriseName', label:'评分对象', width:200 },
{ prop:'scoreContent', label:'评分内容', width:140 },
{ prop:'evaluator', label:'评分员', width:120 },
{ prop:'score', label:'分值', width:90, slot:'score' },
{ prop:'status', label:'状态', width:90, slot:'status' },
{ prop:'deadline', label:'截止时间', width:170, slot:'deadline' },
{ prop:'actions', label:'操作', width:180, slot:'actions', fixed:'right' }
]
const STATUS_DEFAULTS = [
{ value:'', label:'全部' }
]
const list = ref<ScoreRow[]>([]); const total = ref(0); const loading = ref(false); const page = ref(1); const limit = ref(10); const filterKw = ref(''); const statusTab = ref('')
const dataSource = ref<'api'|'mock'|'loading'>('loading'); const apiError = ref<string>('')
async function load() {
loading.value = true; dataSource.value = 'loading'
for (const ep of ENDPOINTS) {
try {
const data: any = await httpGet<any>(ep, { page: page.value, limit: limit.value })
const rows = (data?.list || []).map((it: any) => ({ ...it, raw: it }))
list.value = rows; total.value = Number(data?.total || rows.length); dataSource.value = 'api'; apiError.value = ''; loading.value = false; return
} catch {}
}
list.value = [...FALLBACK]; total.value = FALLBACK.length; dataSource.value = 'mock'; apiError.value = '后端 API 暂未实装, 已加载前端示例数据'; loading.value = false
}
const filtered = computed(() => {
const kw = filterKw.value.trim().toLowerCase(); let rs = list.value
if (kw) rs = rs.filter(r => String(r.enterpriseName).includes(kw) || String(r.evaluator).includes(kw))
return rs
})
const searchFields: Array<{ key: string; label: string; type: 'text' }> = [
{ key:'enterpriseName', label:'评分对象', type:'text' },
{ key:'evaluator', label:'评分员', type:'text' }
]
const filters = reactive<Record<string, any>>({})
function onSearch() { filterKw.value = String(filters.enterpriseName || filters.evaluator || ''); page.value = 1 }
function onStatusTab(v: string) { statusTab.value = v; page.value = 1 }
function onView(row: ScoreRow) { ElMessage.info(`详情 ${row.id}`) }
async function onScore(row: ScoreRow) {
ElMessage.success('已跳转打分 (前端示例)')
}
function onExport() { ElMessage.success('已发起导出 (前端示例, 共 ' + filtered.value.length + ' 条)') }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader title="供应商评分管理" sub="供应商评分记录和评分员管理" :data-source="dataSource" :api-error="apiError">
<el-button type="primary" @click="onExport">导出明细</el-button>
</PageHeader>
<el-tabs v-model="statusTab" @tab-change="onStatusTab" class="rr-tabs">
<el-tab-pane v-for="t in STATUS_DEFAULTS" :key="t.value" :name="t.value" :label="t.label" />
</el-tabs>
<SearchBar v-model:filters="filters" :fields="searchFields" @search="onSearch" />
<DataTable :columns="columns" :data="filtered"
:borderless="true" :loading="loading" :page="page" :limit="limit" :total="total" row-key="id"
@page-change="(p: number) => { page = p; load() }" @limit-change="(l: number) => { limit = l; page = 1; load() }">
<template #score="{ row }">
<span style="font-weight: 600; color: var(--primary);">{{ row.score }}</span>
</template>
<template #status="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'warning'" size="small">{{ row.status === 1 ? '已评分' : '未评分' }}</el-tag>
</template>
<template #deadline="{ row }">{{ row.deadline }}</template>
<template #actions="{ row }">
<el-button type="primary" link @click="onView(row)">详情</el-button>
<el-button v-if="row.status === 0" type="success" link @click="onScore(row)">去打分</el-button>
</template>
</DataTable>
</div>
</template>
<style scoped>
.rr-page { padding: 16px; }
.rr-tabs { margin-bottom: 12px; }
</style>

View File

@ -1,83 +0,0 @@
<script setup lang="ts">
/**
* F006 财务流水 - P1 真 view
*
* 走 /user/accounttransaction/page (4 条路由共用, type 区分)
* 真实接口 (2026-08-07 探测: 200, total=405)
* 字段: id/userId/accountType/type/amount/balanceAfter/payTime/payType/status/orderCode/createDate
*/
import { onMounted, ref, reactive } from 'vue'
import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
interface Tx {
id: string
userId: string
accountType: number | null
type: number | null
amount: number
balanceAfter: number | null
payTime: string | null
payType: number | null
status: number
orderCode: string | null
createDate: string
}
const props = defineProps<{ type?: number }>()
const TITLES: Record<number, string> = { 1: '充值管理', 2: '提现管理', 3: '分账记录管理' }
const STATUS_MAP: Record<number, { label: string; type: string }> = {
0: { label: '待审核', type: 'warning' },
1: { label: '处理中', type: 'primary' },
2: { label: '已驳回', type: 'danger' },
3: { label: '已完成', type: 'success' }
}
const list = ref<Tx[]>([])
const total = ref(0)
const loading = ref(false)
const query = reactive({ page: 1, limit: 10 })
const columns = [
{ prop: 'orderCode', label: '单号', minWidth: 180, formatter: (r: Tx) => r.orderCode || '--' },
{ prop: 'amount', label: '金额', width: 120, formatter: (r: Tx) => '¥' + Number(r.amount ?? 0).toFixed(2) },
{ prop: 'status', label: '状态', width: 110, slot: 'status' },
{ prop: 'payType', label: '支付方式', width: 110, formatter: (r: Tx) => (r.payType === 1 ? '余额' : r.payType === 2 ? '微信' : r.payType === 3 ? '支付宝' : '--') },
{ prop: 'payTime', label: '支付时间', width: 180, formatter: (r: Tx) => r.payTime || '--' },
{ prop: 'createDate', label: '创建时间', width: 180 }
]
async function load() {
loading.value = true
try {
const resp: any = await httpGet('/user/accounttransaction/page', { page: query.page, limit: query.limit, ...(props.type ? { type: props.type } : {}) })
list.value = resp?.list || []
total.value = Number(resp?.total || 0)
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
function onPageChange(p: number) { query.page = p; load() }
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader :title="TITLES[props.type ?? 0] || '交易流水'" :sub="`财务流水记录 (共 ${total} 条)`" />
<div class="rr-card">
<DataTable :columns="columns" :data="list"
:borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="true" row-key="id" empty-text="暂无数据" @page-change="onPageChange"
@limit-change="onLimitChange">
<template #status="{ row }">
<el-tag :type="STATUS_MAP[row.status]?.type || 'info'">{{ STATUS_MAP[row.status]?.label || row.status }}</el-tag>
</template>
</DataTable>
</div>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 1400px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
</style>

View File

@ -1,260 +0,0 @@
<script setup lang="ts">
/**
* W18.4 企业登记管理 — 按项目 UI 规范 (PageHeader/SearchBar/DataTable) + 老 vue register.vue 功能映射
*
* 后端端点: ApiEnterpriseController @ /user/enterprise
* GET /user/enterprise/page 分页 (EnterpriseDTO 含 merchantStatus/merchantNo/terminalNo)
* GET /user/enterprise/merchant/info 商户进件信息 (EnterpriseMerchantDTO 全字段)
* GET /user/enterprise/merchant/status 商户进件状态 (同 DTO)
* POST /user/enterprise/merchant/register/submit 提交进件
* POST /user/enterprise/merchant/elec-contract/apply 生成电子合同
*
* EnterpriseMerchantDTO 真实字段 (来源: devbox 反编译确认, 2026-08-06):
* enterpriseId/userNo/lakalaMerchantNo/cupMerchantNo/termNo/registerStatus
* registerOrderNo/auditFailReason/ecOrderNo/ecApplyId/ecResultUrl/ecNo
* ecStatus/ecUpdateDate/shopId/addTermStatus/addTermReviewId/addTermFailReason
* email/busiCode/merchantType/merchantName/merchantAddress
* provinceCode/provinceName/cityCode/cityName/countyCode/countyName
* licenseNo/licenseName/licenseStartDate/licenseEndDate
* longitude/latitude/legalPersonName/legalPersonIdCard/legalPersonIdType
* legalPersonIdCardStart/legalPersonIdCardEnd
* contactMobile/contactName/openningBankCode/openningBankName/clearingBankCode
* settleProvinceCode/settleProvinceName/settleCityCode/settleCityName
* accountNo/accountName/accountType/accountIdCard/settleType/settlementType
* source/businessContent/idCardFrontUrl/idCardBackUrl/businessLicenseUrl/openingPermitUrl
*
* 状态映射 (老 vue register.vue setStatus):
* registerStatus: 1=审核中 2=审核通过 3=审核失败, 其他=未进件
* ecStatus: UNDONE=待签署 COMPLETED=已签署
* merchantStatus (EnterpriseDTO): 0=未绑定 1=审核中 2=进件中 3=有效 4=无效
*/
import { onMounted, ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import SearchBar from '@/components/SearchBar/SearchBar.vue'
interface Enterprise {
id: string
enterpriseName: string
merchantNo: string | null
terminalNo: string | null
merchantStatus: number | null
administrator: string
administratorMobile: string
}
const list = ref<Enterprise[]>([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const limit = ref(10)
const filters = reactive({
enterpriseName: ''
})
// merchantStatus 映射 (EnterpriseDTO: 0=未绑定 1=审核中 2=进件中 3=有效 4=无效)
const MERCHANT_STATUS_MAP: Record<number, { label: string; cls: string }> = {
0: { label: '未绑定', cls: 'rr-tag-info' },
1: { label: '审核中', cls: 'rr-tag-warn' },
2: { label: '进件中', cls: 'rr-tag-warn' },
3: { label: '有效', cls: 'rr-tag-ok' },
4: { label: '无效', cls: 'rr-tag-err' }
}
// registerStatus 映射 (老 vue: 1=审核中 2=审核通过 3=审核失败, 其他=未进件)
const REGISTER_STATUS_MAP: Record<number, { label: string; cls: string }> = {
1: { label: '审核中', cls: 'rr-tag-warn' },
2: { label: '审核通过', cls: 'rr-tag-ok' },
3: { label: '审核失败', cls: 'rr-tag-err' }
}
function maskMobile(mobile?: string | null) {
if (!mobile) return ''
return mobile.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
async function load() {
loading.value = true
try {
const params: Record<string, any> = { page: page.value, limit: limit.value }
if (filters.enterpriseName) params.enterpriseName = filters.enterpriseName
const data = await httpGet<{ total: number; list: any[] }>('/user/enterprise/page', params)
list.value = (data.list || []).map((it: any) => ({
id: String(it.id),
enterpriseName: it.enterpriseName || '--',
merchantNo: it.merchantNo || null,
terminalNo: it.terminalNo || null,
merchantStatus: it.merchantStatus ?? null,
administrator: it.administrator || '--',
administratorMobile: it.administratorMobile || ''
}))
total.value = data.total || 0
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '加载企业列表失败')
} finally {
loading.value = false
}
}
function onSearch() { page.value = 1; load() }
function onReset() { filters.enterpriseName = ''; page.value = 1; load() }
function onPageChange(p: number) { page.value = p; load() }
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
// ========== 进件详情弹窗 ==========
const detailVisible = ref(false)
const detailLoading = ref(false)
const merchant = ref<Record<string, any> | null>(null)
async function showMerchantDetail(row: Enterprise) {
detailVisible.value = true
detailLoading.value = true
merchant.value = null
try {
const data = await httpGet<any>('/user/enterprise/merchant/info', { enterpriseId: row.id })
merchant.value = data || {}
} catch (e: any) {
merchant.value = null
ElMessage.warning(e?.msg || e?.message || '该企业暂无进件信息')
} finally {
detailLoading.value = false
}
}
function regStatus() {
const s = merchant.value?.registerStatus
return REGISTER_STATUS_MAP[s as number]?.label || '未进件'
}
function ecStatus() {
const s = merchant.value?.ecStatus
if (s === 'COMPLETED') return '已签署'
if (s === 'UNDONE') return '待签署'
return '-'
}
onMounted(() => load())
</script>
<template>
<div class="rr-page">
<PageHeader title="企业登记管理" :sub="`共 ${total} 家企业`" />
<SearchBar
v-model:filters="filters"
:fields="[
{ key: 'enterpriseName', label: '企业名称', type: 'text', placeholder: '企业名称' }
]"
@search="onSearch"
@reset="onReset"
/>
<div class="rr-list-card">
<DataTable
:columns="[
{ label: '企业名称', minWidth: 220, slot: 'enterpriseName' },
{ label: '管理员/手机号', minWidth: 160, slot: 'adminInfo' },
{ prop: 'merchantNo', label: '商户号', width: 160, align: 'center' },
{ prop: 'terminalNo', label: '终端号', width: 160, align: 'center' },
{ label: '商户状态', width: 110, slot: 'merchantStatus', align: 'center' },
{ label: '操作', width: 120, slot: 'action', fixed: 'right', align: 'center' }
]"
:data="list"
:loading="loading"
:total="total"
:page="page"
:limit="limit"
:borderless="true"
:page-sizes="[10, 20, 50, 100]"
row-key="id"
empty-text="暂无企业"
@page-change="onPageChange"
@limit-change="onLimitChange"
>
<template #enterpriseName="{ row }">
<span style="font-weight:500">{{ row.enterpriseName }}</span>
</template>
<template #adminInfo="{ row }">
<div style="display:flex;flex-direction:column;gap:2px">
<span>{{ row.administrator }}</span>
<span style="font-size:12px;color:var(--ink-3)">{{ maskMobile(row.administratorMobile) }}</span>
</div>
</template>
<template #merchantStatus="{ row }">
<span v-if="MERCHANT_STATUS_MAP[row.merchantStatus as number]"
:class="['rr-status-tag', MERCHANT_STATUS_MAP[row.merchantStatus as number].cls]">
{{ MERCHANT_STATUS_MAP[row.merchantStatus as number].label }}
</span>
<span v-else class="rr-muted">--</span>
</template>
<template #action="{ row }">
<el-button type="primary" size="small" text @click="showMerchantDetail(row)">进件详情</el-button>
</template>
</DataTable>
</div>
<!-- 进件详情弹窗 (EnterpriseMerchantDTO 核心字段) -->
<el-dialog v-model="detailVisible" title="商户进件详情" width="760px" :close-on-click-modal="false" destroy-on-close>
<div v-loading="detailLoading">
<template v-if="merchant">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:12px">
<span class="rr-status-tag" :class="REGISTER_STATUS_MAP[merchant.registerStatus as number]?.cls || 'rr-tag-info'">
进件状态: {{ regStatus() }}
</span>
<span class="rr-status-tag rr-tag-ok" v-if="merchant.ecStatus === 'COMPLETED'">电子合同: 已签署</span>
<span class="rr-status-tag rr-tag-warn" v-else-if="merchant.ecStatus === 'UNDONE'">电子合同: 待签署</span>
</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="商户名称">{{ merchant.merchantName || '-' }}</el-descriptions-item>
<el-descriptions-item label="商户类型">{{ merchant.merchantType || '-' }}</el-descriptions-item>
<el-descriptions-item label="拉卡拉商户号">{{ merchant.lakalaMerchantNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="银联商户号">{{ merchant.cupMerchantNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="终端号">{{ merchant.termNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="进件单号">{{ merchant.registerOrderNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="商户地址" :span="2">{{ merchant.merchantAddress || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系人">{{ merchant.contactName || '-' }}</el-descriptions-item>
<el-descriptions-item label="联系人手机">{{ maskMobile(merchant.contactMobile) || '-' }}</el-descriptions-item>
<el-descriptions-item label="法人代表">{{ merchant.legalPersonName || '-' }}</el-descriptions-item>
<el-descriptions-item label="经营内容">{{ merchant.businessContent || '-' }}</el-descriptions-item>
<el-descriptions-item label="营业执照名称">{{ merchant.licenseName || '-' }}</el-descriptions-item>
<el-descriptions-item label="营业执照号">{{ merchant.licenseNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="结算账户名称">{{ merchant.accountName || '-' }}</el-descriptions-item>
<el-descriptions-item label="结算账户号">{{ merchant.accountNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="结算账户类型">{{ merchant.accountType || '-' }}</el-descriptions-item>
<el-descriptions-item label="电子合同号">{{ merchant.ecNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="电子合同订单号">{{ merchant.ecOrderNo || '-' }}</el-descriptions-item>
<el-descriptions-item v-if="merchant.registerStatus === 3" label="审核失败原因" :span="2" style="color:#c62828">
{{ merchant.auditFailReason || '-' }}
</el-descriptions-item>
</el-descriptions>
</template>
<el-empty v-else-if="!detailLoading" description="该企业暂无进件信息" :image-size="80" />
</div>
<template #footer>
<el-button @click="detailVisible=false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped lang="scss">
.rr-page { padding: 16px; }
.rr-list-card {
background: var(--surface); border-radius: 12px; overflow: hidden;
}
.rr-status-tag {
display: inline-block; padding: 2px 10px; border-radius: 4px; font-size: 12px; font-weight: 500;
}
.rr-tag-info { background: #e3f2fd; color: #1565c0; }
.rr-tag-warn { background: #fff3e0; color: #e65100; }
.rr-tag-ok { background: #e8f5e9; color: #2e7d32; }
.rr-tag-err { background: #fce4ec; color: #c62828; }
.rr-muted { color: var(--ink-3); font-size: 12px; }
</style>

View File

@ -1,72 +0,0 @@
<script setup lang="ts">
/**
* F006 系统消息 - P1 真 view
*
* 走 /user/message/page
* 真实接口 (2026-08-07 探测: 200, total=112)
* 字段: id/type/pushType/name/content/readFlag/itemCode/createDate
*/
import { onMounted, ref, reactive } from 'vue'
import { httpGet } from '@/utils/request'
import DataTable from '@/components/DataTable/DataTable.vue'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
interface Msg {
id: string
type: number
pushType: number | null
name: string
content: string
readFlag: number
itemCode: string | null
createDate: string
}
const props = defineProps<{ status?: number }>()
const list = ref<Msg[]>([])
const total = ref(0)
const loading = ref(false)
const query = reactive({ page: 1, limit: 10 })
const READ_MAP: Record<number, { label: string; type: string }> = { 0: { label: '未读', type: 'danger' }, 1: { label: '已读', type: 'success' } }
const columns = [
{ prop: 'id', label: 'ID', width: 90 },
{ prop: 'name', label: '消息名称', minWidth: 180 },
{ prop: 'content', label: '内容', minWidth: 280 },
{ prop: 'readFlag', label: '状态', width: 100, slot: 'read' },
{ prop: 'createDate', label: '创建时间', width: 180 }
]
async function load() {
loading.value = true
try {
const resp: any = await httpGet('/user/message/page', { page: query.page, limit: query.limit, ...(props.status !== undefined ? { status: props.status } : {}) })
list.value = resp?.list || []
total.value = Number(resp?.total || 0)
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
}
function onPageChange(p: number) { query.page = p; load() }
function onLimitChange(l: number) { query.limit = l; query.page = 1; load() }
onMounted(load)
</script>
<template>
<div class="rr-page">
<PageHeader :title="props.status === 0 ? '待处理消息' : '系统消息'" :sub="`${props.status === 0 ? '待处理' : '站内'}消息 (共 ${total} 条)`" />
<div class="rr-card">
<DataTable :columns="columns" :data="list"
:borderless="true" :loading="loading" :total="total" :page="query.page" :limit="query.limit" :show-pagination="true" row-key="id" empty-text="暂无消息" @page-change="onPageChange"
@limit-change="onLimitChange">
<template #read="{ row }">
<el-tag :type="READ_MAP[row.readFlag]?.type || 'info'">{{ READ_MAP[row.readFlag]?.label || row.readFlag }}</el-tag>
</template>
</DataTable>
</div>
</div>
</template>
<style scoped lang="scss">
.rr-page { max-width: 1400px; }
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
</style>

View File

@ -87,12 +87,6 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/modules/user/EnterpriseListView.vue'), component: () => import('@/modules/user/EnterpriseListView.vue'),
meta: { title: '企业管理' } meta: { title: '企业管理' }
}, },
{
path: '/user/enterprise/merchant/register',
name: 'MerchantRegisterList',
component: () => import('@/modules/user/merchant/MerchantRegisterListView.vue'),
meta: { title: '企业登记管理' }
},
{ {
path: '/user/finance', path: '/user/finance',
name: 'UserFinanceList', name: 'UserFinanceList',
@ -180,9 +174,10 @@ const routes: RouteRecordRaw[] = [
meta: { title: '服务商品' } meta: { title: '服务商品' }
}, },
{ {
// W3.x C P2 + W17.3.7: 商品店铺后端未实现, redirect 到 supplier/good 实物商品页
path: '/product/shop', path: '/product/shop',
redirect: '/supplier/good' name: 'ProductShop',
component: () => import('@/modules/product/shop/ShopListView.vue'),
meta: { title: '店铺管理' }
}, },
{ {
path: '/message/feedback', path: '/message/feedback',
@ -199,19 +194,19 @@ const routes: RouteRecordRaw[] = [
// ============ W5.x.x+4: 50 个 P3 路由 - MockTableView 集中配置 ============ // ============ W5.x.x+4: 50 个 P3 路由 - MockTableView 集中配置 ============
// 4 个 message // 4 个 message
{ path: '/message/sms', component: () => import('@/modules/message/sms/SmsListView.vue'), meta: { title: '短信配置' } }, { path: '/message/sms', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'message-sms' }), meta: { title: '短信配置' } },
{ path: '/message/sms-log', component: () => import('@/modules/message/smslog/SmsLogListView.vue'), meta: { title: '短信日志' } }, { path: '/message/sms-log', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'message-sms-log' }), meta: { title: '短信日志' } },
{ path: '/message/mail-template', component: () => import('@/modules/message/mailtemplate/MailTemplateListView.vue'), meta: { title: '邮件模板' } }, { path: '/message/mail-template', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'message-mail-template' }), meta: { title: '邮件模板' } },
{ path: '/message/mail-log', component: () => import('@/modules/message/maillog/MailLogListView.vue'), meta: { title: '邮件日志' } }, { path: '/message/mail-log', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'message-mail-log' }), meta: { title: '邮件日志' } },
// 4 个运营中心 (P1: mock -> 真 view, /sys/operation/pageList type 区分) // 4 个运营中心
{ path: '/sys/operation', component: () => import('@/modules/sys/operation/OperationListView.vue'), props: () => ({ type: 1 }), meta: { title: '全国运营中心' } }, { path: '/sys/operation', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'sys-operation-country' }), meta: { title: '全国运营中心' } },
{ path: '/sys/operation/province', component: () => import('@/modules/sys/operation/OperationListView.vue'), props: () => ({ type: 2 }), meta: { title: '省级运营中心' } }, { path: '/sys/operation/province', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'sys-operation-province' }), meta: { title: '省级运营中心' } },
{ path: '/sys/operation/county', component: () => import('@/modules/sys/operation/OperationListView.vue'), props: () => ({ type: 3 }), meta: { title: '县级运营中心' } }, { path: '/sys/operation/county', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'sys-operation-county' }), meta: { title: '县级运营中心' } },
{ path: '/sys/operation/association', component: () => import('@/modules/sys/operation/OperationListView.vue'), props: () => ({ type: 4 }), meta: { title: '行业协会运营' } }, { path: '/sys/operation/association', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'sys-operation-association' }), meta: { title: '行业协会运营' } },
// 3 个资讯类 (P1: information -> 真 view, industry/categoryZX 404 保留 mock) // 3 个资讯类
{ path: '/other/information', component: () => import('@/modules/other/information/InformationListView.vue'), meta: { title: '资讯列表' } }, { path: '/other/information', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'other-information' }), meta: { title: '资讯列表' } },
{ path: '/other/industry', component: () => import('@/modules/other/category/CategoryDeepListView.vue'), props: { type: 1, title: '行业管理' }, meta: { title: '行业' } }, { path: '/other/industry', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'other-industry' }), meta: { title: '行业' } },
{ path: '/other/category/zx', component: () => import('@/modules/other/category/CategoryDeepListView.vue'), props: { type: 3, title: '资讯分类' }, meta: { title: '资讯分类' } }, { path: '/other/category/zx', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'other-category-zx' }), meta: { title: '资讯分类' } },
// devtools 5 个 // devtools 5 个
{ path: '/devtools/baseclass', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'devtools-baseclass' }), meta: { title: '基类配置' } }, { path: '/devtools/baseclass', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'devtools-baseclass' }), meta: { title: '基类配置' } },
{ path: '/devtools/datasource', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'devtools-datasource' }), meta: { title: '数据源配置' } }, { path: '/devtools/datasource', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'devtools-datasource' }), meta: { title: '数据源配置' } },
@ -219,7 +214,7 @@ const routes: RouteRecordRaw[] = [
{ path: '/devtools/param', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'devtools-param' }), meta: { title: '参数配置(开发)' } }, { path: '/devtools/param', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'devtools-param' }), meta: { title: '参数配置(开发)' } },
{ path: '/devtools/template', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'devtools-template' }), meta: { title: '模板配置' } }, { path: '/devtools/template', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'devtools-template' }), meta: { title: '模板配置' } },
// job 1 个 // job 1 个
{ path: '/job/schedule', component: () => import('@/modules/sys/ScheduleListView.vue'), meta: { title: '定时任务' } }, { path: '/job/schedule', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'job-schedule' }), meta: { title: '定时任务' } },
// order 9 个 // order 9 个
{ path: '/order/ordergood', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '商品订单' } }, { path: '/order/ordergood', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '商品订单' } },
{ path: '/order/serviceorder', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '服务订单' } }, { path: '/order/serviceorder', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '服务订单' } },
@ -228,54 +223,38 @@ const routes: RouteRecordRaw[] = [
{ path: '/order/ordergood/commission', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '实物订单分佣统计' } }, { path: '/order/ordergood/commission', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '实物订单分佣统计' } },
{ path: '/order/serviceorder/commission', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '服务订单分佣统计' } }, { path: '/order/serviceorder/commission', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '服务订单分佣统计' } },
{ path: '/order/ordergood/after', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '商品售后订单' } }, { path: '/order/ordergood/after', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '商品售后订单' } },
{ path: '/order/serviceorder/after', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '服务售后订单' } },
{ path: '/order/orderserviceafter', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '服务售后订单' } }, { path: '/order/orderserviceafter', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '服务售后订单' } },
{ path: '/order/base-price-analysis', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '基础价格分析表' } }, { path: '/order/base-price-analysis', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '基础价格分析表' } },
// W15 新增 - 订单售后 + 退款 + 付款回调 (前端 max-effort mock, 老 vue 同字段)
{ path: '/order/orderpay', component: () => import('@/modules/order/payment/PaymentCallbackListView.vue'), meta: { title: '订单支付回调' } },
{ path: '/order/refund', component: () => import('@/modules/order/refund/RefundProcessListView.vue'), meta: { title: '订单退款管理' } },
{ path: '/order/aftersale', component: () => import('@/modules/order/aftersale/AftersaleListView.vue'), meta: { title: '售后订单' } },
// other 7 个 // other 7 个
{ path: '/other/brand', component: () => import('@/modules/other/supplierBrand/SupplierBrandListView.vue'), meta: { title: '品牌管理' } }, { path: '/other/brand', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'other-brand' }), meta: { title: '品牌管理' } },
// W18 真 view 切换 (原 MockTableView -> 真页面) { path: '/other/category/good', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'other-category-good' }), meta: { title: '平台分类' } },
{ path: '/other/category/good', component: () => import('@/modules/other/categoryGood/CategoryGoodListView.vue'), meta: { title: '平台分类' } }, { path: '/other/help', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'other-help' }), meta: { title: '帮助中心' } },
{ path: '/other/help', component: () => import('@/modules/other/help/HelpListView.vue'), meta: { title: '帮助中心' } }, { path: '/other/guide', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'other-guide' }), meta: { title: '内容列表' } },
{ path: '/other/guide', component: () => import('@/modules/other/operateguide/OperateguideListView.vue'), meta: { title: '内容列表' } }, { path: '/other/textconfig', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'other-textconfig' }), meta: { title: '常量配置' } },
{ path: '/other/textconfig', component: () => import('@/modules/other/textconfig/TextconfigListView.vue'), meta: { title: '常量配置' } }, { path: '/other/groupbuy', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'other-groupbuy' }), meta: { title: '拼团活动' } },
{ path: '/other/groupbuy', component: () => import('@/modules/other/groupbuy/GroupbuyListView.vue'), meta: { title: '拼团活动' } }, { path: '/other/seckill', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'other-seckill' }), meta: { title: '秒杀活动' } },
{ path: '/other/seckill', component: () => import('@/modules/other/seckill/SeckillListView.vue'), meta: { title: '秒杀活动' } },
// supplier 2 个 // supplier 2 个
{ path: '/supplier/coupon', component: () => import('@/modules/supplier/coupon/CouponListView.vue'), meta: { title: '优惠券' } }, // W7: 独立 view, 走 /supplier/coupon/page 真 API { path: '/supplier/coupon', component: () => import('@/modules/supplier/coupon/CouponListView.vue'), meta: { title: '优惠券' } }, // W7: 独立 view, 走 /supplier/coupon/page 真 API
{ path: '/supplier/promoter', component: () => import('@/modules/supplier/promoter/PromoterListView.vue'), meta: { title: '推广员管理' } }, { path: '/supplier/promoter', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'supplier-promoter' }), meta: { title: '推广返佣' } },
{ path: '/supplier/historylog', component: () => import('@/modules/supplier/historylog/HistorylogListView.vue'), meta: { title: '浏览足迹' } },
{ path: '/supplier/ordercomment', component: () => import('@/modules/supplier/ordercomment/OrderCommentListView.vue'), meta: { title: '订单评价' } },
{ path: '/supplier/logistics', component: () => import('@/modules/supplier/logistics/LogisticsListView.vue'), meta: { title: '物流方案' } },
{ path: '/supplier/coupon-scope', component: () => import('@/modules/supplier/couponscope/CouponScopeListView.vue'), meta: { title: '优惠券适用范围' } },
// sys 11 个 // sys 11 个
{ path: '/sys/dict', component: () => import('@/modules/sys/DictTypeListView.vue'), meta: { title: '字典类型' } }, { path: '/sys/dict', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'sys-dict' }), meta: { title: '字典类型' } },
{ path: '/sys/log/login', component: () => import('@/modules/sys/LogLoginView.vue'), meta: { title: '登录日志' } }, { path: '/sys/log/login', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'sys-log-login' }), meta: { title: '登录日志' } },
{ path: '/sys/log/operation', component: () => import('@/modules/sys/LogOperationView.vue'), meta: { title: '操作日志' } }, { path: '/sys/log/operation', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'sys-log-operation' }), meta: { title: '操作日志' } },
{ path: '/sys/log/error', component: () => import('@/modules/sys/LogErrorView.vue'), meta: { title: '异常日志' } }, { path: '/sys/log/error', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'sys-log-error' }), meta: { title: '异常日志' } },
{ path: '/sys/notice', component: () => import('@/modules/sys/NoticeListView.vue'), meta: { title: '通知公告' } }, { path: '/sys/notice', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'sys-notice' }), meta: { title: '通知公告' } },
{ path: '/sys/params', component: () => import('@/modules/sys/ParamsListView.vue'), meta: { title: '参数配置' } }, { path: '/sys/params', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'sys-params' }), meta: { title: '参数配置' } },
{ path: '/sys/notice-user', component: () => import('@/modules/sys/noticeuser/NoticeUserListView.vue'), meta: { title: '用户通知' } }, { path: '/sys/notice-user', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'sys-notice-user' }), meta: { title: '用户通知' } },
{ path: '/sys/userManagement', redirect: '/sys/operation', meta: { title: '全国运营中心' } }, { path: '/sys/userManagement', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'sys-userManagement' }), meta: { title: '全国运营中心' } },
// W18 后台用户管理 5 个 (老 vue sys/user 模块, 真 view 已存在)
{ path: '/sys/user', component: () => import('@/modules/sys/UserListView.vue'), meta: { title: '用户管理' } },
{ path: '/sys/role', component: () => import('@/modules/sys/RoleListView.vue'), meta: { title: '角色管理' } },
{ path: '/sys/post', component: () => import('@/modules/sys/PostListView.vue'), meta: { title: '岗位管理' } },
{ path: '/sys/dept', component: () => import('@/modules/sys/DeptListView.vue'), meta: { title: '部门管理' } },
{ path: '/sys/menu', component: () => import('@/modules/sys/MenuListView.vue'), meta: { title: '菜单管理' } },
// user 9 个 // user 9 个
{ path: '/user/enterprise/level', component: () => import('@/modules/user/enterpriselevel/EnterpriselevelListView.vue'), meta: { title: '企业等级管理' } }, { path: '/user/enterprise/level', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'user-enterprise-level' }), meta: { title: '企业等级管理' } },
{ path: '/user/enterprise/rating', component: () => import('@/modules/user/enterpriserating/EnterpriseratingListView.vue'), meta: { title: '供应商评级' } }, { path: '/user/enterprise/rating', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'user-enterprise-rating' }), meta: { title: '评级' } },
{ path: '/user/enterprise/score', component: () => import('@/modules/user/enterprisescore/EnterprisescoreListView.vue'), meta: { title: '供应商评分' } }, { path: '/user/enterprise/score', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'user-enterprise-score' }), meta: { title: '评分' } },
{ path: '/user/finance/recharge', component: () => import('@/modules/user/finance/AccountTransactionListView.vue'), props: () => ({ type: 1 }), meta: { title: '充值管理' } }, { path: '/user/finance/recharge', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'user-finance-recharge' }), meta: { title: '充值管理' } },
{ path: '/user/finance/recharge-tx', component: () => import('@/modules/user/finance/AccountTransactionListView.vue'), props: () => ({ type: 2 }), meta: { title: '提现管理' } }, { path: '/user/finance/recharge-tx', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'user-finance-recharge-tx' }), meta: { title: '提现管理' } },
{ path: '/user/finance/revenue', component: () => import('@/modules/user/finance/AccountTransactionListView.vue'), props: () => ({ type: 3 }), meta: { title: '分账记录管理' } }, { path: '/user/finance/revenue', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'user-finance-revenue' }), meta: { title: '分账记录管理' } },
{ path: '/user/finance/transaction', component: () => import('@/modules/user/finance/AccountTransactionListView.vue'), meta: { title: '交易流水' } }, { path: '/user/finance/transaction', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'user-finance-transaction' }), meta: { title: '交易流水' } },
{ path: '/user/message', component: () => import('@/modules/user/message/MessageListView.vue'), meta: { title: '系统消息' } }, { path: '/user/message', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'user-message' }), meta: { title: '系统消息' } },
{ path: '/user/message/handel', component: () => import('@/modules/user/message/MessageListView.vue'), props: { status: 0 }, meta: { title: '待处理消息' } }, { path: '/user/message/handel', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'user-message-handel' }), meta: { title: '待处理消息' } },
// W3.1 通用通配占位 // W3.1 通用通配占位
{ {

View File

@ -69,12 +69,10 @@ export const useMenuStore = defineStore('menu', () => {
if (!filtered.length) return null if (!filtered.length) return null
return { ...item, children: filtered } return { ...item, children: filtered }
} }
// F002-W18 修: 后端 url 可能带 / 前缀 (如 /user/enterprise/enterprise), // 子菜单: url 必须在 MENU_LABELS 里, 且首次出现
// MENU_LABELS 不带 / (如 user/enterprise/enterprise), 规范化后匹配 if (!allowedUrls.has(item.url)) return null
const normalizedUrl = item.url.replace(/^\//, '') if (seenUrls.has(item.url)) return null
if (!allowedUrls.has(item.url) && !allowedUrls.has(normalizedUrl)) return null seenUrls.add(item.url)
if (seenUrls.has(normalizedUrl)) return null
seenUrls.add(normalizedUrl)
return item return item
}) })
.filter((item): item is ResolvedMenu => item !== null) .filter((item): item is ResolvedMenu => item !== null)

View File

@ -1,123 +0,0 @@
/* === 全局弹层圆角 (按 ui-preview-v2.html 模板: --radius 8px) ===
EP 弹层 (select-dropdown / popper / dropdown / menu) 通过 teleport 到 body,
不在组件 scope 内, 必须全局生效。双重保险:① 改 EP CSS 变量根 ② 写 !important selector */
/* === 1) 改 EP CSS 变量根 (从源头覆盖, 所有用 var(--el-*) 的弹层都生效) === */
:root {
--el-border-radius-base: 8px;
--el-border-radius-small: 6px;
--el-popper-border-radius: 8px;
}
/* === 2) 显式 selector + !important (覆盖任何内联样式) === */
.el-select-dropdown,
.el-select__popper,
.el-select-v2__popper,
.el-dropdown-menu,
.el-dropdown__popper,
.el-menu--popup,
.el-popper,
.el-popper.is-light,
.el-picker__popper,
.el-autocomplete__popper,
.el-autocomplete-suggestion,
.el-cascader__dropdown,
.el-cascader-panel,
.el-color-picker__panel,
.el-tooltip__popper,
.el-message-box,
.el-notification,
.el-drawer {
border-radius: var(--radius, 8px) !important;
}
/* 弹层内容滚动层 (Element Plus 弹层有 .el-popper__content 内部容器) */
.el-popper .el-popper__content,
.el-select-dropdown .el-scrollbar {
border-radius: var(--radius, 8px) !important;
}
/* === 3) dropdown 项 hover/选中态 (跟模版统一: brand-soft 高亮 + brand 蓝字) === */
.el-select-dropdown__item,
.el-dropdown-menu__item {
border-radius: var(--radius-sm, 6px) !important;
margin: 2px 4px !important;
padding: 0 12px !important;
height: 34px !important;
line-height: 34px !important;
font-size: 13px !important;
color: var(--ink-2, #334155);
background: transparent;
}
.el-select-dropdown__item.is-hovering,
.el-dropdown-menu__item:not(.is-disabled):hover {
background: var(--brand-soft, rgba(37, 99, 235, 0.08)) !important;
color: var(--brand, #2563EB) !important;
}
.el-select-dropdown__item.is-selected {
font-weight: 600 !important;
color: var(--brand, #2563EB) !important;
background: transparent !important;
}
/* === 4) 弹层背景跟主题 (避免 dark 下弹层仍是浅色 + 文字看不清) === */
.el-select-dropdown,
.el-dropdown-menu,
.el-popper.is-light,
.el-cascader__dropdown,
.el-color-picker__panel {
background: var(--surface, #FFFFFF) !important;
border: 1px solid var(--border, #E2E8F0) !important;
}
/* === 5) input 圆角: 跟模版一致 (按 ui-preview-v2.html: 1px 描边 + 8px 圆角 + 34px 高) === */
.el-input__wrapper,
.el-select__wrapper,
.el-textarea__inner {
border-radius: var(--radius, 8px) !important;
box-shadow: 0 0 0 1px var(--border, #E2E8F0) inset !important;
min-height: 34px;
transition: box-shadow 0.15s, border-color 0.15s;
}
.el-input__wrapper:hover,
.el-select__wrapper:hover {
box-shadow: 0 0 0 1px var(--ink-3, #94A3B8) inset !important;
}
.el-input__wrapper.is-focus,
.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)) !important;
}
/* === W18.9 全局滚动条暗主题适配 (深色滚动条避免割裂) === */
/* 默认 (亮主题): 浅灰滚动条 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border, #E2E8F0);
border-radius: 4px;
transition: background 0.15s;
}
::-webkit-scrollbar-thumb:hover {
background: var(--ink-4, #94A3B8);
}
/* Firefox */
html {
scrollbar-color: var(--border, #E2E8F0) transparent;
scrollbar-width: thin;
}
/* 暗主题: 深色滚动条适配, 避免浅白滚动条在深色背景上刺眼 */
[data-theme="dark"] ::-webkit-scrollbar-thumb {
background: var(--border, #475569);
}
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover {
background: var(--ink-3, #CBD5E1);
}
[data-theme="dark"] html {
scrollbar-color: var(--border, #475569) transparent;
}

View File

@ -191,39 +191,4 @@
--el-color-primary-light-8: var(--brand-soft); --el-color-primary-light-8: var(--brand-soft);
--el-color-primary-light-9: var(--brand-soft); --el-color-primary-light-9: var(--brand-soft);
--el-color-primary-dark-2: var(--brand-hover); --el-color-primary-dark-2: var(--brand-hover);
// W18 dark 主题适配:EP 内部 SCSS 常量重写
// :root 块里 var(--xxx) 解析时, dark 块的重写还未生效, 必须用实际值硬编码
--el-color-white: #1E293B; /* surface */
--el-color-black: #F8FAFC; /* ink */
--el-text-color-primary: #F8FAFC; /* ink */
--el-text-color-regular: #E2E8F0; /* ink-2 */
--el-text-color-secondary: #CBD5E1; /* ink-3 */
--el-text-color-placeholder: #94A3B8; /* ink-4 */
--el-text-color-disabled: #94A3B8; /* ink-4 */
--el-border-color: #475569; /* border */
--el-border-color-light: #475569;
--el-border-color-lighter: #475569;
--el-border-color-extra-light: #475569;
--el-border-color-dark: #64748B; /* border-strong */
--el-border-color-darker: #64748B;
--el-fill-color: #0F172A; /* bg-soft */
--el-fill-color-light: #0F172A;
--el-fill-color-lighter: #283449; /* surface-2 */
--el-fill-color-extra-light: #283449;
--el-fill-color-dark: #283449;
--el-fill-color-darker: #283449;
--el-fill-color-blank: #1E293B; /* surface */
--el-bg-color: #1E293B;
--el-bg-color-page: #0F172A; /* bg-soft */
--el-bg-color-overlay: #1E293B;
--el-table-tr-bg-color: #1E293B; /* surface */
--el-table-bg-color: #1E293B;
--el-table-expanded-cell-bg-color: #1E293B;
--el-table-header-bg-color: #283449; /* surface-2 (表头略亮区分) */
--el-table-row-hover-bg-color: #0F172A; /* bg-soft */
--el-tooltip-bg-color: #1E293B; /* surface */
--el-disabled-bg-color: #0F172A; /* bg-soft */
--el-disabled-text-color: #94A3B8; /* ink-4 */
--el-disabled-border-color: #475569;
} }

View File

@ -14,7 +14,7 @@
// ===== 圆角(沿用 v1.3) ===== // ===== 圆角(沿用 v1.3) =====
--radius-sm: 6px; --radius-sm: 6px;
--radius: 8px; --radius: 8px;
--radius-lg: 10px; --radius-lg: 12px;
--radius-xl: 16px; --radius-xl: 16px;
--radius-pill: 9999px; --radius-pill: 9999px;
@ -120,12 +120,4 @@
// ===== v2 新增:主按钮渐变(从主题 brand 自动组合) ===== // ===== v2 新增:主按钮渐变(从主题 brand 自动组合) =====
--brand-gradient: linear-gradient(135deg, var(--brand), var(--brand-2)); --brand-gradient: linear-gradient(135deg, var(--brand), var(--brand-2));
// ===== W18 暗主题适配:补齐 EP 缺失 token (dark 下表格背景/边框自适应) =====
// EP 内部用 --el-color-white / black, 未重定向, dark 下仍是 #FFFFFF
--el-color-white: var(--surface);
--el-color-black: var(--ink);
// el-table 表格体/展开行背景
--el-table-tr-bg-color: var(--surface);
--el-table-expanded-cell-bg-color: var(--surface);
} }

View File

@ -19,9 +19,7 @@ export interface ApiEnvelope<T = any> {
const STORAGE_KEY = 'access_token' const STORAGE_KEY = 'access_token'
export const http: AxiosInstance = axios.create({ export const http: AxiosInstance = axios.create({
// F002-W18 修: baseURL '/api' 让巨变数字 nginx /api/ location 转发 (去 /api 前缀) baseURL: '',
// devbox nginx 同样有 /api/ location (rewrite ^/api/(.*)$ /$1 break), 兼容
baseURL: '/api',
timeout: 15000 timeout: 15000
}) })
@ -41,13 +39,12 @@ http.interceptors.response.use(
const body = response.data as ApiEnvelope | undefined const body = response.data as ApiEnvelope | undefined
if (body && typeof body === 'object' && 'code' in body) { if (body && typeof body === 'object' && 'code' in body) {
if (body.code === 0) return response if (body.code === 0) return response
// 业务级 code:401 // 业务错:401 token 过期 / 其它业务错
// 未登录(无 token)访问受保护接口(如 /sys/menu/nav)→ 跳登录页 if (body.code === 401 || body.code === 401000) {
// 已登录但权限不足(如 /openapi/** 带 token 被 Spring Security 拒)→ 保持 reject 给调用方
if (body.code === 401 && !localStorage.getItem(STORAGE_KEY)) {
handleAuthExpired() handleAuthExpired()
return Promise.reject(body) return Promise.reject(body)
} }
// 业务错(不弹 toast,留给调用方决定)
return Promise.reject(body) return Promise.reject(body)
} }
return response return response

View File

@ -1,178 +0,0 @@
<!--
* F006 主题配置 (管理后台)
* - 选 4 套预设主题之一,应用到 pc-前端-v2 全站
* - 后端接口:
* GET /sys/openapi/theme/current → { theme: "industrial-blue", available: [...] }
* POST /sys/openapi/theme/update?theme=xxx 切换主题
* - 切换后 pc-前端-v2 下次刷新或主页轮询时会拿到最新主题
-->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import PageHeader from '@/components/PageHeader/PageHeader.vue'
import { httpGet, httpPost } from '@/utils/request'
interface Theme {
key: string
label: string
description: string
primary: string
'primary-container': string
secondary: string
surface: string
'text-primary': string
border: string
}
const themes = ref<Theme[]>([
{
key: 'industrial-blue',
label: '工业蓝',
description: '默认主题,适用于工业品 B2B 大宗交易场景',
primary: '#0B5394', 'primary-container': '#E8F0FA',
secondary: '#E07B00', surface: '#FFFFFF',
'text-primary': '#1A1F2C', border: '#E2E6EE',
},
{
key: 'steel-red',
label: '钢红',
description: '国企/政策导向客户首选,庄重正式',
primary: '#A8202E', 'primary-container': '#FAE8EB',
secondary: '#C99B30', surface: '#FFFFFF',
'text-primary': '#1A1F2C', border: '#EFE2E4',
},
{
key: 'forest-green',
label: '森林绿',
description: '能源/碳中和/化工场景,传递可持续发展',
primary: '#1F6E4D', 'primary-container': '#E5F2EC',
secondary: '#D4A017', surface: '#FFFFFF',
'text-primary': '#1A1F2C', border: '#E2EAE5',
},
{
key: 'midnight-slate',
label: '暗夜深灰',
description: '数据型暗模式,适合长时间盯盘的运营人员',
primary: '#3B82F6', 'primary-container': '#1E293B',
secondary: '#60A5FA', surface: '#0F172A',
'text-primary': '#F1F5F9', border: '#334155',
},
])
const current = ref<string>('')
const preview = ref<string>('') // 用户选中但未保存
const saving = ref(false)
const loading = ref(false)
const updateTime = ref('')
async function load() {
loading.value = true
try {
const res: any = await httpGet('/sys/openapi/theme/current')
if (res.code === 0) {
current.value = res.data.theme
preview.value = res.data.theme
updateTime.value = new Date().toLocaleString('zh-CN')
}
} catch (e) {
ElMessage.error('加载当前主题失败: ' + (e as Error).message)
} finally {
loading.value = false
}
}
async function save() {
if (!preview.value) return
saving.value = true
try {
const res: any = await httpPost(`/sys/openapi/theme/update?theme=${preview.value}`)
if (res.code === 0) {
current.value = preview.value
updateTime.value = new Date().toLocaleString('zh-CN')
ElMessage.success(`主题已切换为「${findByKey(preview.value)?.label}」,pc-前端-v2 下次访问将自动应用`)
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch (e: any) {
ElMessage.error('保存失败: ' + e.message)
} finally {
saving.value = false
}
}
function findByKey(key: string) {
return themes.value.find(t => t.key === key)
}
const dirty = computed(() => preview.value !== current.value)
onMounted(load)
</script>
<template>
<PageHeader title="主题配置" :sub="`当前生效主题 · ${findByKey(current)?.label || '加载中'} · 更新于 ${updateTime}`">
<div class="flex gap-2 items-center">
<el-tag v-if="dirty" type="warning" effect="plain">有未保存的更改</el-tag>
<el-button type="primary" :disabled="!dirty || saving" :loading="saving" @click="save">
保存并应用
</el-button>
</div>
</PageHeader>
<div v-loading="loading" class="grid grid-cols-1 lg:grid-cols-2 gap-5 mt-5">
<div
v-for="theme in themes"
:key="theme.key"
class="rounded-2xl border-2 transition-all cursor-pointer overflow-hidden hover:-translate-y-1 hover:shadow-xl"
:class="preview === theme.key
? 'border-primary shadow-lg shadow-primary/20'
: 'border-border'"
@click="preview = theme.key"
>
<!-- 预览头 -->
<div
class="h-24 flex items-center justify-between px-5 text-white"
:style="{ background: `linear-gradient(135deg, ${theme.primary}, ${theme.secondary})` }"
>
<div>
<div class="text-lg font-bold">{{ theme.label }}</div>
<div class="text-xs opacity-80 mt-1">{{ theme.key }}</div>
</div>
<div class="flex items-center gap-2">
<el-icon v-if="preview === theme.key" :size="22"><Check /></el-icon>
<el-tag v-if="current === theme.key" type="success" effect="dark" size="small">当前生效</el-tag>
</div>
</div>
<!-- Preview mock -->
<div class="p-5 space-y-3" :style="{ backgroundColor: theme.surface, color: theme['text-primary'] }">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg flex items-center justify-center text-white font-bold"
:style="{ backgroundColor: theme.primary }">M</div>
<div class="font-bold text-sm">LOGO 主品牌色</div>
</div>
<div class="text-xs opacity-70">{{ theme.description }}</div>
<div class="flex gap-2 mt-3">
<button class="px-4 py-1.5 rounded-lg text-xs font-bold text-white"
:style="{ backgroundColor: theme.primary }">主按钮</button>
<button class="px-4 py-1.5 rounded-lg text-xs font-bold border-2"
:style="{ borderColor: theme.primary, color: theme.primary }">描边按钮</button>
<span class="px-3 py-1 rounded text-xs font-bold"
:style="{ backgroundColor: theme['primary-container'], color: theme.primary }">标签</span>
</div>
<div class="text-[10px] opacity-50 pt-2 border-t" :style="{ borderColor: theme.border }">
HEX: {{ theme.primary }} · {{ theme.secondary }} · {{ theme['text-primary'] }}
</div>
</div>
</div>
</div>
<el-alert
class="mt-6"
type="info"
:closable="false"
title="关于主题切换"
description="点击主题卡片预览,保存后 pc-前端-v2 的所有页面(包括其他用户正在浏览)将在下次请求 / 重载时生效。无需重启服务。"
/>
</template>