This commit is contained in:
2026-08-12 18:24:05 +08:00
parent 4c12831ee0
commit 9ebd0b2295
18 changed files with 381 additions and 18 deletions
+15 -1
View File
@@ -2,7 +2,7 @@
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { LogOut, Menu } from '@lucide/vue'
import { Bell, FileText, LogOut, Mail, Menu } from '@lucide/vue'
import { getApiErrorDetails } from '@/shared/api/http'
import baiyueLogoWhite from '@/assets/brand/baiyue-logo-white.png'
import baiyueMarkWhite from '@/assets/brand/baiyue-mark-white.png'
@@ -129,6 +129,16 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
><Menu :size="22" /></button>
<div v-if="showPageHeading" class="page-heading"><h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p></div>
<div class="header-user">
<el-dropdown disabled trigger="click" placement="bottom-end">
<button class="notification-trigger" type="button" aria-label="消息中心,后端契约待接入" disabled>
<Bell :size="18" /><span>消息中心</span><span class="notification-count">--</span>
</button>
<template #dropdown><el-dropdown-menu>
<el-dropdown-item disabled><Mail :size="15" />消息管理</el-dropdown-item>
<el-dropdown-item disabled><FileText :size="15" />模板管理</el-dropdown-item>
<el-dropdown-item disabled><Bell :size="15" />我的消息</el-dropdown-item>
</el-dropdown-menu></template>
</el-dropdown>
<div class="user-avatar">{{ auth.user?.name.slice(0, 1) }}</div>
<div class="user-copy" :aria-label="`登录身份:${auth.user?.name ?? '-'}(${auth.user?.account ?? '-'})`"><strong>登录身份:{{ auth.user?.name }}({{ auth.user?.account }})</strong></div>
<el-button text aria-label="退出登录" @click="handleLogout"><LogOut :size="18" /></el-button>
@@ -153,4 +163,8 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
<style scoped>
.app-header--without-page-heading .header-user { margin-left: auto; }
.notification-trigger { display: inline-flex; min-height: 34px; align-items: center; gap: 6px; padding: 0 9px; border: 0; border-radius: 4px; background: transparent; color: var(--cc-muted); font: inherit; }
.notification-trigger:disabled { cursor: not-allowed; opacity: .72; }
.notification-count { display: inline-flex; min-width: 22px; height: 18px; align-items: center; justify-content: center; padding: 0 5px; border-radius: 9px; background: #e5e7eb; color: #64748b; font-size: 11px; font-weight: 700; }
@media (max-width: 720px) { .notification-trigger > span:not(.notification-count) { display: none; } }
</style>
+2
View File
@@ -28,6 +28,7 @@ import { feeItemRoutes } from '@/modules/fee-items/routes'
import { containerLoadingRoutes } from '@/modules/container-loading/routes'
import { loadingListRoutes } from '@/modules/loading-lists/routes'
import { financeRoutes } from '@/modules/finance/routes'
import { notificationRoutes } from '@/modules/notifications/routes'
const router = createRouter({
history: createWebHistory(),
@@ -59,6 +60,7 @@ const router = createRouter({
...containerLoadingRoutes,
...loadingListRoutes,
...financeRoutes,
...notificationRoutes,
...customerRoutes,
...warehouseRoutes,
...unifiedOrderRoutes,
+12
View File
@@ -1,6 +1,7 @@
import type { Component } from 'vue'
import {
Building2,
Bell,
ContactRound,
ClipboardList,
CircleDollarSign,
@@ -180,6 +181,17 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
],
},
{
key: 'notifications',
title: '消息中心',
icon: Bell,
hidden: true,
children: [
{ key: 'notification-messages', title: '消息管理', icon: Bell, path: '/notifications/messages' },
{ key: 'notification-templates', title: '模板管理', icon: Bell, path: '/notifications/templates' },
{ key: 'my-notifications', title: '我的消息', icon: Bell, path: '/notifications/mine' },
],
},
{
key: 'system',
title: '系统管理',
+2 -2
View File
@@ -247,9 +247,9 @@ onMounted(loadRoles)
</template>
<section v-if="!loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" preference-key="roles-table" :loading="loading" :total="filteredRows.length" row-key="id" aria-label="平台角色列表" empty-text="没有符合条件的角色">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" preference-key="roles-table" :loading="loading" :total="filteredRows.length" row-key="id" border stripe aria-label="平台角色列表" empty-text="没有符合条件的角色">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增角色</el-button></template>
<template #actions="{ row }"><div v-if="canEdit || canGrant || access.can('platform:role:status') || access.can('platform:role:delete')" class="table-actions"><el-button v-if="canEdit || canGrant" link type="primary" :disabled="rowActionId === (row as Role).id || (row as Role).builtIn" @click="openEdit(row as Role)"><Pencil :size="15" />配置</el-button><el-button v-if="access.can('platform:role:status')" link :type="(row as Role).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Role).id" :disabled="(row as Role).builtIn" @click="toggleStatus(row as Role)"><Power :size="15" />{{ (row as Role).status === '启用' ? '停用' : '启用' }}</el-button><el-button v-if="access.can('platform:role:delete')" link type="danger" :disabled="(row as Role).builtIn || (row as Role).userCount > 0 || rowActionId === (row as Role).id" @click="removeRole(row as Role)"><Trash2 :size="15" />删除</el-button></div><span v-else class="secondary-text">仅查看</span></template>
<template #actions="{ row }"><div v-if="canEdit || canGrant || access.can('platform:role:status') || access.can('platform:role:delete')" class="table-actions"><el-button v-if="canEdit || canGrant" size="small" plain type="primary" :disabled="rowActionId === (row as Role).id || (row as Role).builtIn" @click="openEdit(row as Role)"><Pencil :size="15" />配置</el-button><el-button v-if="access.can('platform:role:status')" size="small" plain :type="(row as Role).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Role).id" :disabled="(row as Role).builtIn" @click="toggleStatus(row as Role)"><Power :size="15" />{{ (row as Role).status === '启用' ? '停用' : '启用' }}</el-button><el-button v-if="access.can('platform:role:delete')" size="small" plain type="danger" :disabled="(row as Role).builtIn || (row as Role).userCount > 0 || rowActionId === (row as Role).id" @click="removeRole(row as Role)"><Trash2 :size="15" />删除</el-button></div><span v-else class="secondary-text">仅查看</span></template>
<template #role="{ row }"><div class="role-cell"><span><ShieldCheck :size="18" /></span><div><strong>{{ (row as Role).name }}</strong></div></div></template>
<template #permissionSummary="{ row }"><div class="primary-cell"><strong>{{ permissionSummary(row as Role) }}</strong><span>平台 IAM 权限</span></div></template>
<template #roleType="{ row }"><el-tag :type="(row as Role).builtIn ? 'warning' : 'info'" effect="plain" size="small">{{ (row as Role).builtIn ? '系统内置' : '自定义' }}</el-tag></template>
@@ -0,0 +1,20 @@
# Admin 消息中心原型核对矩阵
事实来源:`docs/悦/货代SAAS平台后台/货代SaaS平台原型.html`。本矩阵只记录原型已经明确的承载方式;后端契约与权限码尚未冻结,本模块当前不请求接口、不保存本地业务数据、不模拟操作成功。
| 页面 | 操作 | 原型证据 | 原型承载方式 | 原型尺寸/布局 | 当前骨架 | 结论 |
|---|---|---|---|---|---|---|
| 消息管理 | 列表/查询 | `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 行 | 顶部下拉菜单 | 铃铛、“消息中心”、未读徽标;消息管理/模板管理/我的消息三项 | 顶部下拉骨架,未读显示“--”,菜单禁用 | 等待未读汇总接口及页面权限码后启用 |
@@ -0,0 +1,11 @@
<template>
<el-alert
title="消息中心后端契约与权限码尚未冻结"
type="info"
:closable="false"
show-icon
>
当前页面仅用于核对原型布局与交互承载。列表不会加载演示数据,写操作不会模拟成功。
</el-alert>
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
const open = defineModel<boolean>({ default: false })
const scheduled = ref(false)
const form = reactive({ type: '系统通知', priority: '普通', title: '', content: '', target: '全部企业用户', receivers: '', scheduleAt: '' })
const contractHint = computed(() => scheduled.value ? '定时发送规则与接口待后端契约冻结' : '草稿与立即发送接口待后端契约冻结')
</script>
<template>
<AppDialog v-model="open" title="新建消息" width="min(680px, calc(100vw - 28px))" :show-confirm="false" destroy-on-close>
<el-alert :title="contractHint" type="info" :closable="false" show-icon />
<el-form class="message-form" label-position="top">
<div class="form-grid">
<el-form-item label="消息类型" required><el-select v-model="form.type"><el-option label="系统通知" value="系统通知" /><el-option label="业务提醒" value="业务提醒" /><el-option label="异常告警" value="异常告警" /><el-option label="运单动态" value="运单动态" /></el-select></el-form-item>
<el-form-item label="优先级"><el-select v-model="form.priority"><el-option label="普通" value="普通" /><el-option label="重要" value="重要" /><el-option label="紧急" value="紧急" /></el-select></el-form-item>
</div>
<el-form-item label="消息标题" required><el-input v-model="form.title" placeholder="请输入消息标题" /></el-form-item>
<el-form-item label="消息内容" required><el-input v-model="form.content" type="textarea" :rows="5" placeholder="请输入消息内容" /></el-form-item>
<div class="form-grid">
<el-form-item label="接收对象" required><el-select v-model="form.target"><el-option label="全部企业用户" value="全部企业用户" /><el-option label="全部企业管理员" value="全部企业管理员" /><el-option label="全部平台操作员" value="全部平台操作员" /><el-option label="全部平台财务" value="全部平台财务" /><el-option label="指定用户" value="指定用户" /></el-select></el-form-item>
<el-form-item label="发送方式"><el-input model-value="站内信" disabled /></el-form-item>
</div>
<el-form-item v-if="form.target === '指定用户'" label="指定接收人"><el-input v-model="form.receivers" type="textarea" :rows="2" placeholder="接收人选择契约待确认" disabled /></el-form-item>
<el-form-item label="定时发送"><div class="schedule-row"><el-switch v-model="scheduled" aria-label="启用定时发送" /><span>启用</span><el-date-picker v-model="form.scheduleAt" type="datetime" placeholder="选择发送时间" :disabled="!scheduled" /></div></el-form-item>
</el-form>
<template #footer="{ close }"><div class="dialog-actions"><el-button @click="close">取消</el-button><el-button disabled>存为草稿</el-button><el-button type="primary" disabled>{{ scheduled ? '确认定时发送' : '立即发送' }}</el-button></div></template>
</AppDialog>
</template>
<style scoped>
.message-form { margin-top: 18px; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.form-grid :deep(.el-select), .schedule-row :deep(.el-date-editor) { width: 100%; }
.schedule-row { display: grid; width: 100%; grid-template-columns: auto auto minmax(180px, 1fr); align-items: center; gap: 8px; }
.dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.dialog-actions :deep(.el-button) { margin-left: 0; }
@media (max-width: 560px) { .form-grid { grid-template-columns: 1fr; gap: 0; } .schedule-row { grid-template-columns: auto 1fr; } .schedule-row :deep(.el-date-editor) { grid-column: 1 / -1; } }
</style>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
const open = defineModel<boolean>({ default: false })
const contentInput = ref<{ textarea?: HTMLTextAreaElement } | null>(null)
const form = reactive({ code: '', title: '', content: '' })
const variables = [{ label: '客户名称', value: 'customerName' }, { label: '订单号', value: 'orderNo' }, { label: '柜号', value: 'containerNo' }, { label: '金额', value: 'amount' }, { label: '日期', value: 'date' }]
const preview = computed(() => form.title || form.content ? `[系统] ${form.title || '未填写标题'}\n${form.content || '未填写模板内容'}` : '填写标题和内容后,将在这里显示预览效果。')
function normalizeCode(value: string) { form.code = value.replace(/[^A-Za-z0-9_]/g, '').toUpperCase() }
function insertVariable(name: string) {
const textarea = contentInput.value?.textarea
const value = `\${${name}}`
const start = textarea?.selectionStart ?? form.content.length
const end = textarea?.selectionEnd ?? start
form.content = `${form.content.slice(0, start)}${value}${form.content.slice(end)}`.slice(0, 1000)
}
</script>
<template>
<AppDialog v-model="open" title="新增消息模板" width="min(760px, calc(100vw - 28px))" :show-confirm="false" destroy-on-close>
<el-alert title="当前仅承载站内系统消息模板;保存接口待后端契约冻结" type="info" :closable="false" show-icon />
<el-form class="template-form" label-position="top">
<div class="form-grid">
<el-form-item label="模板CODE" required><el-input :model-value="form.code" maxlength="40" placeholder="如:ORDER_SHIPPED_NOTICE" @update:model-value="normalizeCode(String($event))" /><small>仅支持字母、数字和下划线</small></el-form-item>
<el-form-item label="模板类型" required><el-input model-value="系统" disabled /></el-form-item>
</div>
<el-form-item label="模板标题" required><el-input v-model="form.title" maxlength="60" show-word-limit placeholder="请输入模板标题" /></el-form-item>
<el-form-item label="模板内容" required><el-input ref="contentInput" v-model="form.content" type="textarea" :rows="6" maxlength="1000" show-word-limit placeholder="请输入模板内容" /></el-form-item>
<div class="variable-row"><span>常用变量</span><el-button v-for="item in variables" :key="item.value" size="small" @click="insertVariable(item.value)">{{ item.label }}</el-button></div>
<section class="template-preview"><strong>内容预览</strong><p>{{ preview }}</p></section>
</el-form>
<template #footer="{ close }"><div class="dialog-actions"><el-button @click="close">取消</el-button><el-button type="primary" disabled>保存模板</el-button></div></template>
</AppDialog>
</template>
<style scoped>
.template-form { margin-top: 18px; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
small { display: block; margin-top: 5px; color: var(--cc-muted); font-size: 12px; }
.variable-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 18px; color: var(--cc-muted); font-size: 12px; }
.variable-row :deep(.el-button) { margin-left: 0; }
.template-preview { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; }
.template-preview strong { display: block; padding: 9px 14px; border-bottom: 1px solid var(--cc-border); background: #f8fafc; color: var(--cc-text); font-size: 13px; }
.template-preview p { min-height: 76px; margin: 0; padding: 16px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--cc-muted); font-size: 13px; line-height: 1.7; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; }
.dialog-actions :deep(.el-button) { margin-left: 0; }
@media (max-width: 560px) { .form-grid { grid-template-columns: 1fr; gap: 0; } }
</style>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Download, Eye, Plus, Send } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '@/modules/notifications/components/ContractPendingNotice.vue'
import MessageComposerDialog from '@/modules/notifications/components/MessageComposerDialog.vue'
const page = ref(1)
const pageSize = ref(20)
const composerOpen = ref(false)
const filters = ref<QueryModel>({ type: '', status: '', keyword: '' })
const queryFields: QueryField[] = [
{ key: 'type', type: 'select', label: '消息类型', placeholder: '全部类型', options: [{ label: '系统通知', value: '系统通知' }, { label: '业务提醒', value: '业务提醒' }, { label: '异常告警', value: '异常告警' }] },
{ key: 'status', type: 'select', label: '消息状态', placeholder: '全部状态', options: [{ label: '已发送', value: '已发送' }, { label: '草稿', value: '草稿' }] },
{ key: 'keyword', type: 'input', label: '标题或内容', placeholder: '消息标题/内容' },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
{ key: 'id', prop: 'id', label: '消息ID', minWidth: 170 },
{ key: 'title', prop: 'title', label: '标题', minWidth: 220 },
{ key: 'type', prop: 'type', label: '类型', width: 110 },
{ key: 'target', prop: 'target', label: '接收对象', minWidth: 150 },
{ key: 'sentAt', prop: 'sentAt', label: '发送时间', width: 170 },
{ key: 'readSummary', prop: 'readSummary', label: '已读/总数', width: 110 },
{ key: 'status', prop: 'status', label: '状态', width: 100 },
]
</script>
<template>
<AdminPage><ListPageLayout aria-label="消息管理列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" aria-label="消息筛选" ariaLabel="消息筛选" :fields="queryFields" preference-key="notifications-messages-query" /></template>
<template #state><ContractPendingNotice /></template>
<section class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="[]" :columns="columns" preference-key="notifications-messages-table" :total="0" row-key="id" empty-text="接口接入后显示平台消息">
<template #toolbar-actions><el-button type="primary" @click="composerOpen = true"><Plus :size="16" />新建消息</el-button><el-button disabled><Download :size="16" />导出Excel</el-button></template>
<template #pagination-left><span>共 0 条消息</span></template>
<template #actions><div class="table-actions"><el-button link type="primary" disabled><Eye :size="15" />详情</el-button><el-button link type="primary" disabled><Send :size="15" />发送</el-button></div></template>
</DataTable>
</section>
</ListPageLayout><MessageComposerDialog v-model="composerOpen" /></AdminPage>
</template>
<style scoped>.table-actions { display: flex; min-height: 32px; align-items: center; gap: 8px; }.table-actions :deep(.el-button + .el-button) { margin-left: 0; }</style>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Download, FileUp, Pencil, Plus } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '@/modules/notifications/components/ContractPendingNotice.vue'
import TemplateEditorDialog from '@/modules/notifications/components/TemplateEditorDialog.vue'
const page = ref(1)
const pageSize = ref(20)
const editorOpen = ref(false)
const filters = ref<QueryModel>({ code: '', content: '' })
const queryFields: QueryField[] = [{ key: 'code', type: 'input', label: '模板CODE', placeholder: '模板CODE' }, { key: 'content', type: 'input', label: '模板内容', placeholder: '模板内容' }]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'code', prop: 'code', label: '模板CODE', minWidth: 190 },
{ key: 'title', prop: 'title', label: '模板标题', minWidth: 180 },
{ key: 'content', prop: 'content', label: '模板内容', minWidth: 320 },
{ key: 'type', prop: 'type', label: '模板类型', width: 110 },
]
</script>
<template>
<AdminPage><ListPageLayout aria-label="消息模板列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" aria-label="消息模板筛选" ariaLabel="消息模板筛选" :fields="queryFields" preference-key="notifications-templates-query" /></template>
<template #state><ContractPendingNotice /></template>
<section class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="[]" :columns="columns" preference-key="notifications-templates-table" :total="0" row-key="code" empty-text="接口接入后显示站内消息模板">
<template #toolbar-actions><el-button type="primary" @click="editorOpen = true"><Plus :size="16" />新增模板</el-button><el-button disabled><Download :size="16" />导出</el-button><el-button disabled><FileUp :size="16" />导入</el-button></template>
<template #pagination-left><span>共 0 个模板</span></template>
<template #actions><el-button link type="primary" disabled><Pencil :size="15" />编辑</el-button></template>
</DataTable>
</section>
</ListPageLayout><TemplateEditorDialog v-model="editorOpen" /></AdminPage>
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
import { CheckCheck, Eye, Trash2 } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '@/modules/notifications/components/ContractPendingNotice.vue'
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ status: '', type: '' })
const queryFields: QueryField[] = [
{ key: 'status', type: 'select', label: '消息状态', placeholder: '全部状态', options: [{ label: '未读', value: '未读' }, { label: '已读', value: '已读' }] },
{ key: 'type', type: 'select', label: '消息类型', placeholder: '全部类型', options: [{ label: '系统通知', value: '系统通知' }, { label: '业务提醒', value: '业务提醒' }, { label: '任务待办', value: '任务待办' }] },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ key: 'title', prop: 'title', label: '消息标题', minWidth: 220 },
{ key: 'summary', prop: 'summary', label: '内容摘要', minWidth: 320 },
{ key: 'type', prop: 'type', label: '类型', width: 110 },
{ key: 'sentAt', prop: 'sentAt', label: '发送时间', width: 170 },
{ key: 'status', prop: 'status', label: '状态', width: 100 },
]
</script>
<template>
<AdminPage><ListPageLayout aria-label="我的消息列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" aria-label="我的消息筛选" ariaLabel="我的消息筛选" :fields="queryFields" preference-key="notifications-mine-query" /></template>
<template #state><ContractPendingNotice /></template>
<section class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="[]" :columns="columns" preference-key="notifications-mine-table" :total="0" row-key="id" empty-text="接口接入后显示当前员工的消息">
<template #toolbar-actions><el-button disabled><CheckCheck :size="16" />全部标为已读</el-button><el-button disabled><Trash2 :size="16" />清空已读</el-button></template>
<template #pagination-left><span>共 0 条消息</span></template>
<template #actions><div class="table-actions"><el-button link type="primary" disabled><Eye :size="15" />查看</el-button><el-button link type="primary" disabled><CheckCheck :size="15" />标为已读</el-button></div></template>
</DataTable>
</section>
</ListPageLayout></AdminPage>
</template>
<style scoped>.table-actions { display: flex; min-height: 32px; align-items: center; gap: 8px; }.table-actions :deep(.el-button + .el-button) { margin-left: 0; }</style>
+23
View File
@@ -0,0 +1,23 @@
import type { RouteRecordRaw } from 'vue-router'
export const notificationRoutes = [
{
path: 'notifications/messages',
name: 'notification-messages',
component: () => import('@/modules/notifications/pages/MessageManagementView.vue'),
meta: { title: '消息管理', description: '创建并管理平台站内消息' },
},
{
path: 'notifications/templates',
name: 'notification-templates',
component: () => import('@/modules/notifications/pages/MessageTemplatesView.vue'),
meta: { title: '模板管理', description: '维护平台站内消息模板' },
},
{
path: 'notifications/mine',
name: 'my-notifications',
component: () => import('@/modules/notifications/pages/MyMessagesView.vue'),
meta: { title: '我的消息', description: '查看当前平台员工收到的站内消息' },
},
] satisfies RouteRecordRaw[]
+50
View File
@@ -0,0 +1,50 @@
export type NotificationMessageType = 'SYSTEM' | 'BUSINESS' | 'ALERT' | 'TASK'
export type NotificationMessageStatus = 'DRAFT' | 'SCHEDULED' | 'SENT' | 'CANCELLED'
export type NotificationReadStatus = 'UNREAD' | 'READ'
export interface NotificationMessageListItem {
id: string
title: string
type: NotificationMessageType
recipientSummary: string
sentAt: string | null
readCount: number | null
recipientCount: number | null
status: NotificationMessageStatus
}
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<T> {
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.
@@ -64,6 +64,11 @@ describe('InboundFeeDetailManager', () => {
expect(first).toContain('编辑'); expect(first).toContain('审核'); expect(first).toContain('驳回')
expect(second).toContain('调整'); expect(second).toContain('作废'); expect(secondButtons).not.toContain('审核')
expect(wrapper.find('th').text()).toContain('操作')
expect(source).not.toMatch(/<el-button[^>]+link[^>]+(?:编辑|审核|驳回|调整|作废)/)
expect(source).toMatch(/size="small" plain type="primary"[^>]+[^>]*>\s*<Pencil/)
expect(source).toMatch(/size="small" plain type="success"[^>]+[^>]*>\s*<Check/)
expect(source).toMatch(/size="small" plain type="warning"[^>]+[^>]*>\s*<Undo2/)
expect(source).toMatch(/size="small" plain type="danger"[^>]+[^>]*>\s*<X/)
})
it('does not request or expose fee rows without view permission', async () => {
@@ -194,11 +194,11 @@ onMounted(() => void load())
<el-table :data="rows" empty-text="暂无费用数据" row-key="id">
<el-table-column label="操作" fixed="left" width="250">
<template #default="{ row }">
<el-button v-if="canEdit(row as InboundFeeDetail)" link type="primary" @click="openEditor('edit', row as InboundFeeDetail)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="canReview(row as InboundFeeDetail)" link type="success" @click="approve(row as InboundFeeDetail)"><Check :size="15" />审核</el-button>
<el-button v-if="canReview(row as InboundFeeDetail)" link type="warning" @click="openReject(row as InboundFeeDetail)"><Undo2 :size="15" />驳回</el-button>
<el-button v-if="canAdjust(row as InboundFeeDetail)" link type="primary" @click="openEditor('adjust', row as InboundFeeDetail)"><Pencil :size="15" />调整</el-button>
<el-button v-if="canVoid(row as InboundFeeDetail)" link type="danger" @click="voidRow(row as InboundFeeDetail)"><X :size="15" />作废</el-button>
<el-button v-if="canEdit(row as InboundFeeDetail)" size="small" plain type="primary" @click="openEditor('edit', row as InboundFeeDetail)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="canReview(row as InboundFeeDetail)" size="small" plain type="success" @click="approve(row as InboundFeeDetail)"><Check :size="15" />审核</el-button>
<el-button v-if="canReview(row as InboundFeeDetail)" size="small" plain type="warning" @click="openReject(row as InboundFeeDetail)"><Undo2 :size="15" />驳回</el-button>
<el-button v-if="canAdjust(row as InboundFeeDetail)" size="small" plain type="primary" @click="openEditor('adjust', row as InboundFeeDetail)"><Pencil :size="15" />调整</el-button>
<el-button v-if="canVoid(row as InboundFeeDetail)" size="small" plain type="danger" @click="voidRow(row as InboundFeeDetail)"><X :size="15" />作废</el-button>
<el-dropdown v-if="canDelete(row as InboundFeeDetail)">
<el-button link aria-label="更多费用操作"><MoreHorizontal :size="17" /></el-button>
<template #dropdown><el-dropdown-menu>
@@ -557,6 +557,8 @@ onMounted(() => {
v-model:page-size="pageSize"
:data="rows"
:columns="columns"
border
stripe
preference-key="locations-table"
:loading="loading"
:total="total"
@@ -568,26 +570,29 @@ onMounted(() => {
<template #toolbar-actions><el-button v-if="access.can('platform:location:create')" type="primary" @click="openCreate"><Plus :size="16" />新增库位</el-button></template>
<template #actions="{ row }">
<div class="table-actions location-actions">
<el-button link type="primary" :disabled="loading" @click="loadDetail(row as PlatformLocationResource, 'view')">
<el-button size="small" plain type="primary" :disabled="loading" @click="loadDetail(row as PlatformLocationResource, 'view')">
<Eye :size="15" />详情
</el-button>
<el-button
v-if="canEdit(row as PlatformLocationResource)"
link
size="small"
plain
type="primary"
:disabled="loading"
@click="loadDetail(row as PlatformLocationResource, 'edit')"
><Pencil :size="15" />编辑</el-button>
<el-button
v-if="canChangeStatus(row as PlatformLocationResource)"
link
size="small"
plain
:type="(row as PlatformLocationResource).status === 'AVAILABLE' ? 'danger' : 'success'"
:disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
@click="toggleStatus(row as PlatformLocationResource)"
><Power :size="15" />{{ (row as PlatformLocationResource).status === 'AVAILABLE' ? '停用' : '启用' }}</el-button>
<el-button
v-if="canDelete(row as PlatformLocationResource) && !hasMoreLocationActions(row as PlatformLocationResource)"
link
size="small"
plain
type="danger"
:disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
@click="removeLocation(row as PlatformLocationResource)"
@@ -599,7 +604,7 @@ onMounted(() => {
:disabled="loading || actionLoadingIds.has(String((row as PlatformLocationResource).id))"
@command="handleMoreAction(row as PlatformLocationResource, String($event))"
>
<el-button link type="primary" :aria-label="locationMoreActionsLabel(row as PlatformLocationResource)" :title="locationMoreActionsLabel(row as PlatformLocationResource)"><MoreHorizontal :size="16" /></el-button>
<el-button size="small" plain type="primary" :aria-label="locationMoreActionsLabel(row as PlatformLocationResource)" :title="locationMoreActionsLabel(row as PlatformLocationResource)"><MoreHorizontal :size="16" /></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="canDelete(row as PlatformLocationResource)" command="delete" class="location-action-menu__danger">
@@ -411,6 +411,8 @@ onMounted(() => {
v-model:page-size="pageSize"
:data="rows"
:columns="columns"
border
stripe
preference-key="pallets-table"
:loading="loading || warehouseLoading"
:total="total"
@@ -421,11 +423,12 @@ onMounted(() => {
<template #toolbar-actions><el-button v-if="access.can('platform:pallet:create')" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
<template #actions="{ row }">
<div class="table-actions pallet-row-actions">
<el-button link type="primary" title="查看托盘" @click="openDetail(row as PlatformPalletResource)"><Eye :size="15" />查看</el-button>
<el-button v-if="access.can('platform:pallet:edit') && isDomesticWarehouse((row as PlatformPalletResource).warehouseId)" link type="primary" title="编辑托盘" @click="openEdit(row as PlatformPalletResource)"><Pencil :size="15" />编辑</el-button>
<el-button size="small" plain type="primary" title="查看托盘" @click="openDetail(row as PlatformPalletResource)"><Eye :size="15" />查看</el-button>
<el-button v-if="access.can('platform:pallet:edit') && isDomesticWarehouse((row as PlatformPalletResource).warehouseId)" size="small" plain type="primary" title="编辑托盘" @click="openEdit(row as PlatformPalletResource)"><Pencil :size="15" />编辑</el-button>
<el-button
v-if="access.can('platform:pallet:delete')"
link
size="small"
plain
type="danger"
title="删除托盘"
:loading="deletingIds.has(String((row as PlatformPalletResource).id))"
@@ -690,6 +690,8 @@ onMounted(() => void loadWarehouses())
v-model:page-size="pageSize"
:data="rows"
:columns="warehouseColumns"
border
stripe
preference-key="warehouses-table"
:loading="loading"
:total="total"
@@ -709,10 +711,11 @@ onMounted(() => void loadWarehouses())
</template>
<template #actions="{ row }">
<div v-if="access.can('platform:warehouse:edit') || access.can('platform:warehouse:status')" class="table-actions">
<el-button v-if="access.can('platform:warehouse:edit')" link type="primary" :disabled="!warehouseReady || editLoading" @click="openEdit(row as WarehouseRow)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="access.can('platform:warehouse:edit')" size="small" plain type="primary" :disabled="!warehouseReady || editLoading" @click="openEdit(row as WarehouseRow)"><Pencil :size="15" />编辑</el-button>
<el-button
v-if="access.can('platform:warehouse:status')"
link
size="small"
plain
:type="(row as WarehouseRow).status === 'ACTIVE' ? 'danger' : 'success'"
:disabled="!warehouseReady"
:loading="statusLoadingIds.has((row as WarehouseRow).id)"