优化
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '系统管理',
|
||||
|
||||
@@ -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>
|
||||
@@ -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[]
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user