Release/test 合并上生产 #1

Merged
admin merged 236 commits from release/test into main 2026-09-17 11:51:50 +08:00
14 changed files with 142 additions and 33 deletions
Showing only changes of commit 0f352bbdf9 - Show all commits
+15 -11
View File
@@ -1,5 +1,5 @@
import { ApiError, apiRequest } from '@/api/http'
import type { ApiFirstLegStatus, MerchantFirstLegOrderDetail, PageResult } from '@/modules/first-leg/api/firstLeg'
import type { ApiContainerMode, ApiDestinationType, ApiFirstLegStatus, ApiPickupMethod, MerchantFirstLegOrderDetail, PageResult } from '@/modules/first-leg/api/firstLeg'
export type ApiUnifiedOrderMainStatus =
| 'PENDING_REVIEW'
@@ -42,7 +42,6 @@ export interface MerchantUnifiedDeliverySummary {
}
export interface MerchantUnifiedTransportSummary {
loadingStatus: string | null
containerNo: string | null
cabinetNo: string | null
vesselName: string | null
@@ -72,15 +71,20 @@ export interface MerchantUnifiedOrderRow {
totalPackageCount: number | null
actualGrossWeightKg: number | null
actualVolumeCbm: number | null
skuNos?: string[] | null
expressCompany?: string | null
totalNetWeightKg?: number | null
totalQuantity?: number | null
chargeableVolumeCbm?: number | null
shipmentId?: string | null
referenceId?: string | null
inboundedAt?: string | null
businessDate?: string | null
pickupMethod: ApiPickupMethod | null
requestedPickupAt: string | null
containerMode: ApiContainerMode | null
destinationType: ApiDestinationType | null
destinationCode: string | null
skuNos: string[] | null
expressCompany: string | null
expressTrackingNo: string | null
totalNetWeightKg: number | null
totalQuantity: number | null
chargeableVolumeCbm: number | null
shipmentId: string | null
referenceId: string | null
inboundCompletedAt: string | null
measurement: MerchantUnifiedMeasurementSummary | null
preload: MerchantUnifiedPreloadSummary | null
loading: MerchantUnifiedLoadingSummary | null
@@ -135,6 +135,7 @@ export interface FirstLegOrder {
destinationWarehouseId: string
destinationWarehouseName: string
pickupMethod: 'SELF_EXPRESS' | 'PICKUP' | 'SELF_TRUCK'
requestedPickupAt: string
channelCode: string
channelName: string
pricingTaxMode: 'TAX_INCLUDED' | 'TAX_EXCLUDED'
@@ -137,11 +137,11 @@ test('downloads selected carton labels with one array request', () => {
test('exports every visible order field with normalized dates', () => {
assert.match(source, /function formatExportDateTime\(value: string \| null \| undefined\)/)
assert.match(source, /return `\$\{date\.getFullYear\(\)\}-\$\{pad\(date\.getMonth\(\) \+ 1\)\}-\$\{pad\(date\.getDate\(\)\)\} \$\{pad\(date\.getHours\(\)\)\}:\$\{pad\(date\.getMinutes\(\)\)\}:\$\{pad\(date\.getSeconds\(\)\)\}`/)
for (const label of ['订单号', '入仓码', '渠道', '目的仓', '当前订单状态', '箱数', '快递公司', '快递单号', 'SKUS', '总毛重', '总净重', '总体积', '总数量', '计费单价', '计费体积', '计费重量', '备注', '货件编号', '跟踪编号', '国内仓入库时间', '业务日期', '创建时间']) {
for (const label of ['订单号', '入仓码', '渠道', '目的仓', '当前订单状态', '箱数', '快递公司', '快递单号', 'SKUS', '总毛重', '总净重', '总体积', '总数量', '计费单价', '计费体积', '计费重量', '备注', '货件编号', '跟踪编号', '国内仓入库时间', '创建时间']) {
assert.match(source, new RegExp(`'${label}'`))
}
assert.match(source, /formatExportDateTime\(order\.inboundedAt\)/)
assert.match(source, /formatExportDateTime\(order\.businessDate\)/)
assert.doesNotMatch(source, /formatExportDateTime\(order\.businessDate\)/)
assert.match(source, /formatExportDateTime\(order\.createdAt\)/)
})
@@ -156,7 +156,7 @@ test('matches the prototype with selectable dense order-list columns', () => {
assert.match(source, />恢复默认<\/el-button>/)
assert.match(source, /const visibleOrderColumns = computed/)
assert.match(source, /:columns="visibleOrderColumns"/)
for (const label of ['入仓码', '渠道', '目的仓', '当前订单状态', '箱数', '快递公司', '快递单号', 'SKUS', '总毛重', '总净重', '总体积', '总数量', '计费单价', '计费体积', '计费重量', '备注', '货件编号', '跟踪编号', '国内仓入库时间', '业务日期']) {
for (const label of ['入仓码', '渠道', '目的仓', '当前订单状态', '箱数', '快递公司', '快递单号', 'SKUS', '总毛重', '总净重', '总体积', '总数量', '计费单价', '计费体积', '计费重量', '备注', '货件编号', '跟踪编号', '国内仓入库时间']) {
assert.match(source, new RegExp(`label: '${label}'`))
}
assert.match(source, /order\.businessSummary\.totalPackageCount/)
@@ -166,8 +166,8 @@ test('matches the prototype with selectable dense order-list columns', () => {
assert.match(source, /<template #billingUnitPrice>-<\/template>/)
assert.match(source, /<template #chargeableVolume>-<\/template>/)
assert.match(source, /inboundedAt/)
assert.match(source, /businessDate/)
assert.match(source, /<template #businessDate>-<\/template>/)
assert.doesNotMatch(source, /label: '业务日期'/)
assert.doesNotMatch(source, /<template #businessDate>/)
assert.match(source, /<template #createdAt="\{ row \}">\{\{ formatDateTime\(\(row as FirstLegOrder\)\.createdAt\) \}\}<\/template>/)
assert.match(source, /order\.skuNos\.length/)
assert.doesNotMatch(source, /pagedOrders[\s\S]{0,100}loadDetail/)
@@ -112,7 +112,6 @@ const selectableOrderColumns: DataTableColumn[] = [
{ key: 'shipmentId', label: '货件编号', width: 150, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'shipmentId' },
{ key: 'referenceId', label: '跟踪编号', width: 150, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'referenceId' },
{ key: 'receivingTime', label: '国内仓入库时间', width: 164, align: 'center', headerAlign: 'center', slotName: 'receivingTime' },
{ key: 'businessDate', label: '业务日期', width: 148, align: 'center', headerAlign: 'center', slotName: 'businessDate' },
{ key: 'expectedShipDate', label: '期望出运', width: 132, align: 'center', headerAlign: 'center', slotName: 'expectedShipDate' },
{ key: 'cabinetNo', label: '柜号 / 柜次', width: 150, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'cabinetNo' },
{ key: 'vesselVoyage', label: '船名 / 航次', width: 160, align: 'left', headerAlign: 'left', showOverflowTooltip: true, slotName: 'vesselVoyage' },
@@ -1259,13 +1258,12 @@ function exportOrderInformation() {
displayListValue(order.shipmentId || uniqueItemValues(order, 'shipmentId')),
displayListValue(order.referenceNo || uniqueItemValues(order, 'referenceId')),
formatExportDateTime(order.inboundedAt),
formatExportDateTime(order.businessDate),
formatExportDateTime(order.createdAt),
])
downloadCsv('订单信息.csv', [[
'订单号', '入仓码', '渠道', '目的仓', '当前订单状态', '箱数', '快递公司', '快递单号', 'SKUS',
'总毛重', '总净重', '总体积', '总数量', '计费单价', '计费体积', '计费重量', '备注',
'货件编号', '跟踪编号', '国内仓入库时间', '业务日期', '创建时间',
'货件编号', '跟踪编号', '国内仓入库时间', '创建时间',
], ...rows])
ElMessage.success(`已导出 ${rows.length} 条订单信息`)
} finally {
@@ -1567,7 +1565,6 @@ async function searchOrders(model: QueryModel) {
<template #shipmentId="{ row }"><span class="table-ellipsis table-code" :title="displayListValue((row as FirstLegOrder).shipmentId || uniqueItemValues(row as FirstLegOrder, 'shipmentId'))">{{ displayListValue((row as FirstLegOrder).shipmentId || uniqueItemValues(row as FirstLegOrder, 'shipmentId')) }}</span></template>
<template #referenceId="{ row }"><span class="table-ellipsis table-code" :title="displayListValue((row as FirstLegOrder).referenceNo || uniqueItemValues(row as FirstLegOrder, 'referenceId'))">{{ displayListValue((row as FirstLegOrder).referenceNo || uniqueItemValues(row as FirstLegOrder, 'referenceId')) }}</span></template>
<template #receivingTime="{ row }">{{ (row as FirstLegOrder).inboundedAt ? formatDateTime((row as FirstLegOrder).inboundedAt) : '-' }}</template>
<template #businessDate>-</template>
<template #expectedShipDate="{ row }">{{ (row as FirstLegOrder).expectedDepartureDate ? formatDateTime((row as FirstLegOrder).expectedDepartureDate) : '-' }}</template>
<template #cabinetNo="{ row }"><span class="table-ellipsis table-code" :title="displayListValue((row as FirstLegOrder).businessSummary.cabinetNo || (row as FirstLegOrder).businessSummary.containerNo)">{{ displayListValue((row as FirstLegOrder).businessSummary.cabinetNo || (row as FirstLegOrder).businessSummary.containerNo) }}</span></template>
<template #vesselVoyage="{ row }"><span class="table-ellipsis" :title="displayListValue([(row as FirstLegOrder).businessSummary.vesselName, (row as FirstLegOrder).businessSummary.voyageNo].filter(Boolean).join(' · '))">{{ displayListValue([(row as FirstLegOrder).businessSummary.vesselName, (row as FirstLegOrder).businessSummary.voyageNo].filter(Boolean).join(' · ')) }}</span></template>
@@ -41,6 +41,17 @@ test('keeps customer message actions first and covers the requested local states
assert.match(source, /auth\.hasPermission\('merchant:notification:read-all'\)/)
assert.match(source, /v-if="canMarkAllRead"/)
assert.match(source, /v-if="canMarkRead && row\.status === 'UNREAD'"/)
assert.match(source, /notificationCategoryLabel\(row\.type\)/)
assert.match(source, /notificationSentAt\(row\.sentAt\)/)
})
test('uses friendly notification values while keeping unknown server codes visible', async () => {
const source = await readFile(new URL('presentation.ts', moduleRoot), 'utf8')
assert.match(source, /ORDER: '订单消息'/)
assert.match(source, /CLIENT_ACCEPTANCE: '验收消息'/)
assert.match(source, /categoryLabels\[code\.toUpperCase\(\)\] \?\? code/)
assert.match(source, /Number\.isNaN\(parsed\.getTime\(\)\).*return source/s)
assert.match(source, /toLocaleString\('zh-CN', \{ hour12: false \}\)/)
})
test('keeps detail readable without read permission and isolates auto-read failures', async () => {
@@ -50,6 +61,8 @@ test('keeps detail readable without read permission and isolates auto-read failu
assert.match(source, /readErrorMessage\.value=/)
assert.match(source, /v-if="readErrorMessage"/)
assert.match(source, /detail\.value=null;errorMessage\.value=/)
assert.match(source, /notificationSentAt\(detail\?\.sentAt\)/)
assert.match(source, /notificationCategoryLabel\(detail\?\.type\)/)
})
test('maps all three notification authorities into the client session', async () => {
+31
View File
@@ -0,0 +1,31 @@
const categoryLabels: Record<string, string> = {
SYSTEM: '系统通知',
SYSTEM_NOTICE: '系统通知',
BUSINESS: '业务提醒',
BUSINESS_REMINDER: '业务提醒',
ALERT: '异常告警',
EXCEPTION_ALERT: '异常告警',
WARNING: '异常告警',
TASK: '任务待办',
TASK_TODO: '任务待办',
ORDER: '订单消息',
INBOUND: '入库消息',
FINANCE: '财务消息',
CUSTOMS: '报关消息',
ACCEPTANCE: '验收消息',
CLIENT_ACCEPTANCE: '验收消息',
}
export function notificationCategoryLabel(value?: string | null) {
const code = value?.trim()
if (!code) return '—'
return categoryLabels[code.toUpperCase()] ?? code
}
export function notificationSentAt(value?: string | null) {
const source = value?.trim()
if (!source) return '—'
const parsed = new Date(source)
if (Number.isNaN(parsed.getTime())) return source
return parsed.toLocaleString('zh-CN', { hour12: false })
}
@@ -7,11 +7,12 @@ import { useNotificationsStore } from '../public'
import { useAuthStore } from '@/modules/auth/public'
import { getMyNotification, markMyNotificationRead } from '../api/notifications'
import type { NotificationDetail } from '../contracts'
import { notificationCategoryLabel, notificationSentAt } from '../presentation'
const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); const notifications = useNotificationsStore(); const detail = ref<NotificationDetail | null>(null); const loading = ref(false); const errorMessage = ref(''); const readErrorMessage = ref('')
const canMarkRead = computed(() => auth.hasPermission('merchant:notification:read'))
async function markReadIfAllowed(){if(!detail.value||detail.value.status!=='UNREAD'||!canMarkRead.value)return;try{await markMyNotificationRead(detail.value.id);detail.value={...detail.value,status:'READ'};await notifications.refreshUnreadCount()}catch(error){readErrorMessage.value=error instanceof ApiError?error.message:'消息已加载,但自动标记已读失败'}}
async function load(){loading.value=true;errorMessage.value='';readErrorMessage.value='';try{detail.value=await getMyNotification(String(route.params.notificationId));await markReadIfAllowed()}catch(error){detail.value=null;errorMessage.value=error instanceof ApiError?error.message:'消息加载失败,请稍后重试'}finally{loading.value=false}}
onMounted(()=>{void load()})
</script>
<template><div class="notification-detail"><el-button link @click="router.push({ name: 'notifications-list' })"><ArrowLeft :size="16" />返回我的消息</el-button><header class="page-heading"><div><p class="eyebrow">消息详情</p><h1>{{ detail?.title || '消息详情' }}</h1></div><Bell :size="30" /></header><div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load()"><RefreshCcw :size="15" />重试</el-button></div><article v-else v-loading="loading" class="content-card"><el-alert v-if="readErrorMessage" type="warning" :closable="false" :title="readErrorMessage" show-icon /><p class="meta">{{ detail?.sentAt }} · {{ detail?.type }}</p><p class="content">{{ detail?.content || '暂无消息内容' }}</p></article></div></template>
<template><div class="notification-detail"><el-button link @click="router.push({ name: 'notifications-list' })"><ArrowLeft :size="16" />返回我的消息</el-button><header class="page-heading"><div><p class="eyebrow">消息详情</p><h1>{{ detail?.title || '消息详情' }}</h1></div><Bell :size="30" /></header><div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load()"><RefreshCcw :size="15" />重试</el-button></div><article v-else v-loading="loading" class="content-card"><el-alert v-if="readErrorMessage" type="warning" :closable="false" :title="readErrorMessage" show-icon /><p class="meta">{{ notificationSentAt(detail?.sentAt) }} · {{ notificationCategoryLabel(detail?.type) }}</p><p class="content">{{ detail?.content || '暂无消息内容' }}</p></article></div></template>
<style scoped>.notification-detail{display:flex;flex-direction:column;gap:16px}.page-heading{display:flex;justify-content:space-between;color:var(--navy)}.page-heading h1{margin:2px 0 5px;font-size:24px}.eyebrow{margin:0;color:var(--primary);font-size:11px;font-weight:700}.content-card{min-height:240px;padding:24px;border:1px solid var(--border);border-radius:8px;background:var(--surface,#fff)}.meta{color:var(--muted);font-size:12px}.content{white-space:pre-wrap;line-height:1.8;color:var(--navy)}.state-panel{display:flex;min-height:180px;align-items:center;justify-content:center;flex-direction:column;gap:12px}.state-panel--error{color:#b42318;background:#fff8f7;border:1px solid #f5c2bd;border-radius:6px}@media(max-width:720px){.content-card{padding:16px}.page-heading h1{font-size:21px}}</style>
@@ -7,6 +7,7 @@ import { useNotificationsStore } from '../public'
import { useAuthStore } from '@/modules/auth/public'
import { getMyNotifications, markAllMyNotificationsRead, markMyNotificationRead } from '../api/notifications'
import type { NotificationPage, NotificationStatus, NotificationSummary, NotificationType } from '../contracts'
import { notificationCategoryLabel, notificationSentAt } from '../presentation'
const router = useRouter()
const auth = useAuthStore()
@@ -35,7 +36,7 @@ onMounted(() => { void Promise.all([load(), notifications.refreshUnreadCount()])
<section class="content-card">
<form class="filter-bar" @submit.prevent="load(1)"><el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="消息状态"><el-option label="未读" value="UNREAD" /><el-option label="已读" value="READ" /></el-select><el-input v-model="filters.type" clearable maxlength="64" placeholder="消息分类" aria-label="消息分类" /><el-button type="primary" native-type="submit" :loading="loading">查询</el-button><el-button :disabled="loading" @click="filters.status = undefined; filters.type = undefined; load(1)">重置</el-button><el-button v-if="canMarkAllRead" class="mark-all" :disabled="loading" @click="markAllRead">全部标为已读</el-button></form>
<div v-if="errorMessage" class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load(page.page)"><RefreshCcw :size="15" />重试</el-button></div>
<el-table v-else v-loading="loading" :data="page.records" row-key="id" aria-label="我的消息列表"><el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row as NotificationSummary)">查看</el-button><el-button v-if="canMarkRead && row.status === 'UNREAD'" link type="primary" @click="markRead(row as NotificationSummary)">标为已读</el-button></template></el-table-column><el-table-column prop="title" label="消息标题" min-width="180" /><el-table-column prop="summary" label="内容摘要" min-width="260" /><el-table-column prop="type" label="分类" width="140" /><el-table-column prop="sentAt" label="发送时间" width="180" /><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'UNREAD' ? 'danger' : 'info'">{{ row.status === 'UNREAD' ? '未读' : '已读' }}</el-tag></template></el-table-column><template #empty><div class="table-empty"><Bell :size="32" /><strong>暂无消息</strong><span>当前没有可查看的站内消息。</span></div></template></el-table>
<el-table v-else v-loading="loading" :data="page.records" row-key="id" aria-label="我的消息列表"><el-table-column label="操作" width="150" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row as NotificationSummary)">查看</el-button><el-button v-if="canMarkRead && row.status === 'UNREAD'" link type="primary" @click="markRead(row as NotificationSummary)">标为已读</el-button></template></el-table-column><el-table-column prop="title" label="消息标题" min-width="180" /><el-table-column prop="summary" label="内容摘要" min-width="260" /><el-table-column label="分类" width="140"><template #default="{ row }">{{ notificationCategoryLabel(row.type) }}</template></el-table-column><el-table-column label="发送时间" width="180"><template #default="{ row }">{{ notificationSentAt(row.sentAt) }}</template></el-table-column><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'UNREAD' ? 'danger' : 'info'">{{ row.status === 'UNREAD' ? '未读' : '已读' }}</el-tag></template></el-table-column><template #empty><div class="table-empty"><Bell :size="32" /><strong>暂无消息</strong><span>当前没有可查看的站内消息。</span></div></template></el-table>
<div v-if="!errorMessage && page.total > 0" class="pagination-row"><span>共 {{ page.total }} 条</span><el-pagination :current-page="page.page" :page-size="page.size" layout="prev, pager, next" :total="page.total" @current-change="load" /></div>
</section>
</div>
@@ -36,6 +36,10 @@ test('supports tenant profile updates and authenticated qualification downloads'
assert.doesNotMatch(source, /data\.append\('qualificationType'/)
assert.match(source, /'SUPERSEDED'/)
assert.match(storeSource, /normalizeQualificationStatus\(item\.status\)/)
assert.match(source, /validTo: string \| null/)
assert.match(storeSource, /expiresAt: item\.validTo \|\| '长期有效'/)
assert.match(source, /enterpriseType: string \| null/)
assert.match(storeSource, /enterpriseType: profile\.enterpriseType \?\? ''/)
})
test('uploads the three required qualification fields through one multipart package request', () => {
@@ -65,6 +69,13 @@ test('starts empty and only loads server role and member records', () => {
assert.match(storeSource, /await updateMerchantMember/)
assert.match(storeSource, /await changeMerchantMemberStatus/)
assert.match(storeSource, /await resetMerchantMemberPassword/)
assert.match(source, /username: string/)
assert.match(source, /lastLoginAt: string \| null/)
assert.match(source, /memberCount: number/)
assert.match(storeSource, /account: member\.username/)
assert.match(storeSource, /lastLoginAt: formatLastLoginAt\(member\.lastLoginAt\)/)
assert.match(storeSource, /roles\.value\.find\(\(role\) => role\.id === roleId\)\?\.memberCount \?\? 0/)
assert.doesNotMatch(storeSource, /lastLoginAt: '服务端未提供'/)
})
test('shares the merchant permission map and preserves resources not rendered by the role editor', () => {
@@ -14,6 +14,12 @@ export interface MerchantTenantProfile {
contactName: string
contactPhone: string
contactEmail: string | null
contactWechat: string | null
enterpriseType: string | null
establishedDate: string | null
registeredCapital: number | string | null
registeredCapitalCurrency: string | null
registeredAddress: string | null
}
export interface UpdateMerchantTenantProfileRequest {
@@ -31,6 +37,7 @@ export interface MerchantQualification {
qualificationType: MerchantQualificationType
certificateNo: string | null
documentUrl: string
validTo: string | null
status: QualificationStatus
reviewComment: string | null
}
@@ -73,6 +80,7 @@ export interface MerchantRole {
roleType: 'SYSTEM' | 'CUSTOM' | string
status: 'ACTIVE' | 'INACTIVE' | string
description: string | null
memberCount: number
permissionIds: Array<number | string>
}
@@ -91,12 +99,14 @@ export interface MerchantMember {
membershipId: number | string
userId: number | string
authUserId: number | string
username: string
displayName: string
phone: string | null
email: string | null
memberType: 'OWNER' | string
memberNo: string
status: 'ACTIVE' | 'INACTIVE' | string
lastLoginAt: string | null
roleIds: Array<number | string>
}
@@ -38,6 +38,12 @@ export interface TenantCompanyProfile {
contactName: string
contactPhone: string
contactEmail: string
contactWechat: string
enterpriseType: string
establishedDate: string
registeredCapital: number | string | null
registeredCapitalCurrency: string
registeredAddress: string
auditStatus: MerchantTenantStatus
}
@@ -70,6 +76,7 @@ export interface TenantRole {
description: string
status: TenantRecordStatus
builtin: boolean
memberCount: number
permissionIds: string[]
permissionCodes: string[]
}
@@ -77,6 +84,13 @@ export interface TenantRole {
const allPermissionCodes = TENANT_PERMISSION_GROUPS.flatMap((group) => group.items.map((item) => item.code))
const mappedMerchantPermissionCodes = new Set(Object.values(FRONTEND_TO_MERCHANT_PERMISSION_CODES).flat())
function formatLastLoginAt(value: string | null) {
if (!value) return '从未登录'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString('zh-CN', { hour12: false })
}
const emptyCompany: TenantCompanyProfile = {
companyName: '',
creditCode: '',
@@ -84,6 +98,12 @@ const emptyCompany: TenantCompanyProfile = {
contactName: '',
contactPhone: '',
contactEmail: '',
contactWechat: '',
enterpriseType: '',
establishedDate: '',
registeredCapital: null,
registeredCapitalCurrency: '',
registeredAddress: '',
auditStatus: 'PROFILE_REQUIRED',
}
@@ -107,6 +127,12 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
contactName: profile.contactName,
contactPhone: profile.contactPhone,
contactEmail: profile.contactEmail ?? '',
contactWechat: profile.contactWechat ?? '',
enterpriseType: profile.enterpriseType ?? '',
establishedDate: profile.establishedDate ?? '',
registeredCapital: profile.registeredCapital,
registeredCapitalCurrency: profile.registeredCapitalCurrency ?? '',
registeredAddress: profile.registeredAddress ?? '',
auditStatus: profile.status,
}
qualifications.value = qualificationRecords.map((item) => ({
@@ -119,7 +145,7 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
} as const)[item.qualificationType],
fileName: item.documentUrl.split('/').pop() || '资质文件',
status: normalizeQualificationStatus(item.status),
expiresAt: '-',
expiresAt: item.validTo || '长期有效',
updatedAt: '-',
documentUrl: item.documentUrl,
}))
@@ -141,6 +167,7 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
description: role.description ?? '暂无角色说明',
status: role.status === 'ACTIVE' ? 'ACTIVE' : 'INACTIVE',
builtin: role.roleType === 'SYSTEM',
memberCount: role.memberCount,
permissionIds: grantedIds,
permissionCodes: role.roleType === 'SYSTEM' && role.roleCode === 'MERCHANT_OWNER' ? [...allPermissionCodes] : effectivePermissionCodes,
}
@@ -150,12 +177,12 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
return {
id: String(member.membershipId),
name: member.displayName || `企业成员 ${member.membershipId}`,
account: member.memberNo || `用户 ${member.authUserId}`,
account: member.username,
phone: member.phone ?? '',
email: member.email ?? '',
roleIds: member.roleIds.map(String),
status: member.status === 'ACTIVE' ? 'ACTIVE' : 'INACTIVE',
lastLoginAt: '服务端未提供',
lastLoginAt: formatLastLoginAt(member.lastLoginAt),
isAdministrator: member.memberType === 'OWNER',
}
}
@@ -189,6 +216,12 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
contactName: updated.contactName,
contactPhone: updated.contactPhone,
contactEmail: updated.contactEmail ?? '',
contactWechat: updated.contactWechat ?? '',
enterpriseType: updated.enterpriseType ?? '',
establishedDate: updated.establishedDate ?? '',
registeredCapital: updated.registeredCapital,
registeredCapitalCurrency: updated.registeredCapitalCurrency ?? '',
registeredAddress: updated.registeredAddress ?? '',
auditStatus: updated.status,
}
}
@@ -285,7 +318,7 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
}
function roleMemberCount(roleId: string) {
return members.value.filter((member) => member.roleIds.includes(roleId)).length
return roles.value.find((role) => role.id === roleId)?.memberCount ?? 0
}
function roleNames(roleIds: readonly string[]) {
@@ -494,6 +494,10 @@ function closeQualificationPreview() {
<dl class="profile-grid">
<div><dt>公司名称</dt><dd>{{ store.company.companyName || '-' }}</dd></div>
<div><dt>统一社会信用代码</dt><dd>{{ store.company.creditCode || '-' }}</dd></div>
<div><dt>企业类型</dt><dd>{{ store.company.enterpriseType || '-' }}</dd></div>
<div><dt>注册地</dt><dd>{{ store.company.registeredAddress || '-' }}</dd></div>
<div><dt>成立日期</dt><dd>{{ store.company.establishedDate || '-' }}</dd></div>
<div><dt>注册资本</dt><dd>{{ store.company.registeredCapital == null ? '-' : `${store.company.registeredCapital} ${store.company.registeredCapitalCurrency}`.trim() }}</dd></div>
<div><dt>法人姓名</dt><dd>{{ store.company.legalRepresentative || '-' }}</dd></div>
</dl>
</div>
@@ -507,6 +511,7 @@ function closeQualificationPreview() {
<div><dt>联系人</dt><dd>{{ store.company.contactName || '-' }}</dd></div>
<div><dt>联系电话</dt><dd>{{ store.company.contactPhone || '-' }}</dd></div>
<div><dt>联系邮箱</dt><dd>{{ store.company.contactEmail || '-' }}</dd></div>
<div><dt>联系微信</dt><dd>{{ store.company.contactWechat || '-' }}</dd></div>
</dl>
</div>
</el-tab-pane>
@@ -69,9 +69,7 @@ async function loadRoles() {
loading.value = true
loadError.value = ''
try {
const requests = [store.loadRoles()]
if (auth.hasPermission('company:member:list')) requests.push(store.loadMembers())
await Promise.all(requests)
await store.loadRoles()
} catch (error) {
loadError.value = error instanceof Error ? error.message : '角色与成员统计加载失败'
} finally {
@@ -88,7 +88,12 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /URL\.revokeObjectURL/)
assert.match(profileSource, /企业资料加载失败/)
assert.match(profileSource, /@click="loadCompanyContext"/)
assert.doesNotMatch(profileSource, /企业类型|注册资本|contactWechat|businessAddress/)
assert.match(profileSource, /<dt>企业类型<\/dt><dd>\{\{ store\.company\.enterpriseType/)
assert.match(profileSource, /<dt>注册地<\/dt><dd>\{\{ store\.company\.registeredAddress/)
assert.match(profileSource, /<dt>成立日期<\/dt><dd>\{\{ store\.company\.establishedDate/)
assert.match(profileSource, /<dt>注册资本<\/dt><dd>\{\{ store\.company\.registeredCapital/)
assert.match(profileSource, /<dt>联系微信<\/dt><dd>\{\{ store\.company\.contactWechat/)
assert.match(storeSource, /expiresAt: item\.validTo \|\| '长期有效'/)
})
test('implements member list, role assignment, password reset and status control', () => {
@@ -140,9 +145,8 @@ test('protects built-in roles and exposes role permission assignment', () => {
assert.match(rolesSource, /function assignPermissions\(role: TenantRole\)/)
assert.match(storeSource, /if \(role\.builtin\) throw new Error\('内置角色不能删除'\)/)
assert.match(storeSource, /if \(members\.value\.some\(\(member\) => member\.roleIds\.includes\(id\)\)\)/)
assert.match(rolesSource, /store\.loadRoles\(\)/)
assert.match(rolesSource, /auth\.hasPermission\('company:member:list'\).*store\.loadMembers\(\)/)
assert.match(rolesSource, /await Promise\.all\(requests\)/)
assert.match(rolesSource, /await store\.loadRoles\(\)/)
assert.doesNotMatch(rolesSource, /store\.loadMembers\(\)/)
assert.match(rolesSource, /await store\.createRole/)
assert.match(rolesSource, /await store\.updateRole/)
assert.match(rolesSource, /await store\.toggleRoleStatus/)