优化
This commit is contained in:
@@ -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: '<p>page</p>' }, 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: '<p>warehouses</p>' }, meta: { title: '仓库管理', requiresAuth: true } },
|
||||
{ path: '/notifications/mine', name: 'my-notifications', component: { template: '<p>messages</p>' }, meta: { title: '我的消息', requiresAuth: true } },
|
||||
{ path: '/login', component: { template: '<p>login</p>' } },
|
||||
],
|
||||
})
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
const wrapper = mount(AppLayout, {
|
||||
global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '<nav />' }, WorkspaceTabs: { template: '<div class="workspace-tabs-stub" />' }, 'el-button': { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
|
||||
global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '<nav />' }, WorkspaceTabs: { template: '<div class="workspace-tabs-stub" />' }, 'el-dropdown': { template: '<div><slot /><slot name="dropdown" /></div>' }, 'el-dropdown-menu': { template: '<div><slot /></div>' }, 'el-dropdown-item': { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' }, 'el-button': { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
|
||||
})
|
||||
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 () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Bell, FileText, LogOut, Mail, Menu } from '@lucide/vue'
|
||||
@@ -13,6 +13,7 @@ import { useAuthStore } from '@/core/auth/auth'
|
||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||
import { canAccessWorkspaceRoute, workspaceRouteId, workspaceTabCandidate } from '@/core/workspace-tabs/routeWorkspace'
|
||||
import { MAX_CACHED_WORKSPACE_VIEWS, useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||
import { getUnreadCount } from '@/modules/notifications/api/notificationCenter'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -26,6 +27,8 @@ const pageTitle = computed(() => route.meta.title || '管理后台')
|
||||
const pageDescription = computed(() => route.meta.description || '')
|
||||
const showPageHeading = computed(() => route.meta.showPageHeading !== false)
|
||||
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
|
||||
const unreadCount = ref<number | null>(null)
|
||||
const unreadLoading = ref(false)
|
||||
|
||||
access.initializeAfterLogin()
|
||||
workspace.initialize(auth.user?.id || auth.user?.account || '')
|
||||
@@ -92,6 +95,14 @@ function handleMenuToggle() {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
async function loadUnreadCount() {
|
||||
if (!access.can('platform:notification:view')) return
|
||||
unreadLoading.value = true
|
||||
try { unreadCount.value = await getUnreadCount() } catch { unreadCount.value = null } finally { unreadLoading.value = false }
|
||||
}
|
||||
|
||||
function openNotificationPage(path: string) { void router.push(path) }
|
||||
|
||||
watch(() => route.fullPath, () => {
|
||||
mobileMenuOpen.value = false
|
||||
syncCurrentRoute()
|
||||
@@ -103,6 +114,8 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
||||
workspace.initialize(userId)
|
||||
void pruneInaccessibleTabs().then(syncCurrentRoute)
|
||||
})
|
||||
onMounted(() => { window.addEventListener('cc-notification-unread-changed', loadUnreadCount); void loadUnreadCount() })
|
||||
onBeforeUnmount(() => window.removeEventListener('cc-notification-unread-changed', loadUnreadCount))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -129,14 +142,14 @@ 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>
|
||||
<el-dropdown v-if="access.can('platform:notification:view')" trigger="click" placement="bottom-end">
|
||||
<button class="notification-trigger" type="button" :aria-label="`消息中心,${unreadCount ?? '未知'} 条未读`">
|
||||
<Bell :size="18" /><span>消息中心</span><span class="notification-count">{{ unreadLoading ? '…' : unreadCount ?? '!' }}</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-item v-if="access.can('platform:notification:task:list')" @click="openNotificationPage('/notifications/messages')"><Mail :size="15" />消息管理</el-dropdown-item>
|
||||
<el-dropdown-item v-if="access.can('platform:notification:template:list')" @click="openNotificationPage('/notifications/templates')"><FileText :size="15" />模板管理</el-dropdown-item>
|
||||
<el-dropdown-item @click="openNotificationPage('/notifications/mine')"><Bell :size="15" />我的消息</el-dropdown-item>
|
||||
</el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
<div class="user-avatar">{{ auth.user?.name.slice(0, 1) }}</div>
|
||||
@@ -164,7 +177,7 @@ 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-trigger { cursor: pointer; }
|
||||
.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>
|
||||
|
||||
@@ -89,6 +89,19 @@ describe('admin router access metadata', () => {
|
||||
expect(route?.meta.permission).toBe('platform:download:list')
|
||||
})
|
||||
|
||||
it('registers message-center routes with the frozen IAM permissions', () => {
|
||||
const expected = [
|
||||
['notification-messages', '/notifications/messages', 'platform:notification:task:list'],
|
||||
['notification-templates', '/notifications/templates', 'platform:notification:template:list'],
|
||||
['my-notifications', '/notifications/mine', 'platform:notification:view'],
|
||||
]
|
||||
for (const [name, path, permission] of expected) {
|
||||
const route = router.getRoutes().find((item) => item.name === name)
|
||||
expect(route?.path).toBe(path)
|
||||
expect(route?.meta.permission).toBe(permission)
|
||||
}
|
||||
})
|
||||
|
||||
it('redirects the removed enterprise review route to the protected customer list', () => {
|
||||
const route = router.getRoutes().find((item) => item.name === 'enterprise-reviews')
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('adminNavigation', () => {
|
||||
'财务管理',
|
||||
'销售管理',
|
||||
'通用',
|
||||
'消息中心',
|
||||
'系统管理',
|
||||
])
|
||||
expect(adminNavigation.find((item) => item.key === 'customers')?.children?.map((item) => item.title)).toEqual([
|
||||
@@ -71,6 +72,11 @@ describe('adminNavigation', () => {
|
||||
'车辆列表',
|
||||
'下载中心',
|
||||
])
|
||||
expect(adminNavigation.find((item) => item.key === 'notifications')?.children?.map((item) => item.title)).toEqual([
|
||||
'消息管理',
|
||||
'模板管理',
|
||||
'我的消息',
|
||||
])
|
||||
expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([
|
||||
'用户管理',
|
||||
'角色权限',
|
||||
@@ -91,6 +97,7 @@ describe('adminNavigation', () => {
|
||||
expect(paths).not.toContain('/inbounds')
|
||||
expect(paths).not.toContain('/first-leg-pricing/customer-rules')
|
||||
expect(paths).toContain('/downloads')
|
||||
expect(paths).toEqual(expect.arrayContaining(['/notifications/messages', '/notifications/templates', '/notifications/mine']))
|
||||
expect(paths).toContain('/master-data/fee-items')
|
||||
expect(paths).toContain('/container-loading')
|
||||
expect(paths).toContain('/container-loading/smart-loading')
|
||||
@@ -143,6 +150,7 @@ describe('adminNavigation', () => {
|
||||
const paths = getNavigationLeaves(filtered).map((item) => item.path)
|
||||
expect(paths).toContain('/system/permissions')
|
||||
expect(paths).not.toContain('/prototype/finance/profit-report')
|
||||
expect(paths).not.toContain('/notifications/messages')
|
||||
expect(paths).not.toContain('/customers')
|
||||
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '系统管理'])
|
||||
expect(filtered).not.toBe(adminNavigation)
|
||||
|
||||
@@ -185,11 +185,10 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
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: 'notification-messages', title: '消息管理', icon: Bell, path: '/notifications/messages', permission: 'platform:notification:task:list' },
|
||||
{ key: 'notification-templates', title: '模板管理', icon: Bell, path: '/notifications/templates', permission: 'platform:notification:template:list' },
|
||||
{ key: 'my-notifications', title: '我的消息', icon: Bell, path: '/notifications/mine', permission: 'platform:notification:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -79,7 +79,7 @@ onMounted(load)
|
||||
</template>
|
||||
<div v-else class="fee-empty">当前客户暂无海外仓费用配置</div>
|
||||
<AppDialog v-model="visible" title="编辑海外仓费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" :show-cancel="false" @confirm="save">
|
||||
<el-form ref="formRef" :model="form" label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option v-for="currency in currencies" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item><div class="edit-groups"><div v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex"><div class="edit-group-heading"><h3>{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')"><Plus :size="15" />新增阶梯</el-button></div><table><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in group" :key="tier.id"><td><el-button link type="danger" :disabled="group.length <= 1" @click="removeTier(tier)"><Trash2 :size="15" />删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" /></td></tr></tbody></table></div></div></el-form>
|
||||
<el-form ref="formRef" :model="form" label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option v-for="currency in currencies" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item><div class="edit-groups"><div v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex"><div class="edit-group-heading"><h3>{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')"><Plus :size="15" />新增阶梯</el-button></div><table><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody><tr v-for="tier in group" :key="tier.id"><td><el-button size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(tier)"><Trash2 :size="15" />删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" /></td></tr></tbody></table></div></div></el-form>
|
||||
<template #footer><div class="dialog-actions"><el-button :disabled="saving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存费用</el-button></div></template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { getCustomerOverseasStorageFees, updateCustomerOverseasStorageFees } from './overseasStorageFee'
|
||||
import { http } from '@/shared/api/http'
|
||||
|
||||
const overseasListSource = readFileSync(resolve(process.cwd(), 'src/modules/customers/pages/OverseasStorageFeesView.vue'), 'utf8')
|
||||
const overseasPanelSource = readFileSync(resolve(process.cwd(), 'src/modules/customers/CustomerOverseasStorageFeePanel.vue'), 'utf8')
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), put: vi.fn() },
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
@@ -12,6 +17,17 @@ const config = { id: 12, customerId: 9007199254740993n.toString(), customerName:
|
||||
describe('customer overseas storage fee api', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('keeps overseas fee business actions as semantic buttons at narrow widths', () => {
|
||||
expect(overseasListSource).toContain('border stripe')
|
||||
expect(overseasListSource).toContain('size="small" plain type="primary"')
|
||||
expect(overseasListSource).toContain('size="small" plain type="danger"')
|
||||
expect(overseasListSource).toContain('<Pencil :size="15" />')
|
||||
expect(overseasListSource).toContain('<Trash2 :size="15" />')
|
||||
expect(overseasListSource).toContain('@media (max-width:720px)')
|
||||
expect(overseasPanelSource).toContain('size="small" plain type="danger"')
|
||||
expect(overseasPanelSource).toContain('@media (max-width:760px)')
|
||||
})
|
||||
|
||||
it('keeps customer ids as strings and uses the customer detail path', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: config })
|
||||
const result = await getCustomerOverseasStorageFees(config.customerId)
|
||||
|
||||
@@ -70,8 +70,8 @@ onMounted(() => { void load() })
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="queryFields" ariaLabel="海外仓阶梯费用筛选" preference-key="overseas-storage-fees-query" @search="search" /></template>
|
||||
<template v-if="error" #state><section class="surface-panel error-state" role="alert"><div><strong>{{ error.message }}</strong><ApiErrorMeta :details="error" /></div><el-button type="primary" @click="load">重新加载</el-button></section></template>
|
||||
<section v-if="!error" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load">
|
||||
<template #actions="{ row }"><el-button v-if="access.can('platform:customer:edit')" link type="primary" @click="openEdit(row as OverseasStorageFeeConfig)"><Pencil :size="15" />编辑</el-button></template>
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" border stripe empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load">
|
||||
<template #actions="{ row }"><el-button v-if="access.can('platform:customer:edit')" size="small" plain type="primary" @click="openEdit(row as OverseasStorageFeeConfig)"><Pencil :size="15" />编辑</el-button></template>
|
||||
<template #storage="{ row }"><div class="tier-list"><div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'STORAGE')" :key="tier.id" class="tier-item"><span>{{ label(tier) }}</span><strong>{{ tier.unitPrice }} {{ (row as OverseasStorageFeeConfig).currency }}</strong></div></div></template>
|
||||
<template #returnStorage="{ row }"><div class="tier-list"><div v-for="tier in tiersFor(row as OverseasStorageFeeConfig, 'RETURN_STORAGE')" :key="tier.id" class="tier-item"><span>{{ label(tier) }}</span><strong>{{ tier.unitPrice }} {{ (row as OverseasStorageFeeConfig).currency }}</strong></div></div></template>
|
||||
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个客户</span></template>
|
||||
@@ -79,7 +79,7 @@ onMounted(() => { void load() })
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
<AppDialog v-model="dialogOpen" title="编辑海外仓阶梯费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
|
||||
<el-form label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item><div class="groups"><div v-for="(group, index) in [storageTiers, returnTiers]" :key="index"><div class="group-heading"><h3>{{ index === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(index === 0 ? 'STORAGE' : 'RETURN_STORAGE')"><Plus :size="15" />新增阶梯</el-button></div><el-table :data="group" size="small"><el-table-column label="操作" width="88" fixed="left"><template #default="{ row }"><el-button link type="danger" :disabled="group.length <= 1" @click="removeTier(row as OverseasStorageFeeTier)"><Trash2 :size="15" />删除</el-button></template></el-table-column><el-table-column label="开始天数"><template #default="{ row }"><el-input-number v-model="row.startDay" :min="1" :max="36500" :controls="false" /></template></el-table-column><el-table-column label="结束天数"><template #default="{ row }"><el-input-number v-if="row.endDay !== null" v-model="row.endDay" :min="1" :max="36500" :controls="false" /><span v-else>以上</span></template></el-table-column><el-table-column label="价格"><template #default="{ row }"><el-input-number v-model="row.unitPrice" :min="0" :precision="6" :controls="false" /></template></el-table-column></el-table></div></div></el-form>
|
||||
<el-form label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item><div class="groups"><div v-for="(group, index) in [storageTiers, returnTiers]" :key="index"><div class="group-heading"><h3>{{ index === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(index === 0 ? 'STORAGE' : 'RETURN_STORAGE')"><Plus :size="15" />新增阶梯</el-button></div><el-table :data="group" size="small"><el-table-column label="操作" width="88" fixed="left"><template #default="{ row }"><el-button size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(row as OverseasStorageFeeTier)"><Trash2 :size="15" />删除</el-button></template></el-table-column><el-table-column label="开始天数"><template #default="{ row }"><el-input-number v-model="row.startDay" :min="1" :max="36500" :controls="false" /></template></el-table-column><el-table-column label="结束天数"><template #default="{ row }"><el-input-number v-if="row.endDay !== null" v-model="row.endDay" :min="1" :max="36500" :controls="false" /><span v-else>以上</span></template></el-table-column><el-table-column label="价格"><template #default="{ row }"><el-input-number v-model="row.unitPrice" :min="0" :precision="6" :controls="false" /></template></el-table-column></el-table></div></div></el-form>
|
||||
</AppDialog>
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
||||
@@ -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('<Pencil :size="15" />')
|
||||
expect(rolesSource).toContain('<Power :size="15" />')
|
||||
expect(rolesSource).toContain('<Trash2 :size="15" />')
|
||||
expect(rolesSource).toContain('@media (max-width: 720px)')
|
||||
})
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(listPlatformRoles).mockReset()
|
||||
|
||||
@@ -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 解析。
|
||||
|
||||
@@ -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: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
|
||||
const envelope = <T>(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')
|
||||
})
|
||||
})
|
||||
@@ -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<T> = Omit<PageResult<T>, 'page' | 'size' | 'total' | 'pages'> & { page: RawId; size: RawId; total: RawId; pages: RawId }
|
||||
type RawInbox = Omit<InboxItem, 'id'> & { id: RawId }
|
||||
type RawInboxDetail = Omit<InboxDetail, 'id'> & { id: RawId }
|
||||
type RawTemplate = Omit<NotificationTemplate, 'id'> & { id: RawId }
|
||||
type RawTask = Omit<NotificationTask, 'id'> & { 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 = <T>(value: RawPage<T>): PageResult<T> => ({ ...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<RawPage<RawInbox>>, ApiEnvelope<RawPage<RawInbox>>>(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 }>, ApiEnvelope<{ unreadCount: RawId }>>(`${inboxPath}/unread-count`); return Number(unwrapApiResponse(response).unreadCount) }
|
||||
export async function getInboxDetail(messageId: string) { const response = await http.get<ApiEnvelope<RawInboxDetail>, ApiEnvelope<RawInboxDetail>>(`${inboxPath}/${encodeURIComponent(messageId)}`); return inboxDetail(unwrapApiResponse(response)) }
|
||||
export async function markInboxRead(messageId: string) { const response = await http.put<ApiEnvelope<RawInboxDetail>, ApiEnvelope<RawInboxDetail>>(`${inboxPath}/${encodeURIComponent(messageId)}/read`); return inboxDetail(unwrapApiResponse(response)) }
|
||||
export async function markAllInboxRead() { const response = await http.put<ApiEnvelope<{ updatedCount: RawId }>, ApiEnvelope<{ updatedCount: RawId }>>(`${inboxPath}/read-all`); return Number(unwrapApiResponse(response).updatedCount) }
|
||||
|
||||
export async function createNotification(payload: CreateNotificationRequest) { const response = await http.post<ApiEnvelope<RawTask>, ApiEnvelope<RawTask>>(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<RawPage<RawTemplate>>, ApiEnvelope<RawPage<RawTemplate>>>(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<RawTemplate>, ApiEnvelope<RawTemplate>>(templatePath, payload); return template(unwrapApiResponse(response)) }
|
||||
export async function updateTemplate(templateId: string, payload: UpdateTemplateRequest) { const response = await http.put<ApiEnvelope<RawTemplate>, ApiEnvelope<RawTemplate>>(`${templatePath}/${encodeURIComponent(templateId)}`, payload); return template(unwrapApiResponse(response)) }
|
||||
export async function changeTemplateStatus(templateId: string, targetStatus: Exclude<TemplateStatus, 'DRAFT'>, version: number) { const response = await http.put<ApiEnvelope<RawTemplate>, ApiEnvelope<RawTemplate>>(`${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<RawPage<RawTask>>, ApiEnvelope<RawPage<RawTask>>>(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<RawTask>, ApiEnvelope<RawTask>>(`${taskPath}/${encodeURIComponent(taskId)}/cancel`); return task(unwrapApiResponse(response)) }
|
||||
export async function retryTask(taskId: string) { const response = await http.put<ApiEnvelope<RawTask>, ApiEnvelope<RawTask>>(`${taskPath}/${encodeURIComponent(taskId)}/retry`); return task(unwrapApiResponse(response)) }
|
||||
@@ -1,11 +0,0 @@
|
||||
<template>
|
||||
<el-alert
|
||||
title="消息中心后端契约与权限码尚未冻结"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
当前页面仅用于核对原型布局与交互承载。列表不会加载演示数据,写操作不会模拟成功。
|
||||
</el-alert>
|
||||
</template>
|
||||
|
||||
@@ -1,41 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { PlatformTenantSelector } from '@/modules/iam'
|
||||
import { createNotification } from '../api/notificationCenter'
|
||||
import type { NotificationTemplate, RecipientType } from '../types'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
|
||||
const props = defineProps<{ templates: NotificationTemplate[] }>()
|
||||
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 ? '定时发送规则与接口待后端契约冻结' : '草稿与立即发送接口待后端契约冻结')
|
||||
const emit = defineEmits<{ success: [] }>()
|
||||
const submitting = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const scope = ref<'platform' | 'customer'>('platform')
|
||||
const recipientIdsText = ref('')
|
||||
const variables = reactive<Record<string, string>>({})
|
||||
const form = reactive({ tenantId: '0', templateCode: '', recipientType: 'PLATFORM_ROLE' as RecipientType, roleCode: '', scheduledAt: '' })
|
||||
const selectedTemplate = computed(() => props.templates.find((item) => item.templateCode === form.templateCode) ?? null)
|
||||
const scheduled = computed({ get: () => Boolean(form.scheduledAt), set: (value) => { if (!value) form.scheduledAt = '' } })
|
||||
const needsIds = computed(() => ['USER', 'MEMBERSHIP'].includes(form.recipientType))
|
||||
const needsRole = computed(() => ['TENANT_ROLE', 'PLATFORM_ROLE'].includes(form.recipientType))
|
||||
const allowedRecipients = computed(() => scope.value === 'platform'
|
||||
? [{ label: '指定平台用户', value: 'USER' }, { label: '平台角色', value: 'PLATFORM_ROLE' }]
|
||||
: [{ label: '指定企业成员', value: 'MEMBERSHIP' }, { label: '企业角色', value: 'TENANT_ROLE' }, { label: '企业全部成员', value: 'TENANT_ALL' }])
|
||||
|
||||
watch(scope, (value) => { form.tenantId = value === 'platform' ? '0' : ''; form.recipientType = value === 'platform' ? 'PLATFORM_ROLE' : 'TENANT_ALL'; form.roleCode = ''; recipientIdsText.value = '' })
|
||||
watch(selectedTemplate, (value) => { for (const key of Object.keys(variables)) delete variables[key]; for (const key of value?.variableWhitelist ?? []) variables[key] = '' })
|
||||
|
||||
function reset() { scope.value = 'platform'; Object.assign(form, { tenantId: '0', templateCode: '', recipientType: 'PLATFORM_ROLE', roleCode: '', scheduledAt: '' }); recipientIdsText.value = ''; error.value = null; for (const key of Object.keys(variables)) delete variables[key] }
|
||||
function parseIds() { return [...new Set(recipientIdsText.value.split(/[\s,,]+/).map((value) => value.trim()).filter(Boolean))] }
|
||||
function idempotencyKey() { return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `notification-${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
async function submit() {
|
||||
error.value = null
|
||||
const recipientIds = parseIds()
|
||||
if (!form.templateCode) return ElMessage.warning('请选择已激活模板')
|
||||
if (scope.value === 'customer' && !form.tenantId) return ElMessage.warning('请选择目标企业')
|
||||
if (needsIds.value && (!recipientIds.length || recipientIds.some((value) => !/^\d+$/.test(value)))) return ElMessage.warning('请输入有效的数字用户或成员 ID')
|
||||
if (needsRole.value && !form.roleCode.trim()) return ElMessage.warning('请输入角色编码')
|
||||
if (Object.values(variables).some((value) => !value.trim())) return ElMessage.warning('请填写全部模板变量')
|
||||
submitting.value = true
|
||||
try {
|
||||
await createNotification({ tenantId: form.tenantId, templateCode: form.templateCode, recipientType: form.recipientType, ...(needsIds.value ? { recipientIds } : {}), ...(needsRole.value ? { roleCode: form.roleCode.trim() } : {}), variables: { ...variables }, scheduledAt: form.scheduledAt ? new Date(form.scheduledAt).toISOString() : null, idempotencyKey: idempotencyKey() })
|
||||
ElMessage.success(form.scheduledAt ? '消息已定时' : '消息发送任务已创建')
|
||||
open.value = false; reset(); emit('success')
|
||||
} catch (reason) { error.value = getApiErrorDetails(reason, '消息发送失败') } finally { submitting.value = false }
|
||||
}
|
||||
</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 />
|
||||
<AppDialog v-model="open" title="新建消息" width="min(680px, calc(100vw - 28px))" :show-confirm="false" destroy-on-close @cancel="reset">
|
||||
<el-alert v-if="error" type="error" :closable="false" show-icon><template #title><span>{{ error.message }}</span><ApiErrorMeta :details="error" /></template></el-alert>
|
||||
<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>
|
||||
<div class="form-grid"><el-form-item label="发送范围" required><el-segmented v-model="scope" :options="[{ label: '平台员工', value: 'platform' }, { label: '指定企业', value: 'customer' }]" /></el-form-item><el-form-item label="发送方式"><el-input model-value="站内信" disabled /></el-form-item></div>
|
||||
<PlatformTenantSelector v-if="scope === 'customer'" v-model="form.tenantId" label="目标企业" />
|
||||
<el-form-item label="消息模板" required><el-select v-model="form.templateCode" filterable placeholder="请选择已激活模板"><el-option v-for="item in templates" :key="item.id" :label="`${item.templateName}(${item.templateCode})`" :value="item.templateCode" /></el-select></el-form-item>
|
||||
<div class="form-grid"><el-form-item label="接收对象" required><el-select v-model="form.recipientType"><el-option v-for="item in allowedRecipients" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item v-if="needsRole" label="角色编码" required><el-input v-model="form.roleCode" maxlength="64" placeholder="请输入服务端角色编码" /></el-form-item></div>
|
||||
<el-form-item v-if="needsIds" label="接收人 ID" required><el-input v-model="recipientIdsText" type="textarea" :rows="2" placeholder="多个数字 ID 使用逗号或换行分隔,最多 1000 个" /></el-form-item>
|
||||
<section v-if="selectedTemplate?.variableWhitelist.length" class="variables"><strong>模板变量</strong><el-form-item v-for="key in selectedTemplate.variableWhitelist" :key="key" :label="key" required><el-input v-model="variables[key]" maxlength="2000" /></el-form-item></section>
|
||||
<el-form-item label="定时发送"><div class="schedule-row"><el-switch v-model="scheduled" aria-label="启用定时发送" /><span>启用</span><el-date-picker v-model="form.scheduledAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" 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>
|
||||
<template #footer="{ close }"><div class="dialog-actions"><el-button :disabled="submitting" @click="close">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">{{ 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; } }
|
||||
.message-form { margin-top: 18px; }.form-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; }.form-grid :deep(.el-select),.message-form > :deep(.el-form-item .el-select),.schedule-row :deep(.el-date-editor) { width: 100%; }.variables { margin-bottom: 16px; padding: 14px; border: 1px solid var(--cc-border); border-radius: 8px; }.variables > strong { display: block; margin-bottom: 12px; font-size: 13px; }.schedule-row { display:grid;width:100%;grid-template-columns:auto auto minmax(180px,1fr);align-items:center;gap:8px }.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}.schedule-row{grid-template-columns:auto 1fr}.schedule-row :deep(.el-date-editor){grid-column:1/-1}}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,50 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { createTemplate, updateTemplate } from '../api/notificationCenter'
|
||||
import type { NotificationTemplate } from '../types'
|
||||
|
||||
const props = defineProps<{ template?: NotificationTemplate | null }>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const emit = defineEmits<{ success: [] }>()
|
||||
const submitting = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
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 || '未填写模板内容'}` : '填写标题和内容后,将在这里显示预览效果。')
|
||||
const whitelistText = ref('')
|
||||
const form = reactive({ code: '', name: '', category: '', title: '', content: '' })
|
||||
const editing = computed(() => Boolean(props.template))
|
||||
const preview = computed(() => form.title || form.content ? `[站内信] ${form.title || '未填写标题'}\n${form.content || '未填写模板内容'}` : '填写标题和内容后,将在这里显示预览效果。')
|
||||
const whitelist = computed(() => [...new Set(whitelistText.value.split(/[\s,,]+/).map((v) => v.trim()).filter(Boolean))])
|
||||
|
||||
watch([open, () => props.template], ([isOpen]) => { if (!isOpen) return; const item = props.template; Object.assign(form, item ? { code: item.templateCode, name: item.templateName, category: item.category, title: item.titleTemplate, content: item.bodyTemplate } : { code: '', name: '', category: '', title: '', content: '' }); whitelistText.value = item?.variableWhitelist.join(', ') ?? ''; error.value = null }, { immediate: true })
|
||||
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)
|
||||
function insertVariable(name: string) { if (!whitelist.value.includes(name)) whitelistText.value = [...whitelist.value, name].join(', '); 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,10000) }
|
||||
async function submit() {
|
||||
error.value = null
|
||||
if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(form.code)) return ElMessage.warning('模板CODE须以字母开头,仅支持大写字母、数字和下划线')
|
||||
if (!form.name.trim() || !form.category.trim() || !form.title.trim() || !form.content.trim()) return ElMessage.warning('请填写完整模板信息')
|
||||
if (whitelist.value.some((key) => !/^[a-z][a-z0-9_]{0,31}$/.test(key))) return ElMessage.warning('变量名须以小写字母开头,仅支持小写字母、数字和下划线')
|
||||
const payload = { templateName: form.name.trim(), category: form.category.trim(), titleTemplate: form.title.trim(), bodyTemplate: form.content.trim(), variableWhitelist: whitelist.value }
|
||||
submitting.value = true
|
||||
try { if (props.template) await updateTemplate(props.template.id, { ...payload, version: props.template.version }); else await createTemplate({ templateCode: form.code, ...payload }); ElMessage.success(props.template ? '模板已更新' : '模板草稿已创建'); open.value = false; emit('success') } catch (reason) { error.value = getApiErrorDetails(reason, '模板保存失败') } finally { submitting.value = false }
|
||||
}
|
||||
</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 v-model="open" :title="editing ? '编辑消息模板' : '新增消息模板'" width="min(760px, calc(100vw - 28px))" :show-confirm="false" destroy-on-close>
|
||||
<el-alert v-if="error" type="error" :closable="false" show-icon><template #title><span>{{ error.message }}</span><ApiErrorMeta :details="error" /></template></el-alert>
|
||||
<el-alert v-else 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="64" :disabled="editing" placeholder="如:ORDER_SHIPPED_NOTICE" @update:model-value="normalizeCode(String($event))" /></el-form-item><el-form-item label="模板名称" required><el-input v-model="form.name" maxlength="128" /></el-form-item></div><div class="form-grid"><el-form-item label="业务分类" required><el-input v-model="form.category" maxlength="64" /></el-form-item><el-form-item label="模板类型"><el-input model-value="站内信" disabled /></el-form-item></div><el-form-item label="消息标题模板" required><el-input v-model="form.title" maxlength="256" show-word-limit /></el-form-item><el-form-item label="消息正文模板" required><el-input ref="contentInput" v-model="form.content" type="textarea" :rows="6" maxlength="10000" show-word-limit /></el-form-item><el-form-item label="变量白名单"><el-input v-model="whitelistText" placeholder="多个变量使用逗号分隔,如 customer_name, order_no" /></el-form-item><div class="variable-row"><span>常用变量</span><el-button v-for="name in ['customer_name','order_no','container_no','amount','date']" :key="name" size="small" @click="insertVariable(name)">{{ name }}</el-button></div><section class="template-preview"><strong>内容预览</strong><p>{{ preview }}</p></section></el-form>
|
||||
<template #footer="{ close }"><div class="dialog-actions"><el-button :disabled="submitting" @click="close">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">保存模板</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; } }
|
||||
.template-form{margin-top:18px}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.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;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,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: '<div class="dialog" :data-width="width"><slot /><slot name="footer" :close="() => {}" /></div>' },
|
||||
ApiErrorMeta: true,
|
||||
PlatformTenantSelector: true,
|
||||
'el-alert': true,
|
||||
'el-form': { template: '<form><slot /></form>' },
|
||||
'el-form-item': { template: '<label><slot /></label>' },
|
||||
'el-segmented': true,
|
||||
'el-select': { template: '<select><slot /></select>' },
|
||||
'el-option': true,
|
||||
'el-input': true,
|
||||
'el-switch': true,
|
||||
'el-date-picker': true,
|
||||
'el-button': { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,46 +1,19 @@
|
||||
<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 },
|
||||
]
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { Plus, RotateCcw, XCircle } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
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 LoadErrorState from '@/shared/components/LoadErrorState.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import MessageComposerDialog from '../components/MessageComposerDialog.vue'; import { cancelTask, listTasks, listTemplates, retryTask } from '../api/notificationCenter'; import type { NotificationTask, NotificationTaskStatus, NotificationTemplate } from '../types'; import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'; import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
const access=useAccessPreviewStore(),page=ref(1),pageSize=ref(20),loading=ref(false),actionId=ref(''),error=ref<ApiErrorDetails|null>(null),composerOpen=ref(false),templates=ref<NotificationTemplate[]>([]),result=ref({records:[] as NotificationTask[],total:0,pages:0}),filters=ref<QueryModel>({category:'',status:'',keyword:''})
|
||||
const queryFields:QueryField[]=[{key:'category',type:'input',label:'业务分类',placeholder:'业务分类'},{key:'status',type:'select',label:'任务状态',placeholder:'全部状态',options:['CREATED','SCHEDULED','DISPATCHING','RETRYING','SENT','DEAD','CANCELLED'].map(value=>({label:statusLabel(value as NotificationTaskStatus),value}))},{key:'keyword',type:'input',label:'任务号或标题',placeholder:'任务号/消息标题'}]
|
||||
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:180,fixed:'left',slotName:'actions'},{key:'taskNo',prop:'taskNo',label:'任务号',minWidth:190},{key:'title',prop:'title',label:'标题',minWidth:220},{key:'category',prop:'category',label:'业务分类',width:130},{key:'recipientCount',prop:'recipientCount',label:'接收人数',width:100},{key:'scheduledAt',label:'定时发送',width:170,slotName:'scheduledAt'},{key:'sentAt',label:'发送时间',width:170,slotName:'sentAt'},{key:'status',label:'状态',width:110,slotName:'status'}]
|
||||
function statusLabel(v:NotificationTaskStatus){return {CREATED:'已创建',SCHEDULED:'待定时发送',DISPATCHING:'发送中',RETRYING:'重试中',SENT:'已发送',DEAD:'发送失败',CANCELLED:'已取消'}[v]}
|
||||
function formatDate(v:string|null){return v?new Date(v).toLocaleString('zh-CN',{hour12:false}):'—'}
|
||||
async function load(){loading.value=true;error.value=null;try{const keyword=String(filters.value.keyword||'').trim();const data=await listTasks({page:page.value,size:pageSize.value,category:String(filters.value.category||'').trim()||undefined,status:(filters.value.status||undefined) as NotificationTaskStatus|undefined,...(keyword.startsWith('NT')?{taskNo:keyword}:{title:keyword||undefined})});result.value={records:data.records,total:data.total,pages:data.pages}}catch(e){error.value=getApiErrorDetails(e,'消息任务加载失败')}finally{loading.value=false}}
|
||||
async function loadTemplates(){if(!access.can('platform:notification:create'))return;try{const data=await listTemplates({page:1,size:200,status:'ACTIVE'});templates.value=data.records}catch(e){ElMessage.error(getApiErrorMessage(e,'可用模板加载失败'))}}
|
||||
function search(){page.value===1?void load():page.value=1}
|
||||
async function action(row:NotificationTask,type:'cancel'|'retry'){try{await ElMessageBox.confirm(type==='cancel'?'确定取消该消息任务吗?':'确定重试该消息任务吗?',type==='cancel'?'取消任务':'重试任务',{type:'warning'});actionId.value=row.id;if(type==='cancel')await cancelTask(row.id);else await retryTask(row.id);ElMessage.success(type==='cancel'?'任务已取消':'任务已提交重试');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(getApiErrorMessage(e,type==='cancel'?'取消失败':'重试失败'))}finally{actionId.value=''}}
|
||||
watch([page,pageSize],load);onMounted(()=>{void load();void loadTemplates()})
|
||||
</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>
|
||||
<template><AdminPage><ListPageLayout aria-label="消息管理列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="消息任务筛选" :fields="queryFields" preference-key="notifications-messages-query" @search="search" /></template><template v-if="error" #state><LoadErrorState title="消息任务加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-messages-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息任务"><template #toolbar-actions><el-button v-if="access.can('platform:notification:create')" type="primary" :disabled="!templates.length" @click="composerOpen=true"><Plus :size="16"/>新建消息</el-button></template><template #pagination-left><span>共 {{ result.total }} 个任务</span></template><template #actions="{row}"><div class="table-actions"><el-button v-if="access.can('platform:notification:task:cancel')&&['CREATED','SCHEDULED'].includes((row as NotificationTask).status)" link type="danger" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'cancel')"><XCircle :size="15"/>取消</el-button><el-button v-if="access.can('platform:notification:task:retry')&&['RETRYING','DEAD'].includes((row as NotificationTask).status)" link type="primary" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'retry')"><RotateCcw :size="15"/>重试</el-button></div></template><template #scheduledAt="{row}">{{formatDate((row as NotificationTask).scheduledAt)}}</template><template #sentAt="{row}">{{formatDate((row as NotificationTask).sentAt)}}</template><template #status="{row}"><el-tag>{{statusLabel((row as NotificationTask).status)}}</el-tag></template></DataTable></section></ListPageLayout><MessageComposerDialog v-model="composerOpen" :templates="templates" @success="load"/></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>
|
||||
|
||||
@@ -1,37 +1,14 @@
|
||||
<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 },
|
||||
]
|
||||
import { onMounted, ref, watch } from 'vue'; import { Pencil, Plus, Power } from '@lucide/vue'; import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
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 LoadErrorState from '@/shared/components/LoadErrorState.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import TemplateEditorDialog from '../components/TemplateEditorDialog.vue'; import { changeTemplateStatus, listTemplates } from '../api/notificationCenter'; import type { NotificationTemplate, TemplateStatus } from '../types'; import { getApiErrorDetails,getApiErrorMessage,type ApiErrorDetails } from '@/shared/api/http'; import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
const access=useAccessPreviewStore(),page=ref(1),pageSize=ref(20),loading=ref(false),actionId=ref(''),error=ref<ApiErrorDetails|null>(null),editorOpen=ref(false),editing=ref<NotificationTemplate|null>(null),result=ref({records:[] as NotificationTemplate[],total:0,pages:0}),filters=ref<QueryModel>({code:'',category:'',title:'',status:''})
|
||||
const queryFields:QueryField[]=[{key:'code',type:'input',label:'模板CODE',placeholder:'模板CODE'},{key:'category',type:'input',label:'业务分类',placeholder:'业务分类'},{key:'title',type:'input',label:'名称或标题',placeholder:'模板名称/标题'},{key:'status',type:'select',label:'状态',placeholder:'全部状态',options:[{label:'草稿',value:'DRAFT'},{label:'已激活',value:'ACTIVE'},{label:'已禁用',value:'DISABLED'}]}]
|
||||
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:190,fixed:'left',slotName:'actions'},{key:'templateCode',prop:'templateCode',label:'模板CODE',minWidth:190},{key:'templateName',prop:'templateName',label:'模板名称',minWidth:170},{key:'category',prop:'category',label:'业务分类',width:130},{key:'titleTemplate',prop:'titleTemplate',label:'消息标题模板',minWidth:220},{key:'bodyTemplate',prop:'bodyTemplate',label:'正文模板',minWidth:320,showOverflowTooltip:true},{key:'status',label:'状态',width:100,slotName:'status'},{key:'version',prop:'version',label:'版本',width:80}]
|
||||
function statusLabel(v:TemplateStatus){return {DRAFT:'草稿',ACTIVE:'已激活',DISABLED:'已禁用'}[v]}
|
||||
async function load(){loading.value=true;error.value=null;try{const d=await listTemplates({page:page.value,size:pageSize.value,templateCode:String(filters.value.code||'').trim()||undefined,category:String(filters.value.category||'').trim()||undefined,title:String(filters.value.title||'').trim()||undefined,status:(filters.value.status||undefined) as TemplateStatus|undefined});result.value={records:d.records,total:d.total,pages:d.pages}}catch(e){error.value=getApiErrorDetails(e,'模板加载失败')}finally{loading.value=false}}
|
||||
function search(){page.value===1?void load():page.value=1}function create(){editing.value=null;editorOpen.value=true}function edit(row:NotificationTemplate){editing.value=row;editorOpen.value=true}
|
||||
async function changeStatus(row:NotificationTemplate){const target=row.status==='DRAFT'?'ACTIVE':'DISABLED';try{await ElMessageBox.confirm(target==='ACTIVE'?'激活后模板不可再编辑,确定继续吗?':'禁用后首期不可重新启用,确定继续吗?',target==='ACTIVE'?'激活模板':'禁用模板',{type:'warning'});actionId.value=row.id;await changeTemplateStatus(row.id,target,row.version);ElMessage.success(target==='ACTIVE'?'模板已激活':'模板已禁用');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(getApiErrorMessage(e,'模板状态变更失败'))}finally{actionId.value=''}}
|
||||
watch([page,pageSize],load);onMounted(load)
|
||||
</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>
|
||||
<template><AdminPage><ListPageLayout aria-label="消息模板列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="消息模板筛选" :fields="queryFields" preference-key="notifications-templates-query" @search="search"/></template><template v-if="error" #state><LoadErrorState title="模板加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error"/></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-templates-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息模板"><template #toolbar-actions><el-button v-if="access.can('platform:notification:template:create')" type="primary" @click="create"><Plus :size="16"/>新增模板</el-button></template><template #pagination-left><span>共 {{result.total}} 个模板</span></template><template #actions="{row}"><div class="table-actions"><el-button v-if="access.can('platform:notification:template:edit')&&(row as NotificationTemplate).status==='DRAFT'" link type="primary" @click="edit(row as NotificationTemplate)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can('platform:notification:template:status')&&(row as NotificationTemplate).status!=='DISABLED'" link :type="(row as NotificationTemplate).status==='ACTIVE'?'danger':'primary'" :loading="actionId===(row as NotificationTemplate).id" @click="changeStatus(row as NotificationTemplate)"><Power :size="15"/>{{(row as NotificationTemplate).status==='DRAFT'?'激活':'禁用'}}</el-button></div></template><template #status="{row}"><el-tag>{{statusLabel((row as NotificationTemplate).status)}}</el-tag></template></DataTable></section></ListPageLayout><TemplateEditorDialog v-model="editorOpen" :template="editing" @success="load"/></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>
|
||||
|
||||
@@ -1,41 +1,16 @@
|
||||
<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 },
|
||||
]
|
||||
import { onMounted, ref, watch } from 'vue'; import { CheckCheck, Eye } from '@lucide/vue'; import { ElMessage } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'; import AppDialog from '@/shared/components/AppDialog.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 LoadErrorState from '@/shared/components/LoadErrorState.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'; import { getInboxDetail,listInbox,markAllInboxRead,markInboxRead } from '../api/notificationCenter'; import type { InboxDetail,InboxItem,InboxStatus } from '../types'; import { getApiErrorDetails,getApiErrorMessage,type ApiErrorDetails } from '@/shared/api/http'
|
||||
const page=ref(1),pageSize=ref(20),loading=ref(false),actionId=ref(''),error=ref<ApiErrorDetails|null>(null),detailOpen=ref(false),detailLoading=ref(false),detail=ref<InboxDetail|null>(null),result=ref({records:[] as InboxItem[],total:0,pages:0}),filters=ref<QueryModel>({status:'',category:''})
|
||||
const queryFields:QueryField[]=[{key:'status',type:'select',label:'消息状态',placeholder:'全部状态',options:[{label:'未读',value:'UNREAD'},{label:'已读',value:'READ'}]},{key:'category',type:'input',label:'业务分类',placeholder:'业务分类'}]
|
||||
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:210,fixed:'left',slotName:'actions'},{key:'title',prop:'title',label:'消息标题',minWidth:240},{key:'category',prop:'category',label:'业务分类',width:140},{key:'sentAt',label:'发送时间',width:170,slotName:'sentAt'},{key:'status',label:'状态',width:100,slotName:'status'}]
|
||||
function formatDate(v:string|null){return v?new Date(v).toLocaleString('zh-CN',{hour12:false}):'—'}
|
||||
async function load(){loading.value=true;error.value=null;try{const d=await listInbox({page:page.value,size:pageSize.value,status:(filters.value.status||undefined) as InboxStatus|undefined,category:String(filters.value.category||'').trim()||undefined});result.value={records:d.records,total:d.total,pages:d.pages}}catch(e){error.value=getApiErrorDetails(e,'我的消息加载失败')}finally{loading.value=false}}
|
||||
function search(){page.value===1?void load():page.value=1}
|
||||
async function openDetail(row:InboxItem){detailOpen.value=true;detailLoading.value=true;detail.value=null;try{detail.value=await getInboxDetail(row.id);if(detail.value.status==='UNREAD'){detail.value=await markInboxRead(row.id);await load();window.dispatchEvent(new CustomEvent('cc-notification-unread-changed'))}}catch(e){ElMessage.error(getApiErrorMessage(e,'消息详情加载失败'))}finally{detailLoading.value=false}}
|
||||
async function markRead(row:InboxItem){actionId.value=row.id;try{await markInboxRead(row.id);ElMessage.success('已标为已读');await load();window.dispatchEvent(new CustomEvent('cc-notification-unread-changed'))}catch(e){ElMessage.error(getApiErrorMessage(e,'标记已读失败'))}finally{actionId.value=''}}
|
||||
async function markAll(){try{const count=await markAllInboxRead();ElMessage.success(count?`已将 ${count} 条消息标为已读`:'没有需要更新的未读消息');await load();window.dispatchEvent(new CustomEvent('cc-notification-unread-changed'))}catch(e){ElMessage.error(getApiErrorMessage(e,'全部标为已读失败'))}}
|
||||
watch([page,pageSize],load);onMounted(load)
|
||||
</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>
|
||||
<template><AdminPage><ListPageLayout aria-label="我的消息列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="我的消息筛选" :fields="queryFields" preference-key="notifications-mine-query" @search="search"/></template><template v-if="error" #state><LoadErrorState title="我的消息加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error"/></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-mine-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息"><template #toolbar-actions><el-button @click="markAll"><CheckCheck :size="16"/>全部标为已读</el-button></template><template #pagination-left><span>共 {{result.total}} 条消息</span></template><template #actions="{row}"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as InboxItem)"><Eye :size="15"/>查看</el-button><el-button v-if="(row as InboxItem).status==='UNREAD'" link type="primary" :loading="actionId===(row as InboxItem).id" @click="markRead(row as InboxItem)"><CheckCheck :size="15"/>标为已读</el-button></div></template><template #sentAt="{row}">{{formatDate((row as InboxItem).sentAt)}}</template><template #status="{row}"><el-tag :type="(row as InboxItem).status==='UNREAD'?'danger':'info'">{{(row as InboxItem).status==='UNREAD'?'未读':'已读'}}</el-tag></template></DataTable></section></ListPageLayout><AppDialog v-model="detailOpen" title="消息详情" width="min(680px, calc(100vw - 28px))" :loading="detailLoading" :show-confirm="false"><article v-if="detail" class="message-detail"><header><div><h3>{{detail.title}}</h3><p>{{detail.category}} · {{formatDate(detail.sentAt)}}</p></div><el-tag :type="detail.status==='UNREAD'?'danger':'info'">{{detail.status==='UNREAD'?'未读':'已读'}}</el-tag></header><div class="message-body">{{detail.body}}</div><dl v-if="detail.sourceType||detail.sourceId"><div><dt>来源类型</dt><dd>{{detail.sourceType||'—'}}</dd></div><div><dt>来源标识</dt><dd>{{detail.sourceId||'—'}}</dd></div></dl></article></AppDialog></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}.message-detail header{display:flex;justify-content:space-between;gap:16px;padding-bottom:16px;border-bottom:1px solid var(--cc-border)}.message-detail h3{margin:0;font-size:17px}.message-detail p{margin:6px 0 0;color:var(--cc-muted);font-size:12px}.message-body{padding:20px 0;white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.8}.message-detail dl{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0;border-top:1px solid var(--cc-border)}.message-detail dl>div{padding:12px}.message-detail dt{color:var(--cc-muted);font-size:12px}.message-detail dd{margin:5px 0 0;overflow-wrap:anywhere}@media(max-width:480px){.message-detail header{flex-direction:column}.message-detail dl{grid-template-columns:1fr}}</style>
|
||||
|
||||
@@ -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[]
|
||||
|
||||
|
||||
@@ -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<T> { 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<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.
|
||||
export interface CreateNotificationRequest { tenantId: string; templateCode: string; recipientType: RecipientType; recipientIds?: string[]; roleCode?: string; variables: Record<string, string>; 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<CreateTemplateRequest, 'templateCode'> { version: number }
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<OceanBookingSaveRequest>({ 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() })
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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('<Eye :size="15" />')
|
||||
expect(locationsSource).toContain('<Pencil :size="15" />')
|
||||
expect(locationsSource).toContain('<Power :size="15" />')
|
||||
expect(locationsSource).toContain('<Trash2 :size="15" />')
|
||||
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))'")
|
||||
|
||||
@@ -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('<Eye :size="15" />')
|
||||
expect(palletsSource).toContain('<Pencil :size="15" />')
|
||||
expect(palletsSource).toContain('<Trash2 :size="15" />')
|
||||
expect(palletsSource).toContain('@media (max-width: 720px)')
|
||||
})
|
||||
it('uses the prototype-sized centered editor while retaining the detail drawer', () => {
|
||||
expect(palletsSource).toContain('<AppDrawer v-model="detailOpen"')
|
||||
expect(palletsSource).toContain('<AppDialog v-model="editorOpen" :title="editorTitle" width="min(560px, calc(100vw - 28px))"')
|
||||
|
||||
@@ -175,6 +175,15 @@ enableAutoUnmount(afterEach)
|
||||
afterAll(() => 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('<Pencil :size="15" />')
|
||||
expect(warehousesSource).toContain('<Power :size="15" />')
|
||||
expect(warehousesSource).toContain('@media (max-width: 720px)')
|
||||
})
|
||||
it('uses the prototype-sized centered warehouse editor with a fixed footer', () => {
|
||||
expect(warehousesSource).toContain('<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))"')
|
||||
expect(warehousesSource).not.toContain('<AppDrawer v-model="editorOpen"')
|
||||
|
||||
@@ -5,7 +5,11 @@ import DataTable, { createPaginationItems, normalizePage, normalizePageSizes, ty
|
||||
import LayoutPreferenceList from './LayoutPreferenceList.vue'
|
||||
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
|
||||
|
||||
const TableStub = defineComponent({ template: '<div><slot /></div>', methods: { doLayout() {} } })
|
||||
const TableStub = defineComponent({
|
||||
props: { border: Boolean, stripe: Boolean },
|
||||
template: '<div class="table-stub" :data-border="String(border)" :data-stripe="String(stripe)"><slot /></div>',
|
||||
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: {
|
||||
|
||||
@@ -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<number>('page', { default: 1 })
|
||||
@@ -294,7 +298,7 @@ function setPage(value: number) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-table ref="tableRef" v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :aria-busy="loading">
|
||||
<el-table ref="tableRef" v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :border="border" :stripe="stripe" :aria-busy="loading">
|
||||
<el-table-column
|
||||
v-for="(column, index) in visibleColumns"
|
||||
:key="columnRenderKey(column, index)"
|
||||
|
||||
Reference in New Issue
Block a user