From 2c5f53ec003a52d8ea1865e58e1436edf1082021 Mon Sep 17 00:00:00 2001
From: ouyangxq <775153912@qq.com>
Date: Wed, 12 Aug 2026 18:50:03 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/layouts/AppLayout.test.ts | 13 ++-
src/app/layouts/AppLayout.vue | 29 +++++--
src/app/router/index.test.ts | 13 +++
src/core/navigation/adminNavigation.test.ts | 8 ++
src/core/navigation/adminNavigation.ts | 7 +-
.../CustomerOverseasStorageFeePanel.vue | 2 +-
.../customers/overseasStorageFee.test.ts | 16 ++++
.../pages/OverseasStorageFeesView.vue | 6 +-
src/modules/iam/pages/RolesView.test.ts | 10 +++
src/modules/notifications/PROTOTYPE_MATRIX.md | 34 ++++----
.../api/notificationCenter.test.ts | 85 +++++++++++++++++++
.../notifications/api/notificationCenter.ts | 38 +++++++++
.../components/ContractPendingNotice.vue | 11 ---
.../components/MessageComposerDialog.vue | 82 ++++++++++++------
.../components/TemplateEditorDialog.vue | 65 +++++++-------
.../notifications/notifications.test.ts | 62 ++++++++++++++
.../pages/MessageManagementView.vue | 61 ++++---------
.../pages/MessageTemplatesView.vue | 47 +++-------
.../notifications/pages/MyMessagesView.vue | 53 +++---------
src/modules/notifications/routes.ts | 7 +-
src/modules/notifications/types.ts | 60 +++----------
.../pages/OceanBookingDetailView.test.ts | 6 ++
.../pages/OceanBookingDetailView.vue | 16 +++-
src/modules/ocean-bookings/types.ts | 2 +-
.../locations/pages/LocationsView.test.ts | 12 +++
.../pallets/pages/PalletsView.test.ts | 10 +++
.../warehouses/pages/WarehousesView.test.ts | 9 ++
src/shared/components/DataTable.test.ts | 26 +++++-
src/shared/components/DataTable.vue | 6 +-
29 files changed, 513 insertions(+), 283 deletions(-)
create mode 100644 src/modules/notifications/api/notificationCenter.test.ts
create mode 100644 src/modules/notifications/api/notificationCenter.ts
delete mode 100644 src/modules/notifications/components/ContractPendingNotice.vue
create mode 100644 src/modules/notifications/notifications.test.ts
diff --git a/src/app/layouts/AppLayout.test.ts b/src/app/layouts/AppLayout.test.ts
index f7554da9..c740c98e 100644
--- a/src/app/layouts/AppLayout.test.ts
+++ b/src/app/layouts/AppLayout.test.ts
@@ -7,6 +7,9 @@ import { ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import AppLayout from './AppLayout.vue'
+const { getUnreadCount } = vi.hoisted(() => ({ getUnreadCount: vi.fn() }))
+vi.mock('@/modules/notifications/api/notificationCenter', () => ({ getUnreadCount }))
+
const CachedPage = defineComponent({
setup() {
const count = ref(0)
@@ -26,31 +29,35 @@ async function mountLayout(path = '/') {
const auth = useAuthStore()
auth.token = 'token'
auth.user = { id: '1', name: '平台管理员', account: 'admin', roles: ['PLATFORM_ADMIN'] }
- auth.permissions = ['platform:warehouse:list']
+ auth.permissions = ['platform:warehouse:list', 'platform:notification:view']
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', name: 'home', component: { template: '
page
' }, meta: { requiresAuth: true, workspaceTab: false, keepAlive: false } },
{ path: '/customers', name: 'customers', component: CachedPage, meta: { title: '客户管理', showPageHeading: false, requiresAuth: true } },
{ path: '/warehouses', name: 'warehouses', component: { template: 'warehouses
' }, meta: { title: '仓库管理', requiresAuth: true } },
+ { path: '/notifications/mine', name: 'my-notifications', component: { template: 'messages
' }, meta: { title: '我的消息', requiresAuth: true } },
{ path: '/login', component: { template: 'login
' } },
],
})
await router.push(path)
await router.isReady()
const wrapper = mount(AppLayout, {
- global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '' }, WorkspaceTabs: { template: '' }, 'el-button': { emits: ['click'], template: '' } } },
+ global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '' }, WorkspaceTabs: { template: '' }, 'el-dropdown': { template: '
' }, 'el-dropdown-menu': { template: '
' }, 'el-dropdown-item': { emits: ['click'], template: '' }, 'el-button': { emits: ['click'], template: '' } } },
})
return { wrapper, router }
}
describe('AppLayout', () => {
- beforeEach(() => vi.mocked(ElMessageBox.confirm).mockReset())
+ beforeEach(() => { vi.mocked(ElMessageBox.confirm).mockReset(); getUnreadCount.mockReset().mockResolvedValue(4) })
it('shows the authenticated identity without a role preview switcher', async () => {
const { wrapper } = await mountLayout()
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
expect(wrapper.text()).not.toContain('权限预览')
+ await flushPromises()
+ expect(wrapper.get('[aria-label="消息中心,4 条未读"]').attributes('disabled')).toBeUndefined()
+ expect(wrapper.get('.notification-count').text()).toBe('4')
})
it('hides the shell heading when the route opts out', async () => {
diff --git a/src/app/layouts/AppLayout.vue b/src/app/layouts/AppLayout.vue
index 84f5f67a..52834104 100644
--- a/src/app/layouts/AppLayout.vue
+++ b/src/app/layouts/AppLayout.vue
@@ -1,5 +1,5 @@
@@ -129,14 +142,14 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
>
{{ pageTitle }}
{{ pageDescription }}
diff --git a/src/modules/iam/pages/RolesView.test.ts b/src/modules/iam/pages/RolesView.test.ts
index 10a1a56a..d40fe53e 100644
--- a/src/modules/iam/pages/RolesView.test.ts
+++ b/src/modules/iam/pages/RolesView.test.ts
@@ -72,6 +72,16 @@ function mountView(pinia = createPinia()) {
}
describe('RolesView', () => {
+ it('uses bordered striped tables and semantic role operation buttons', () => {
+ expect(rolesSource).toContain('border stripe')
+ expect(rolesSource).toContain('size="small" plain type="primary"')
+ expect(rolesSource).toContain('size="small" plain :type=')
+ expect(rolesSource).toContain('size="small" plain type="danger"')
+ expect(rolesSource).toContain('')
+ expect(rolesSource).toContain('')
+ expect(rolesSource).toContain('')
+ expect(rolesSource).toContain('@media (max-width: 720px)')
+ })
beforeEach(() => {
setActivePinia(createPinia())
vi.mocked(listPlatformRoles).mockReset()
diff --git a/src/modules/notifications/PROTOTYPE_MATRIX.md b/src/modules/notifications/PROTOTYPE_MATRIX.md
index 7a1896e0..d51500cd 100644
--- a/src/modules/notifications/PROTOTYPE_MATRIX.md
+++ b/src/modules/notifications/PROTOTYPE_MATRIX.md
@@ -1,20 +1,22 @@
# Admin 消息中心原型核对矩阵
-事实来源:`docs/悦/货代SAAS平台后台/货代SaaS平台原型.html`。本矩阵只记录原型已经明确的承载方式;后端契约与权限码尚未冻结,本模块当前不请求接口、不保存本地业务数据、不模拟操作成功。
+原型事实来源:`docs/悦/货代SAAS平台后台/货代SaaS平台原型.html`。接口事实来源:`iam/src/main/resources/openapi/notification-v1.yaml`。当前实现仅支持站内信,不扩展微信、邮件或短信渠道。
-| 页面 | 操作 | 原型证据 | 原型承载方式 | 原型尺寸/布局 | 当前骨架 | 结论 |
-|---|---|---|---|---|---|---|
-| 消息管理 | 列表/查询 | `page-msg-manage`,6040-6049 行 | 页面内表格 | 类型、状态、标题/内容筛选;新建与导出位于表格工具栏 | 正式路由 `/notifications/messages`,查询栏 + 空表格 | 按项目列表规范还原;操作列移至第一列 |
-| 消息管理 | 新建消息 | `modal-msg-create`,9229-9243 行;`showMsgCreate`,14661 行 | 居中弹框 | 最大宽度约 680px;基础信息、接收对象、定时发送;底部取消/草稿/立即发送 | 680px `AppDialog`,仅站内信,提交按钮禁用 | 保留原型承载;真实接收对象、草稿及发送等待契约 |
-| 消息管理 | 立即/定时发送 | `sendMsg`,14678 行;定时控件 9241 行 | 新建消息弹框内 | 定时开关 + 日期时间;立即发送按钮 | 控件可核对,提交禁用 | 等待发送命令、幂等及定时规则契约 |
-| 消息管理 | 草稿 | `saveMsgAsDraft`,14697 行 | 新建消息弹框内 | 独立“存为草稿”按钮 | 按钮保留但禁用 | 等待草稿创建/更新契约 |
-| 消息管理 | 详情 | `renderMsgManage`,14588 行仅有“详情”按钮 | 未定义 | 未定义 | 列表操作边界预留,不创建容器 | 待产品确认弹框/抽屉/独立页面 |
-| 消息管理 | 发送统计 | 表格“已读/总数”列,6048 行 | 未定义 | 仅有聚合列,无统计操作或详情容器 | 统计列预留 | 等待统计字段和承载方式确认 |
-| 模板管理 | 列表/查询 | `page-msg-template`,6052-6060 行 | 页面内表格 | CODE、内容筛选;新增/导入/导出位于表格工具栏 | 正式路由 `/notifications/templates`,查询栏 + 空表格 | 操作列移至第一列;导入导出因契约缺失禁用 |
-| 模板管理 | 新增模板 | `modal-msg-template-create`,9246-9274 行 | 居中弹框 | 最大宽度约 760px;变量快捷插入、字符计数、内容预览 | 760px `AppDialog`;只保留站内系统模板 | 保留原型承载,不扩展微信/邮件/短信真实渠道 |
-| 模板管理 | 编辑模板 | `renderMsgTemplate`,14594 行仅有“编辑”按钮 | 未定义 | 未定义 | 列表操作边界预留,不复用新增弹框作推断 | 待产品确认承载方式与不可变字段规则 |
-| 我的消息 | 列表/查询 | `page-msg-my`,6063-6071 行 | 页面内表格 | 状态、类型筛选;全部已读/清空已读位于表格工具栏 | 正式路由 `/notifications/mine`,查询栏 + 空表格 | 操作列移至第一列;批量操作禁用 |
-| 我的消息 | 查看 | `renderMsgMy`,14656 行仅有“查看”按钮 | 未定义 | 未定义 | 列表操作边界预留,不创建容器 | 待产品确认承载方式 |
-| 我的消息 | 标为已读/清空 | 6064、14656 行 | 列表按钮 | 单条、全部标为已读及清空已读 | 按钮预留并禁用 | 等待读状态、批量命令与删除语义契约 |
-| 顶部入口 | 未读入口/三页跳转 | `topMessageMenu`,838-847 行 | 顶部下拉菜单 | 铃铛、“消息中心”、未读徽标;消息管理/模板管理/我的消息三项 | 顶部下拉骨架,未读显示“--”,菜单禁用 | 等待未读汇总接口及页面权限码后启用 |
+| 页面 | 操作 | 原型承载方式 | 当前实现 | 契约差异与结论 |
+|---|---|---|---|---|
+| 消息管理 | 列表/查询 | `page-msg-manage` 页面内表格 | `/notifications/messages` 展示通知任务,支持任务号/标题、分类、状态查询;操作列固定第一列 | 冻结契约只提供任务列表,未提供按消息正文管理的列表;按任务 DTO 展示任务号、接收人数、定时/发送时间与状态 |
+| 消息管理 | 新建消息 | `modal-msg-create`,约 680px 居中弹框 | 680px `AppDialog`;选择激活模板、平台员工/指定企业范围、接收对象、模板变量 | `tenantId` 仅在适配边界作为兼容字段;页面统一使用“企业/客户”文案 |
+| 消息管理 | 立即/定时发送 | 新建消息弹框内 | 调用 `POST /platform/v1/notifications`;立即发送传 `scheduledAt=null`,定时发送传 ISO 时间;生成幂等键 | 契约已覆盖两种发送方式;仅创建任务,不静默模拟发送成功 |
+| 消息管理 | 草稿 | 新建消息弹框内“存为草稿” | 不展示 | 冻结契约没有消息草稿创建、更新或查询 API,不能提供伪草稿 |
+| 消息管理 | 取消/重试 | 原型未明确 | CREATED/SCHEDULED 可取消,RETRYING/DEAD 可重试 | 按冻结任务 API 与状态约束补充,分别受 `task:cancel`、`task:retry` 权限控制 |
+| 消息管理 | 详情/发送统计 | 原型仅有详情按钮和“已读/总数”聚合列,未定义容器 | 不展示操作或伪统计 | 冻结契约无任务详情、收件人明细、已读统计 API;待后端补充契约后再承载 |
+| 模板管理 | 列表/查询 | `page-msg-template` 页面内表格 | `/notifications/templates` 查询 CODE、分类、名称/标题、状态;操作列固定第一列 | 对接模板分页 API;原型导入/导出因无 API 不展示 |
+| 模板管理 | 新增模板 | `modal-msg-template-create`,约 760px 居中弹框 | 760px `AppDialog`;CODE、名称、分类、标题/正文、变量白名单、预览;保存为 DRAFT | 仅支持 `IN_APP`,不展示真实渠道选择 |
+| 模板管理 | 编辑模板 | 原型仅有编辑按钮,未定义容器 | 复用同尺寸模板弹框,仅 DRAFT 可编辑,CODE 不可变并携带 version | 冻结契约明确只允许编辑草稿,复用表单避免重复交互 |
+| 模板管理 | 状态流转 | 原型未定义 | DRAFT 可激活,ACTIVE 可禁用,DISABLED 无操作 | 严格执行 `DRAFT -> ACTIVE -> DISABLED`;禁用后不可恢复 |
+| 我的消息 | 列表/查询 | `page-msg-my` 页面内表格 | `/notifications/mine` 查询当前平台员工收件箱,支持状态、分类筛选;操作列固定第一列 | 使用 `platform` app,服务端从登录上下文隔离,不传企业 ID |
+| 我的消息 | 查看/标为已读 | 原型仅有查看按钮;支持单条和全部已读 | 680px 居中详情弹框;打开未读详情自动标记;支持单条和全部标为已读 | 对接 detail/read/read-all;变更后刷新顶部未读数 |
+| 我的消息 | 清空已读 | 原型列表按钮 | 不展示 | 首期没有删除或清空已读 API,不能展示可点击后报错的入口 |
+| 顶部入口 | 未读入口/三页跳转 | `topMessageMenu` 顶部下拉 | 有收件箱权限时加载真实未读数;按任务/模板权限展示管理入口,始终提供“我的消息” | 对接 `/platform/v1/notifications/unread-count`;加载失败显示异常标记并保留可访问入口 |
+接口依赖:所有响应经 `{code,data}` 解包;分页统一读取 `records/page/size/total/pages`;响应中的 `int64` ID 在前端保持字符串。写请求中的企业、用户和成员 ID 也在类型适配边界保持数字字符串,避免 JavaScript 大整数精度损失,由服务端 Long DTO 解析。
diff --git a/src/modules/notifications/api/notificationCenter.test.ts b/src/modules/notifications/api/notificationCenter.test.ts
new file mode 100644
index 00000000..4b4a5de3
--- /dev/null
+++ b/src/modules/notifications/api/notificationCenter.test.ts
@@ -0,0 +1,85 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { http } from '@/shared/api/http'
+import {
+ cancelTask,
+ changeTemplateStatus,
+ createNotification,
+ getInboxDetail,
+ getUnreadCount,
+ listInbox,
+ listTasks,
+ listTemplates,
+ markAllInboxRead,
+ markInboxRead,
+ retryTask,
+ updateTemplate,
+} from './notificationCenter'
+
+vi.mock('@/shared/api/http', () => ({
+ http: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
+ unwrapApiResponse: (response: { data: T }) => response.data,
+}))
+
+const envelope = (data: T) => ({ code: '200', data })
+const inbox = { id: '9007199254740993', category: 'ORDER', title: '已发货', status: 'UNREAD' as const, sentAt: '2026-08-12T10:00:00Z', readAt: null }
+const template = { id: 12, templateCode: 'ORDER_SENT', templateName: '订单已发货', category: 'ORDER', channel: 'IN_APP' as const, titleTemplate: '已发货', bodyTemplate: '正文', variableWhitelist: [], status: 'DRAFT' as const, version: 1 }
+const task = { id: 13, taskNo: 'NT-13', category: 'ORDER', title: '已发货', status: 'CREATED' as const, recipientCount: 2, scheduledAt: null, sentAt: null, duplicated: false }
+
+describe('notificationCenter API', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ it('uses the platform inbox paths and normalizes envelope pagination and ids', async () => {
+ vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [inbox], page: '1', size: '20', total: '1', pages: '1' }))
+ const result = await listInbox({ page: 1, size: 20, status: 'UNREAD' })
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/notifications', { params: { page: 1, size: 20, status: 'UNREAD' } })
+ expect(result).toMatchObject({ page: 1, size: 20, total: 1, pages: 1 })
+ expect(result.records[0]?.id).toBe(String(inbox.id))
+
+ vi.mocked(http.get).mockResolvedValueOnce(envelope({ unreadCount: '7' }))
+ expect(await getUnreadCount()).toBe(7)
+ expect(http.get).toHaveBeenLastCalledWith('/platform/v1/notifications/unread-count')
+ })
+
+ it('gets and marks inbox messages without a request body', async () => {
+ vi.mocked(http.get).mockResolvedValueOnce(envelope({ ...inbox, body: '正文', sourceType: null, sourceId: null }))
+ vi.mocked(http.put)
+ .mockResolvedValueOnce(envelope({ ...inbox, body: '正文', sourceType: null, sourceId: null }))
+ .mockResolvedValueOnce(envelope({ updatedCount: '3' }))
+ await getInboxDetail('10/20')
+ await markInboxRead('10/20')
+ expect(await markAllInboxRead()).toBe(3)
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/notifications/10%2F20')
+ expect(http.put).toHaveBeenNthCalledWith(1, '/platform/v1/notifications/10%2F20/read')
+ expect(http.put).toHaveBeenNthCalledWith(2, '/platform/v1/notifications/read-all')
+ })
+
+ it('posts the frozen create-notification DTO unchanged', async () => {
+ vi.mocked(http.post).mockResolvedValueOnce(envelope(task))
+ const payload = { tenantId: '0', templateCode: 'ORDER_SENT', recipientType: 'PLATFORM_ROLE' as const, roleCode: 'PLATFORM_ADMIN', variables: {}, scheduledAt: null, idempotencyKey: 'notification-1' }
+ expect((await createNotification(payload)).id).toBe('13')
+ expect(http.post).toHaveBeenCalledWith('/platform/v1/notifications', payload)
+ })
+
+ it('uses template list, update, and one-way status paths', async () => {
+ vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [template], page: 1, size: 20, total: 1, pages: 1 }))
+ vi.mocked(http.put).mockResolvedValue(envelope(template))
+ await listTemplates({ page: 1, size: 20, status: 'DRAFT' })
+ const update = { templateName: '新名称', category: 'ORDER', titleTemplate: '标题', bodyTemplate: '正文', variableWhitelist: [], version: 1 }
+ await updateTemplate('12', update)
+ await changeTemplateStatus('12', 'ACTIVE', 1)
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/notification-templates', { params: { page: 1, size: 20, status: 'DRAFT' } })
+ expect(http.put).toHaveBeenNthCalledWith(1, '/platform/v1/notification-templates/12', update)
+ expect(http.put).toHaveBeenNthCalledWith(2, '/platform/v1/notification-templates/12/status', { targetStatus: 'ACTIVE', version: 1 })
+ })
+
+ it('uses task list, cancel, and retry paths without request bodies', async () => {
+ vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [task], page: 1, size: 20, total: 1, pages: 1 }))
+ vi.mocked(http.put).mockResolvedValue(envelope(task))
+ await listTasks({ page: 1, size: 20, status: 'CREATED' })
+ await cancelTask('13')
+ await retryTask('13')
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/notification-tasks', { params: { page: 1, size: 20, status: 'CREATED' } })
+ expect(http.put).toHaveBeenNthCalledWith(1, '/platform/v1/notification-tasks/13/cancel')
+ expect(http.put).toHaveBeenNthCalledWith(2, '/platform/v1/notification-tasks/13/retry')
+ })
+})
diff --git a/src/modules/notifications/api/notificationCenter.ts b/src/modules/notifications/api/notificationCenter.ts
new file mode 100644
index 00000000..4b71a4b8
--- /dev/null
+++ b/src/modules/notifications/api/notificationCenter.ts
@@ -0,0 +1,38 @@
+import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+import type { CreateNotificationRequest, CreateTemplateRequest, InboxDetail, InboxItem, InboxStatus, NotificationTask, NotificationTaskStatus, NotificationTemplate, PageResult, TemplateStatus, UpdateTemplateRequest } from '../types'
+
+type RawId = string | number
+type RawPage = Omit, 'page' | 'size' | 'total' | 'pages'> & { page: RawId; size: RawId; total: RawId; pages: RawId }
+type RawInbox = Omit & { id: RawId }
+type RawInboxDetail = Omit & { id: RawId }
+type RawTemplate = Omit & { id: RawId }
+type RawTask = Omit & { id: RawId }
+
+const inboxPath = '/platform/v1/notifications'
+const templatePath = '/platform/v1/notification-templates'
+const taskPath = '/platform/v1/notification-tasks'
+const id = (value: RawId) => String(value)
+const page = (value: RawPage): PageResult => ({ ...value, page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) })
+const inbox = (value: RawInbox): InboxItem => ({ ...value, id: id(value.id) })
+const inboxDetail = (value: RawInboxDetail): InboxDetail => ({ ...value, id: id(value.id) })
+const template = (value: RawTemplate): NotificationTemplate => ({ ...value, id: id(value.id) })
+const task = (value: RawTask): NotificationTask => ({ ...value, id: id(value.id) })
+
+export async function listInbox(query: { page: number; size: number; status?: InboxStatus; category?: string }) {
+ const response = await http.get>, ApiEnvelope>>(inboxPath, { params: query })
+ const data = unwrapApiResponse(response)
+ return page({ ...data, records: data.records.map(inbox) })
+}
+export async function getUnreadCount() { const response = await http.get, ApiEnvelope<{ unreadCount: RawId }>>(`${inboxPath}/unread-count`); return Number(unwrapApiResponse(response).unreadCount) }
+export async function getInboxDetail(messageId: string) { const response = await http.get, ApiEnvelope>(`${inboxPath}/${encodeURIComponent(messageId)}`); return inboxDetail(unwrapApiResponse(response)) }
+export async function markInboxRead(messageId: string) { const response = await http.put, ApiEnvelope>(`${inboxPath}/${encodeURIComponent(messageId)}/read`); return inboxDetail(unwrapApiResponse(response)) }
+export async function markAllInboxRead() { const response = await http.put, ApiEnvelope<{ updatedCount: RawId }>>(`${inboxPath}/read-all`); return Number(unwrapApiResponse(response).updatedCount) }
+
+export async function createNotification(payload: CreateNotificationRequest) { const response = await http.post, ApiEnvelope>(inboxPath, payload); return task(unwrapApiResponse(response)) }
+export async function listTemplates(query: { page: number; size: number; templateCode?: string; category?: string; title?: string; status?: TemplateStatus }) { const response = await http.get>, ApiEnvelope>>(templatePath, { params: query }); const data = unwrapApiResponse(response); return page({ ...data, records: data.records.map(template) }) }
+export async function createTemplate(payload: CreateTemplateRequest) { const response = await http.post, ApiEnvelope>(templatePath, payload); return template(unwrapApiResponse(response)) }
+export async function updateTemplate(templateId: string, payload: UpdateTemplateRequest) { const response = await http.put, ApiEnvelope>(`${templatePath}/${encodeURIComponent(templateId)}`, payload); return template(unwrapApiResponse(response)) }
+export async function changeTemplateStatus(templateId: string, targetStatus: Exclude, version: number) { const response = await http.put, ApiEnvelope>(`${templatePath}/${encodeURIComponent(templateId)}/status`, { targetStatus, version }); return template(unwrapApiResponse(response)) }
+export async function listTasks(query: { page: number; size: number; taskNo?: string; category?: string; title?: string; status?: NotificationTaskStatus }) { const response = await http.get>, ApiEnvelope>>(taskPath, { params: query }); const data = unwrapApiResponse(response); return page({ ...data, records: data.records.map(task) }) }
+export async function cancelTask(taskId: string) { const response = await http.put, ApiEnvelope>(`${taskPath}/${encodeURIComponent(taskId)}/cancel`); return task(unwrapApiResponse(response)) }
+export async function retryTask(taskId: string) { const response = await http.put, ApiEnvelope>(`${taskPath}/${encodeURIComponent(taskId)}/retry`); return task(unwrapApiResponse(response)) }
diff --git a/src/modules/notifications/components/ContractPendingNotice.vue b/src/modules/notifications/components/ContractPendingNotice.vue
deleted file mode 100644
index 5fe7cd5c..00000000
--- a/src/modules/notifications/components/ContractPendingNotice.vue
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- 当前页面仅用于核对原型布局与交互承载。列表不会加载演示数据,写操作不会模拟成功。
-
-
-
diff --git a/src/modules/notifications/components/MessageComposerDialog.vue b/src/modules/notifications/components/MessageComposerDialog.vue
index 8af7a86d..d28207f4 100644
--- a/src/modules/notifications/components/MessageComposerDialog.vue
+++ b/src/modules/notifications/components/MessageComposerDialog.vue
@@ -1,41 +1,69 @@
-
-
+
+ {{ error.message }}
-
-
-
-
-
-
-
-
-
-
-
- 启用
+
+
+
+
+
+
+ 启用
- 取消存为草稿{{ scheduled ? '确认定时发送' : '立即发送' }}
+ 取消{{ scheduled ? '确认定时发送' : '立即发送' }}
-
diff --git a/src/modules/notifications/components/TemplateEditorDialog.vue b/src/modules/notifications/components/TemplateEditorDialog.vue
index 591b4422..630fdb30 100644
--- a/src/modules/notifications/components/TemplateEditorDialog.vue
+++ b/src/modules/notifications/components/TemplateEditorDialog.vue
@@ -1,50 +1,47 @@
-
-
-
-
- 仅支持字母、数字和下划线
-
-
-
-
- 常用变量{{ item.label }}
-
-
- 取消保存模板
+
+ {{ error.message }}
+
+
常用变量{{ name }}
+ 取消保存模板
diff --git a/src/modules/notifications/notifications.test.ts b/src/modules/notifications/notifications.test.ts
new file mode 100644
index 00000000..fcb5b021
--- /dev/null
+++ b/src/modules/notifications/notifications.test.ts
@@ -0,0 +1,62 @@
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { mount } from '@vue/test-utils'
+import { describe, expect, it } from 'vitest'
+import MessageComposerDialog from './components/MessageComposerDialog.vue'
+import TemplateEditorDialog from './components/TemplateEditorDialog.vue'
+
+const source = (path: string) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
+
+const dialogStubs = {
+ AppDialog: { props: ['width'], template: '
' },
+ ApiErrorMeta: true,
+ PlatformTenantSelector: true,
+ 'el-alert': true,
+ 'el-form': { template: '' },
+ 'el-form-item': { template: '' },
+ 'el-segmented': true,
+ 'el-select': { template: '' },
+ 'el-option': true,
+ 'el-input': true,
+ 'el-switch': true,
+ 'el-date-picker': true,
+ 'el-button': { props: ['disabled'], template: '' },
+}
+
+describe('notification center contract UI', () => {
+ it.each([
+ './pages/MessageManagementView.vue',
+ './pages/MessageTemplatesView.vue',
+ './pages/MyMessagesView.vue',
+ ])('keeps actions as the first table column in %s', (path) => {
+ const content = source(path)
+ expect(content).toMatch(/const columns:[^=]+\s*=\s*\[\{key:'actions',label:'操作'/)
+ })
+
+ it('does not expose unsupported clear-read or disabled-template restore actions', () => {
+ expect(source('./pages/MyMessagesView.vue')).not.toContain('清空已读')
+ const templates = source('./pages/MessageTemplatesView.vue')
+ expect(templates).toContain("status!=='DISABLED'")
+ expect(templates).toContain("status==='DRAFT'?'激活':'禁用'")
+ })
+
+ it('uses the prototype composer width and exposes the real send command', () => {
+ const wrapper = mount(MessageComposerDialog, {
+ props: { modelValue: true, templates: [] },
+ global: { stubs: dialogStubs },
+ })
+ expect(wrapper.get('.dialog').attributes('data-width')).toContain('680px')
+ expect(wrapper.findAll('button').some((button) => button.text() === '立即发送' && !button.attributes('disabled'))).toBe(true)
+ expect(wrapper.text()).not.toContain('存为草稿')
+ })
+
+ it('uses the prototype editor width and exposes the real save command', () => {
+ const wrapper = mount(TemplateEditorDialog, {
+ props: { modelValue: true },
+ global: { stubs: dialogStubs },
+ })
+ expect(wrapper.get('.dialog').attributes('data-width')).toContain('760px')
+ const save = wrapper.findAll('button').find((button) => button.text() === '保存模板')
+ expect(save?.attributes('disabled')).toBeUndefined()
+ })
+})
diff --git a/src/modules/notifications/pages/MessageManagementView.vue b/src/modules/notifications/pages/MessageManagementView.vue
index 37186cd4..3337c2dc 100644
--- a/src/modules/notifications/pages/MessageManagementView.vue
+++ b/src/modules/notifications/pages/MessageManagementView.vue
@@ -1,46 +1,19 @@
-
-
-
-
-
-
-
- 新建消息导出Excel
- 共 0 条消息
- 详情发送
-
-
-
-
-
-
+新建消息共 {{ result.total }} 个任务取消重试
{{formatDate((row as NotificationTask).scheduledAt)}}{{formatDate((row as NotificationTask).sentAt)}}{{statusLabel((row as NotificationTask).status)}}
+
diff --git a/src/modules/notifications/pages/MessageTemplatesView.vue b/src/modules/notifications/pages/MessageTemplatesView.vue
index 33da458a..e0093c9f 100644
--- a/src/modules/notifications/pages/MessageTemplatesView.vue
+++ b/src/modules/notifications/pages/MessageTemplatesView.vue
@@ -1,37 +1,14 @@
-
-
-
-
-
-
-
- 新增模板导出导入
- 共 0 个模板
- 编辑
-
-
-
-
+新增模板共 {{result.total}} 个模板编辑{{(row as NotificationTemplate).status==='DRAFT'?'激活':'禁用'}} {{statusLabel((row as NotificationTemplate).status)}}
+
diff --git a/src/modules/notifications/pages/MyMessagesView.vue b/src/modules/notifications/pages/MyMessagesView.vue
index 87f51d61..933fb5c2 100644
--- a/src/modules/notifications/pages/MyMessagesView.vue
+++ b/src/modules/notifications/pages/MyMessagesView.vue
@@ -1,41 +1,16 @@
-
-
-
-
-
-
-
- 全部标为已读清空已读
- 共 0 条消息
- 查看标为已读
-
-
-
-
-
-
+全部标为已读共 {{result.total}} 条消息查看标为已读
{{formatDate((row as InboxItem).sentAt)}}{{(row as InboxItem).status==='UNREAD'?'未读':'已读'}}{{detail.body}}
- 来源类型
- {{detail.sourceType||'—'}}
- 来源标识
- {{detail.sourceId||'—'}}
+
diff --git a/src/modules/notifications/routes.ts b/src/modules/notifications/routes.ts
index 696b2560..07ef4d0c 100644
--- a/src/modules/notifications/routes.ts
+++ b/src/modules/notifications/routes.ts
@@ -5,19 +5,18 @@ export const notificationRoutes = [
path: 'notifications/messages',
name: 'notification-messages',
component: () => import('@/modules/notifications/pages/MessageManagementView.vue'),
- meta: { title: '消息管理', description: '创建并管理平台站内消息' },
+ meta: { title: '消息管理', description: '创建并管理平台站内消息', permission: 'platform:notification:task:list' },
},
{
path: 'notifications/templates',
name: 'notification-templates',
component: () => import('@/modules/notifications/pages/MessageTemplatesView.vue'),
- meta: { title: '模板管理', description: '维护平台站内消息模板' },
+ meta: { title: '模板管理', description: '维护平台站内消息模板', permission: 'platform:notification:template:list' },
},
{
path: 'notifications/mine',
name: 'my-notifications',
component: () => import('@/modules/notifications/pages/MyMessagesView.vue'),
- meta: { title: '我的消息', description: '查看当前平台员工收到的站内消息' },
+ meta: { title: '我的消息', description: '查看当前平台员工收到的站内消息', permission: 'platform:notification:view' },
},
] satisfies RouteRecordRaw[]
-
diff --git a/src/modules/notifications/types.ts b/src/modules/notifications/types.ts
index 33d93f16..48c36f8c 100644
--- a/src/modules/notifications/types.ts
+++ b/src/modules/notifications/types.ts
@@ -1,50 +1,14 @@
-export type NotificationMessageType = 'SYSTEM' | 'BUSINESS' | 'ALERT' | 'TASK'
-export type NotificationMessageStatus = 'DRAFT' | 'SCHEDULED' | 'SENT' | 'CANCELLED'
-export type NotificationReadStatus = 'UNREAD' | 'READ'
+export type InboxStatus = 'UNREAD' | 'READ'
+export type TemplateStatus = 'DRAFT' | 'ACTIVE' | 'DISABLED'
+export type NotificationTaskStatus = 'CREATED' | 'SCHEDULED' | 'DISPATCHING' | 'RETRYING' | 'SENT' | 'DEAD' | 'CANCELLED'
+export type RecipientType = 'USER' | 'MEMBERSHIP' | 'TENANT_ROLE' | 'TENANT_ALL' | 'PLATFORM_ROLE'
-export interface NotificationMessageListItem {
- id: string
- title: string
- type: NotificationMessageType
- recipientSummary: string
- sentAt: string | null
- readCount: number | null
- recipientCount: number | null
- status: NotificationMessageStatus
-}
+export interface PageResult { records: T[]; page: number; size: number; total: number; pages: number }
+export interface InboxItem { id: string; category: string; title: string; status: InboxStatus; sentAt: string; readAt: string | null }
+export interface InboxDetail extends InboxItem { body: string; sourceType: string | null; sourceId: string | null }
+export interface NotificationTemplate { id: string; templateCode: string; templateName: string; category: string; channel: 'IN_APP'; titleTemplate: string; bodyTemplate: string; variableWhitelist: string[]; status: TemplateStatus; version: number }
+export interface NotificationTask { id: string; taskNo: string; category: string; title: string; status: NotificationTaskStatus; recipientCount: number; scheduledAt: string | null; sentAt: string | null; duplicated: boolean }
-export interface NotificationInboxItem {
- id: string
- title: string
- summary: string
- type: NotificationMessageType
- sentAt: string
- readStatus: NotificationReadStatus
-}
-
-export interface NotificationTemplateListItem {
- id: string
- code: string
- title: string
- content: string
- type: 'SYSTEM'
-}
-
-export interface NotificationListResult {
- records: T[]
- total: number
- pages: number
-}
-
-export interface NotificationComposerModel {
- type: NotificationMessageType
- priority: 'NORMAL' | 'IMPORTANT' | 'URGENT'
- title: string
- content: string
- recipientScope: string
- recipientIds: string[]
- scheduledAt: string | null
-}
-
-// These view models deliberately contain no endpoint paths or transport DTO names.
-// Frozen backend DTOs will be mapped at the future api/ boundary instead of leaking into pages.
+export interface CreateNotificationRequest { tenantId: string; templateCode: string; recipientType: RecipientType; recipientIds?: string[]; roleCode?: string; variables: Record; scheduledAt: string | null; idempotencyKey: string; sourceEventId?: string | null }
+export interface CreateTemplateRequest { templateCode: string; templateName: string; category: string; titleTemplate: string; bodyTemplate: string; variableWhitelist: string[] }
+export interface UpdateTemplateRequest extends Omit { version: number }
diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts
index 57bb9e2e..15a97d10 100644
--- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts
+++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts
@@ -48,6 +48,12 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).toContain('v-model="container.capacityCbm"')
expect(source).toContain('v-model="container.maxWeightKg"')
expect(source).toContain('releaseOceanBooking(id.value, releaseRequest)')
+ expect(source).toContain('capacityCbm: null')
+ expect(source).toContain('maxWeightKg: null')
+ expect(source).toContain('function validateContainerRequirements()')
+ expect(source).toContain('容量、限重和数量必须大于 0')
+ expect(source).not.toContain('capacityCbm: 68')
+ expect(source).not.toContain('maxWeightKg: 28000')
})
it('keeps view, create and edit on the formal detail route with a return action', () => {
diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue
index d509caf2..707630ba 100644
--- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue
+++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue
@@ -85,7 +85,7 @@ function blankParties(): OceanBookingParty[] {
function blankData(): OceanBookingDraftData {
return {
customerId: '', customerName: '', channelCode: '', channelName: '', warehouseId: '', warehouseName: '', parties: blankParties(),
- containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }],
+ containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', capacityCbm: null, maxWeightKg: null, quantity: 1 }],
}
}
const form = reactive({ expectedVersion: 0, data: blankData() })
@@ -151,7 +151,7 @@ function applyDetail(value: OceanBookingDetail) {
estimatedSailingAt: value.estimatedSailingAt, estimatedArrivalAt: value.estimatedArrivalAt,
remark: value.remark,
parties: value.parties.length ? value.parties.map(({ partyType, partyName, contactName, contactPhone, address }) => ({ partyType, partyName, contactName, contactPhone, address })) : blankParties(),
- containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ containerType, containerKind, capacityCbm, maxWeightKg, quantity }) => ({ containerType, containerKind, capacityCbm, maxWeightKg, quantity })) : [{ containerType: '40HC', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }],
+ containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ containerType, containerKind, capacityCbm, maxWeightKg, quantity }) => ({ containerType, containerKind, capacityCbm: capacityCbm ?? null, maxWeightKg: maxWeightKg ?? null, quantity: quantity ?? null })) : [{ containerType: '40HC', containerKind: 'FCL', capacityCbm: null, maxWeightKg: null, quantity: 1 }],
}
}
async function load() {
@@ -169,6 +169,7 @@ async function save() {
actionError.value = { message: '发货人、收货人和通知人名称均为必填项' }
return
}
+ if (!validateContainerRequirements()) return
saving.value = true
actionError.value = null
try {
@@ -214,6 +215,7 @@ async function submitChange() {
async function runAction(action: OceanBookingAction) {
if (!detail.value || action === 'change-request') { if (action === 'change-request') openChangeDialog(); return }
actionError.value = null
+ if (!validateContainerRequirements()) return
try {
const reason = await requiredReason(action)
await ElMessageBox.confirm(`确认执行“${actionLabels[action]}”吗?`, actionLabels[action], { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' })
@@ -227,7 +229,15 @@ async function runAction(action: OceanBookingAction) {
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause)
} finally { actionSaving.value = false }
}
-function addContainer() { form.data.containerRequirements.push({ containerType: '40HC', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }) }
+function validateContainerRequirements() {
+ const invalid = form.data.containerRequirements.find((container) => !(Number(container.capacityCbm) > 0) || !(Number(container.maxWeightKg) > 0) || !(Number(container.quantity) > 0))
+ if (invalid) {
+ actionError.value = { message: `箱型 ${invalid.containerType || '未命名'} 的容量、限重和数量必须大于 0,请补充后再提交` }
+ return false
+ }
+ return true
+}
+function addContainer() { form.data.containerRequirements.push({ containerType: '40HC', containerKind: 'FCL', capacityCbm: null, maxWeightKg: null, quantity: 1 }) }
function removeContainer(index: number) { if (form.data.containerRequirements.length > 1) form.data.containerRequirements.splice(index, 1) }
onMounted(() => { void load(); void loadOptions() })
diff --git a/src/modules/ocean-bookings/types.ts b/src/modules/ocean-bookings/types.ts
index 8f71e39b..9de849fb 100644
--- a/src/modules/ocean-bookings/types.ts
+++ b/src/modules/ocean-bookings/types.ts
@@ -1,7 +1,7 @@
export type OceanBookingStatus = 'DRAFT' | 'SUBMITTED' | 'CONFIRMED' | 'RELEASED' | 'FAILED' | 'CHANGE_PENDING' | 'CANCELLED'
export interface OceanBookingParty { id?: string; partyType: string; partyName: string; contactName?: string | null; contactPhone?: string | null; address?: string | null }
-export interface OceanBookingContainer { id?: string; containerType: string; quantity: number; containerKind?: string | null; capacityCbm: number; maxWeightKg: number }
+export interface OceanBookingContainer { id?: string; containerType: string; quantity: number | null; containerKind?: string | null; capacityCbm: number | null; maxWeightKg: number | null }
export interface OceanBookingContainerAllocation extends OceanBookingContainer { allocatedQuantity?: number | null }
export interface OceanBookingDraftData {
customerId: string; customerName: string; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainer[]
diff --git a/src/modules/warehouse-resources/locations/pages/LocationsView.test.ts b/src/modules/warehouse-resources/locations/pages/LocationsView.test.ts
index 6ffe9239..fe733060 100644
--- a/src/modules/warehouse-resources/locations/pages/LocationsView.test.ts
+++ b/src/modules/warehouse-resources/locations/pages/LocationsView.test.ts
@@ -142,6 +142,18 @@ enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('LocationsView', () => {
+ it('uses bordered striped tables and explicit semantic row actions', () => {
+ expect(locationsSource).toContain('border')
+ expect(locationsSource).toContain('stripe')
+ expect(locationsSource).toContain('size="small" plain type="primary"')
+ expect(locationsSource).toMatch(/size="small"\s+plain\s+:type=/)
+ expect(locationsSource).toMatch(/size="small"\s+plain\s+type="danger"/)
+ expect(locationsSource).toContain('')
+ expect(locationsSource).toContain('')
+ expect(locationsSource).toContain('')
+ expect(locationsSource).toContain('')
+ expect(locationsSource).toContain('@media (max-width: 720px)')
+ })
it('uses the prototype-sized centered editor while retaining the detail drawer', () => {
expect(locationsSource).toContain("drawerMode.value === 'view' ? AppDrawer : AppDialog")
expect(locationsSource).toContain("width: 'min(560px, calc(100vw - 28px))'")
diff --git a/src/modules/warehouse-resources/pallets/pages/PalletsView.test.ts b/src/modules/warehouse-resources/pallets/pages/PalletsView.test.ts
index 4b79eb93..65aff93a 100644
--- a/src/modules/warehouse-resources/pallets/pages/PalletsView.test.ts
+++ b/src/modules/warehouse-resources/pallets/pages/PalletsView.test.ts
@@ -112,6 +112,16 @@ enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('PalletsView', () => {
+ it('uses bordered striped tables and explicit semantic row actions', () => {
+ expect(palletsSource).toContain('border')
+ expect(palletsSource).toContain('stripe')
+ expect(palletsSource).toContain('size="small" plain type="primary"')
+ expect(palletsSource).toMatch(/size="small"\s+plain\s+type="danger"/)
+ expect(palletsSource).toContain('')
+ expect(palletsSource).toContain('')
+ expect(palletsSource).toContain('')
+ expect(palletsSource).toContain('@media (max-width: 720px)')
+ })
it('uses the prototype-sized centered editor while retaining the detail drawer', () => {
expect(palletsSource).toContain(' vi.unstubAllGlobals())
describe('WarehousesView', () => {
+ it('uses bordered striped tables and semantic operation buttons', () => {
+ expect(warehousesSource).toContain('border')
+ expect(warehousesSource).toContain('stripe')
+ expect(warehousesSource).toContain('size="small" plain type="primary"')
+ expect(warehousesSource).toMatch(/size="small"\s+plain\s+:type=/)
+ expect(warehousesSource).toContain('')
+ expect(warehousesSource).toContain('')
+ expect(warehousesSource).toContain('@media (max-width: 720px)')
+ })
it('uses the prototype-sized centered warehouse editor with a fixed footer', () => {
expect(warehousesSource).toContain('', methods: { doLayout() {} } })
+const TableStub = defineComponent({
+ props: { border: Boolean, stripe: Boolean },
+ template: '
',
+ methods: { doLayout() {} },
+})
const ColumnStub = defineComponent({
inheritAttrs: false,
props: { label: String, prop: String },
@@ -69,6 +73,26 @@ describe('DataTable pagination helpers', () => {
describe('DataTable column preferences', () => {
beforeEach(() => localStorage.clear())
+ it('enables bordered striped business tables by default and allows explicit opt-out', () => {
+ const defaultTable = shallowMount(DataTable, {
+ props: { data: [], columns: configurableColumns },
+ global: {
+ stubs: { ElTable: TableStub, ElTableColumn: ColumnStub, ElButton: ButtonStub, ElPopover: PopoverStub },
+ directives: { loading: {} },
+ },
+ })
+ expect(defaultTable.get('.table-stub').attributes()).toMatchObject({ 'data-border': 'true', 'data-stripe': 'true' })
+
+ const plainTable = shallowMount(DataTable, {
+ props: { data: [], columns: configurableColumns, border: false, stripe: false },
+ global: {
+ stubs: { ElTable: TableStub, ElTableColumn: ColumnStub, ElButton: ButtonStub, ElPopover: PopoverStub },
+ directives: { loading: {} },
+ },
+ })
+ expect(plainTable.get('.table-stub').attributes()).toMatchObject({ 'data-border': 'false', 'data-stripe': 'false' })
+ })
+
it('uses key as the data property for ordinary columns only', () => {
const wrapper = shallowMount(DataTable, {
props: {
diff --git a/src/shared/components/DataTable.vue b/src/shared/components/DataTable.vue
index 02e076a6..b6f39d0d 100644
--- a/src/shared/components/DataTable.vue
+++ b/src/shared/components/DataTable.vue
@@ -64,12 +64,16 @@ const props = withDefaults(defineProps<{
pageSizes?: number[]
preferenceKey?: string
columnSettingsMode?: 'auto' | 'show' | 'hide'
+ border?: boolean
+ stripe?: boolean
}>(), {
loading: false,
emptyText: '暂无数据',
total: undefined,
pageSizes: () => [10, 20, 50, 100],
columnSettingsMode: 'auto',
+ border: true,
+ stripe: true,
})
const page = defineModel('page', { default: 1 })
@@ -294,7 +298,7 @@ function setPage(value: number) {
-
+