Release/test #3
@@ -53,7 +53,7 @@
|
||||
| 装柜完成后的退税报关资料 | `src/modules/first-leg/views/FirstLegOrdersView.vue` 的“报关资料”、`components/CustomsDocumentsDialog.vue`、`api/customs.ts` | 只显示登录客户在同柜的退税入仓码;报关开始前可上传、替换、调整同一资料覆盖的一个或多个入仓码及删除误传,全部写入携带幂等键和版本;开始后只读冻结资料,整柜完成后才能下载平台回传的一对一完成文件。Backend `firstleg/customs/container/`、`customs-v1.yaml`,二进制归 `file/` |
|
||||
| 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 |
|
||||
| 客户财务账单、入仓码联查、收款、未收余额与客户确认 | `src/modules/finance/`;确认与明细入口 `views/FinanceBillsView.vue`、`api/finance.ts`,入仓码通过 `src/modules/first-leg/public.ts` 复用 `InboundCodeAssociationDialog` | Backend `finance/`;账单行只显示服务端 `inboundCode` 并按登录客户查询业务关系,缺失显示“—”,不得回退内部 ID。收款方式由服务端枚举映射中文。出账或已出账调整后为 `PENDING_CONFIRMATION`,客户确认提交登录成员、`expectedVersion` 和幂等键 |
|
||||
| 客户资金账户、授信与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际余额、冻结金额、授信额度、授信有效期、已用授信、冻结授信、总可用余额和不可变流水;数据加载前显示“—”,不得先伪装为零。授信到期日当天仍有效,次日已用授信并入实际余额,负数实际余额会阻止提交新订单。充值只展示服务端启用的微信/支付宝静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 |
|
||||
| 客户资金账户、授信与充值申请 | `src/modules/finance/views/FinanceAccountView.vue`、`api/account.ts` | 显示当前企业 CNY 实际余额、冻结金额、授信额度、授信有效期、已用授信、冻结授信、总可用余额和不可变流水;数据加载前显示“—”,不得先伪装为零。授信到期日当天仍有效,次日已用授信并入实际余额,负数实际余额会阻止提交新订单。充值只展示服务端启用的平台统一静态收款码,客户上传 JPG/PNG 付款截图后进入待审核。客户范围只取登录态,无收款码时禁止提交,不模拟支付成功 |
|
||||
| 海外采购、美国本土卡与美本土散板 | `src/modules/cross-border/routes.ts`、`components/BusinessOrdersView.vue`、`api/businessOrders.ts` | 三个路由均为 `active`,客户范围只取 Token;采购创建的 `customerName` 只取当前认证会话企业名称,不允许自由输入或冒用其他客户。Backend `lastleg-service/` 的 `procurement/`、`trucking/`、`looseboard/` 客户接口 |
|
||||
| 海外仓入库订单 | `src/modules/order-management/routes.ts`、`views/InboundOrdersView.vue`、`api/inboundPlans.ts` | `orders/inbound` 为只读列表/详情,权限 `merchant:warehouse:inbound:list`;不展示或提交内部 ID |
|
||||
| 海外仓贴标、仓储转运、货物废弃 | `src/modules/overseas-warehouse/routes.ts`、`views/WarehouseServiceView.vue`、`api/warehouseServices.ts` | 三个路由均为 `active`;退货预报另见本节独立入口;贴标申请先选国外中转仓和有库存历史入仓单,换标类型为只换 SKU、只换箱唛、SKU+箱唛。涉及箱唛换标时,整张贴标申请只选择一个 FBA 目的仓,全部箱级明细统一提交该目的仓,模板和明细不再逐行选择目的仓。箱唛换标明细一行固定代表一箱,箱数不可编辑,提交也固定为 1。货物明细外层始终提供“SKU 关联”“箱号关联”“SKU 混箱换标”三套模板的下载、上传,不随换标类型切换;上传只进入待导入关系区,客户可勾选部分或全选后导入本次明细,只有进入明细的库存才随申请提交和冻结。明细也支持手动新增编辑,组合换标以仓库唛头连接旧/新 SKU 和新 FBA 箱唛关系。涉及箱唛换标时必须选择卡车、自提或快递;快递可选择打印面单并上传 PDF/图片。PDA 逐箱换标、上传照片并由仓库复核后,客户详情展示完工照片和“换标完成确认”;客户确认前不得继续出库,确认后卡车进入预排车,自提/快递进入换标出库待创建。历史库存位置不能解释成箱唛与具体托盘快照,客户页面不暴露托盘、库位和内部库存 ID |
|
||||
|
||||
Vendored
+26
-15
@@ -1,12 +1,15 @@
|
||||
def sendProductionWeComNotification(String status, String color, String detail) {
|
||||
def sendProductionWeComNotification(String title, String status, String color, String detail) {
|
||||
withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) {
|
||||
String sourceVersion = env.SOURCE_TAG ?: env.GIT_COMMIT ?: '—'
|
||||
String content = """## 生产部署通知
|
||||
> 项目:${env.JOB_NAME}
|
||||
String sourceVersion = env.SOURCE_TAG ?: env.GIT_COMMIT ?: '未解析'
|
||||
String duration = currentBuild.durationString?.replace(' and counting', '') ?: '—'
|
||||
String content = """## ${title}
|
||||
> 应用:客户工作台
|
||||
> Job:${env.JOB_NAME}
|
||||
> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL})
|
||||
> 提交:${sourceVersion}
|
||||
> 状态:<font color="${color}">${status}</font>
|
||||
> 详情:${detail}
|
||||
${detail}
|
||||
> 耗时:${duration}
|
||||
> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))}
|
||||
"""
|
||||
String payloadFile = 'wecom-production-notification.json'
|
||||
@@ -123,13 +126,6 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
stage('确认部署生产前端') {
|
||||
when { expression { env.IS_PRODUCTION == 'true' } }
|
||||
steps {
|
||||
input message: "确认将 main 提交 ${env.SOURCE_TAG} 部署到 app.buggoglobal.com?", ok: '确认部署'
|
||||
}
|
||||
}
|
||||
|
||||
stage('部署生产前端') {
|
||||
when { expression { env.IS_PRODUCTION == 'true' } }
|
||||
steps {
|
||||
@@ -199,7 +195,12 @@ pipeline {
|
||||
script {
|
||||
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
|
||||
try {
|
||||
sendProductionWeComNotification('部署成功', 'info', 'Client 生产前端已完成发布和部署检查')
|
||||
sendProductionWeComNotification(
|
||||
'✅ 生产部署成功',
|
||||
'成功',
|
||||
'info',
|
||||
'> 结果:Client 生产前端已完成发布和部署流程'
|
||||
)
|
||||
} catch (Exception error) {
|
||||
echo "企业微信通知失败,不影响原构建结果:${error.message}"
|
||||
}
|
||||
@@ -219,7 +220,12 @@ pipeline {
|
||||
script {
|
||||
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
|
||||
try {
|
||||
sendProductionWeComNotification('部署失败', 'warning', '请打开 Jenkins 构建日志查看失败阶段')
|
||||
sendProductionWeComNotification(
|
||||
'❌ 生产发布失败',
|
||||
'失败',
|
||||
'warning',
|
||||
'> 说明:请打开 Jenkins 构建日志查看失败阶段'
|
||||
)
|
||||
} catch (Exception error) {
|
||||
echo "企业微信通知失败,不影响原构建结果:${error.message}"
|
||||
}
|
||||
@@ -230,7 +236,12 @@ pipeline {
|
||||
script {
|
||||
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
|
||||
try {
|
||||
sendProductionWeComNotification('部署已取消', 'comment', '生产部署流程被人工取消或中止')
|
||||
sendProductionWeComNotification(
|
||||
'⏹ 生产部署已取消',
|
||||
'已取消',
|
||||
'comment',
|
||||
'> 说明:生产部署流程被人工取消或中止'
|
||||
)
|
||||
} catch (Exception error) {
|
||||
echo "企业微信通知失败,不影响原构建结果:${error.message}"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -22,8 +22,6 @@ const activeParentKeys = computed(() => new Set(getNavigationParentKeys(visibleN
|
||||
const openedKeys = ref<ReadonlySet<string>>(new Set(activeParentKeys.value))
|
||||
|
||||
function handleToggle(key: string) {
|
||||
if (activeParentKeys.value.has(key)) return
|
||||
|
||||
const next = new Set(openedKeys.value)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const source = readFileSync(new URL('./DataTable.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('keeps action-only table toolbars on the left', () => {
|
||||
assert.match(source, /'data-table__toolbar--actions-only': !\$slots\.toolbar/)
|
||||
assert.match(source, /v-if="\$slots\.toolbar" class="data-table__toolbar-heading"/)
|
||||
assert.match(source, /\.data-table__toolbar--actions-only \{ justify-content: flex-start; \}/)
|
||||
})
|
||||
@@ -143,8 +143,8 @@ function setPage(value: number) {
|
||||
|
||||
<template>
|
||||
<div class="data-table" :class="$attrs.class">
|
||||
<header v-if="$slots.toolbar || $slots['toolbar-actions']" class="data-table__toolbar">
|
||||
<div class="data-table__toolbar-heading"><slot name="toolbar" /></div>
|
||||
<header v-if="$slots.toolbar || $slots['toolbar-actions']" class="data-table__toolbar" :class="{ 'data-table__toolbar--actions-only': !$slots.toolbar }">
|
||||
<div v-if="$slots.toolbar" class="data-table__toolbar-heading"><slot name="toolbar" /></div>
|
||||
<div class="data-table__toolbar-actions"><slot name="toolbar-actions" /></div>
|
||||
</header>
|
||||
<slot name="before-table" />
|
||||
@@ -227,6 +227,7 @@ function setPage(value: number) {
|
||||
<style scoped>
|
||||
.data-table { min-width: 0; }
|
||||
.data-table__toolbar { display: flex; min-height: 48px; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--client-gap); padding: var(--client-toolbar-padding); border-bottom: 1px solid var(--client-border); }
|
||||
.data-table__toolbar--actions-only { justify-content: flex-start; }
|
||||
.data-table__toolbar-heading, .data-table__toolbar-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--client-gap); }
|
||||
.data-table__toolbar-heading { color: var(--client-muted); font-size: 12px; }
|
||||
.data-table__toolbar-heading :slotted(strong) { color: var(--client-text); font-size: 14px; }
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const recoverySource = readFileSync(new URL('./staticAssetRecovery.ts', import.meta.url), 'utf8')
|
||||
const mainSource = readFileSync(new URL('../main.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('refreshes once when a deployment invalidates lazy chunks or same-origin scripts and styles', () => {
|
||||
assert.match(mainSource, /installStaticAssetRecovery\(router\)/)
|
||||
assert.match(recoverySource, /vite:preloadError/)
|
||||
assert.match(recoverySource, /router\.onError/)
|
||||
assert.match(recoverySource, /HTMLScriptElement/)
|
||||
assert.match(recoverySource, /HTMLLinkElement/)
|
||||
assert.match(recoverySource, /window\.location\.reload\(\)/)
|
||||
assert.match(recoverySource, /RECOVERY_COOLDOWN_MS/)
|
||||
assert.match(recoverySource, /previous\?\.href === window\.location\.href/)
|
||||
})
|
||||
|
||||
test('does not turn ordinary application errors or third-party asset failures into refresh loops', () => {
|
||||
assert.match(recoverySource, /isStaticAssetLoadError\(error\)/)
|
||||
assert.match(recoverySource, /origin !== window\.location\.origin/)
|
||||
assert.match(recoverySource, /sessionStorage/)
|
||||
assert.match(recoverySource, /setTimeout\(clearRecoveryRecord, RECOVERY_STABLE_MS\)/)
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
const RECOVERY_KEY = 'client-static-asset-recovery'
|
||||
const RECOVERY_COOLDOWN_MS = 60_000
|
||||
const RECOVERY_STABLE_MS = 15_000
|
||||
|
||||
interface RecoveryRecord {
|
||||
href: string
|
||||
refreshedAt: number
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) return `${error.name}: ${error.message}`
|
||||
return typeof error === 'string' ? error : ''
|
||||
}
|
||||
|
||||
export function isStaticAssetLoadError(error: unknown) {
|
||||
return /ChunkLoadError|Loading chunk [\w-]+ failed|Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module|Unable to preload CSS/i.test(errorMessage(error))
|
||||
}
|
||||
|
||||
function readRecoveryRecord(): RecoveryRecord | null {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(RECOVERY_KEY)
|
||||
if (!raw) return null
|
||||
const value = JSON.parse(raw) as Partial<RecoveryRecord>
|
||||
return typeof value.href === 'string' && typeof value.refreshedAt === 'number'
|
||||
? { href: value.href, refreshedAt: value.refreshedAt }
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeRecoveryRecord(record: RecoveryRecord) {
|
||||
try {
|
||||
window.sessionStorage.setItem(RECOVERY_KEY, JSON.stringify(record))
|
||||
return true
|
||||
} catch {
|
||||
// Without a persistent guard, reloading could loop while the deployment is still unavailable.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function clearRecoveryRecord() {
|
||||
try {
|
||||
window.sessionStorage.removeItem(RECOVERY_KEY)
|
||||
} catch {
|
||||
// Storage availability must not affect application startup.
|
||||
}
|
||||
}
|
||||
|
||||
function recoverFromStaticAssetFailure(error: unknown, confirmedAssetFailure = false) {
|
||||
if (!confirmedAssetFailure && !isStaticAssetLoadError(error)) return false
|
||||
const now = Date.now()
|
||||
const previous = readRecoveryRecord()
|
||||
if (previous?.href === window.location.href && now - previous.refreshedAt < RECOVERY_COOLDOWN_MS) return false
|
||||
if (!writeRecoveryRecord({ href: window.location.href, refreshedAt: now })) return false
|
||||
window.location.reload()
|
||||
return true
|
||||
}
|
||||
|
||||
function failedApplicationAsset(event: Event) {
|
||||
const target = event.target
|
||||
if (target instanceof HTMLScriptElement) return target.src
|
||||
if (target instanceof HTMLLinkElement && ['stylesheet', 'modulepreload', 'preload'].includes(target.rel)) return target.href
|
||||
return ''
|
||||
}
|
||||
|
||||
export function installStaticAssetRecovery(router: Router) {
|
||||
window.addEventListener('vite:preloadError', (event) => {
|
||||
const preloadEvent = event as Event & { payload?: unknown }
|
||||
if (recoverFromStaticAssetFailure(preloadEvent.payload, true)) preloadEvent.preventDefault()
|
||||
})
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
const assetUrl = failedApplicationAsset(event)
|
||||
if (!assetUrl) return
|
||||
try {
|
||||
if (new URL(assetUrl, window.location.href).origin !== window.location.origin) return
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
recoverFromStaticAssetFailure(assetUrl, true)
|
||||
}, true)
|
||||
|
||||
router.onError((error) => {
|
||||
recoverFromStaticAssetFailure(error)
|
||||
})
|
||||
|
||||
window.setTimeout(clearRecoveryRecord, RECOVERY_STABLE_MS)
|
||||
}
|
||||
@@ -119,7 +119,8 @@ test('source contract: uses a native recursive disclosure list without Element m
|
||||
assert.match(sidebarMenuSource, /const activeParentKeys = computed\(\(\) => new Set\(getNavigationParentKeys\(visibleNavigation\.value, props\.activePath\)\)\)/)
|
||||
assert.match(sidebarMenuSource, /const openedKeys = ref<ReadonlySet<string>>\(new Set\(activeParentKeys\.value\)\)/)
|
||||
assert.match(sidebarMenuSource, /openedKeys\.value = new Set\(\[\.\.\.openedKeys\.value, \.\.\.activeParentKeys\.value\]\)/)
|
||||
assert.match(sidebarMenuSource, /if \(activeParentKeys\.value\.has\(key\)\) return/)
|
||||
assert.doesNotMatch(sidebarMenuSource, /if \(activeParentKeys\.value\.has\(key\)\) return/)
|
||||
assert.match(sidebarMenuSource, /if \(next\.has\(key\)\) next\.delete\(key\)/)
|
||||
assert.match(sidebarMenuSource, /<ul class="client-nav-menu">/)
|
||||
assert.match(sidebarMenuSource, /:active-path="activePath"/)
|
||||
assert.match(sidebarMenuSource, /:opened-keys="openedKeys"/)
|
||||
|
||||
@@ -6,9 +6,12 @@ import { ElConfigProvider } from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { installStaticAssetRecovery } from './core/staticAssetRecovery'
|
||||
import 'element-plus/es/components/message/style/css'
|
||||
import 'element-plus/es/components/message-box/style/css'
|
||||
import './styles/index.css'
|
||||
|
||||
installStaticAssetRecovery(router)
|
||||
|
||||
const app = createApp({ render: () => h(ElConfigProvider, { locale: zhCn }, { default: () => h(App) }) })
|
||||
app.use(createPinia()).use(router).mount('#app')
|
||||
|
||||
@@ -393,7 +393,6 @@ onMounted(() => { if (props.detailPage) void loadDetail(); else if (!props.creat
|
||||
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="订单查询条件" :fields="queryFields" :search-on-reset="false" @search="searchOrders" @reset="resetQuery" />
|
||||
<div class="business-table-card">
|
||||
<DataTable v-model:page="query.page" v-model:page-size="query.size" :data="rows" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[10, 20, 50, 100]" :row-actions="getRowActions" row-key="orderNo" empty-text="暂无订单">
|
||||
<template #toolbar><strong>订单列表</strong><span>共 {{ total }} 条</span></template>
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="resetForm(); mode = 'create'">新增下单</el-button></template>
|
||||
<template v-if="error" #error><div class="business-load-error" role="alert"><span>{{ error }}</span><el-button size="small" @click="load">重新加载</el-button></div></template>
|
||||
<template #orderNo="{ row }"><button class="business-order-link" type="button" @click="openDetail(row)">{{ (row as BusinessRow).orderNo }}</button></template>
|
||||
|
||||
@@ -61,14 +61,12 @@ export interface AccountLedgerEntry {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type RechargePaymentMethod = 'WECHAT' | 'ALIPAY'
|
||||
export type RechargeStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
|
||||
export interface RechargeRequest {
|
||||
requestId: string
|
||||
requestNo: string
|
||||
amount: string
|
||||
paymentMethod: RechargePaymentMethod
|
||||
voucherFileId: string
|
||||
voucherFileName: string
|
||||
status: RechargeStatus
|
||||
@@ -80,13 +78,6 @@ export interface RechargeRequest {
|
||||
reviewedAt: string | null
|
||||
}
|
||||
|
||||
export interface RechargeChannel {
|
||||
paymentMethod: RechargePaymentMethod
|
||||
displayName: string
|
||||
enabled: boolean
|
||||
qrCodeUrl: string | null
|
||||
}
|
||||
|
||||
export function getFinanceAccount() {
|
||||
return apiRequest<FinanceAccount>({ method: 'GET', url: '/client/finance/v1/account' })
|
||||
}
|
||||
@@ -109,15 +100,9 @@ export async function getRechargeRequests(page: number, size: number) {
|
||||
return normalizePageResult(result)
|
||||
}
|
||||
|
||||
export function getRechargeChannels() {
|
||||
return apiRequest<RechargeChannel[]>({ method: 'GET', url: '/client/finance/v1/recharge-channels' })
|
||||
}
|
||||
|
||||
export function createRechargeRequest(amount: string, paymentMethod: RechargePaymentMethod, file: File,
|
||||
idempotencyKey: string) {
|
||||
export function createRechargeRequest(amount: string, file: File, idempotencyKey: string) {
|
||||
const formData = new FormData()
|
||||
formData.append('amount', amount)
|
||||
formData.append('paymentMethod', paymentMethod)
|
||||
formData.append('file', file, file.name)
|
||||
return apiRequest<RechargeRequest>({
|
||||
method: 'POST',
|
||||
|
||||
@@ -8,13 +8,13 @@ const routesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf
|
||||
const permissionsSource = readFileSync(new URL('../../tenant-management/contracts.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('uses only authenticated client finance account endpoints without customer scope', () => {
|
||||
for (const path of ['/client/finance/v1/account', '/client/finance/v1/account/ledger', '/client/finance/v1/recharge-requests', '/client/finance/v1/recharge-channels']) {
|
||||
for (const path of ['/client/finance/v1/account', '/client/finance/v1/account/ledger', '/client/finance/v1/recharge-requests']) {
|
||||
assert.match(apiSource, new RegExp(path.replaceAll('/', '\\/')))
|
||||
}
|
||||
assert.match(apiSource, /headers: \{ 'Idempotency-Key': idempotencyKey \}/)
|
||||
assert.match(apiSource, /formData\.append\('amount', amount\)/)
|
||||
assert.match(apiSource, /formData\.append\('paymentMethod', paymentMethod\)/)
|
||||
assert.match(apiSource, /formData\.append\('file', file, file\.name\)/)
|
||||
assert.doesNotMatch(apiSource, /RechargePaymentMethod|paymentMethod|WECHAT|ALIPAY/)
|
||||
assert.doesNotMatch(apiSource, /customerId|tenantId|mock|fallback/i)
|
||||
})
|
||||
|
||||
@@ -23,12 +23,14 @@ test('activates the account route and maps read and recharge permissions indepen
|
||||
assert.match(permissionsSource, /'merchant:finance:account:view': \['merchant:finance:account:view'\]/)
|
||||
assert.match(permissionsSource, /'merchant:finance:recharge:create': \['merchant:finance:recharge:create'\]/)
|
||||
assert.match(viewSource, /auth\.hasPermission\('merchant:finance:recharge:create'\)/)
|
||||
assert.match(viewSource, /<el-button v-if="canRecharge"/)
|
||||
assert.match(viewSource, /class="balance-title"[\s\S]*?<el-button v-if="canRecharge" size="small" type="primary"/)
|
||||
assert.match(viewSource, /\.balance-summary \{[^}]*grid-template-columns: repeat\(6,/)
|
||||
assert.doesNotMatch(viewSource, /class="account-actions"|class="balance-grid"/)
|
||||
assert.doesNotMatch(viewSource, /class="account-heading"/)
|
||||
})
|
||||
|
||||
test('renders server balances, ledger and recharge states without internal identifiers', () => {
|
||||
for (const field of ['actualBalance', 'frozenBalance', 'availableBalance', 'creditLimit', 'creditUsed', 'availableCredit']) assert.match(viewSource, new RegExp(`account\\?\\.${field}`))
|
||||
for (const field of ['creditUsedAfter', 'availableCreditAfter']) assert.match(viewSource, new RegExp(`row\\.${field}`))
|
||||
assert.match(viewSource, /实际可用资金与可用授信合计/)
|
||||
assert.match(viewSource, /CREDIT_LIMIT: '授信调整'/)
|
||||
assert.match(viewSource, /row-key="businessNo"/)
|
||||
@@ -39,9 +41,12 @@ test('renders server balances, ledger and recharge states without internal ident
|
||||
assert.match(viewSource, /v-loading="pageLoading"/)
|
||||
assert.match(viewSource, /function formatAccountAmount[\s\S]*value == null \? '—' : formatAmount\(value\)/)
|
||||
assert.match(viewSource, /formatAccountAmount\(account\?\.actualBalance\)/)
|
||||
assert.match(viewSource, /:default-current-page="ledgerPage"/)
|
||||
assert.match(viewSource, /:default-current-page="rechargePage"/)
|
||||
assert.doesNotMatch(viewSource, /<el-pagination (?:v-model:current-page|:current-page)=/)
|
||||
assert.match(viewSource, /<DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize"/)
|
||||
assert.match(viewSource, /<DataTable v-model:page="rechargePage" v-model:page-size="rechargePageSize"/)
|
||||
assert.match(viewSource, /const ledgerColumns: DataTableColumn\[]/)
|
||||
assert.match(viewSource, /const rechargeColumns: DataTableColumn\[]/)
|
||||
assert.doesNotMatch(viewSource, /label: '已用授信'|label: '可用授信'/)
|
||||
assert.doesNotMatch(viewSource, /<el-table|<el-pagination/)
|
||||
assert.match(apiSource, /function normalizePageResult<[\s\S]*total: Number\(result\.total\)/)
|
||||
assert.match(apiSource, /page: Number\(result\.page\)/)
|
||||
assert.doesNotMatch(viewSource, /requestId|ledgerId|customerId|tenantId/)
|
||||
@@ -49,13 +54,16 @@ test('renders server balances, ledger and recharge states without internal ident
|
||||
|
||||
test('matches the frozen recharge workflow and preserves an idempotency key for failed retries', () => {
|
||||
assert.match(viewSource, /title="账户充值" width="760px"/)
|
||||
for (const amount of ['50.00', '100.00', '200.00', '500.00']) assert.match(viewSource, new RegExp(amount.replace('.', '\\.')))
|
||||
assert.match(viewSource, /\['WECHAT', 'ALIPAY'\]/)
|
||||
assert.match(viewSource, /selectedChannel\.value\?\.enabled && selectedChannel\.value\.qrCodeUrl/)
|
||||
for (const amount of ['1000', '5000', '20000', '50000']) assert.match(viewSource, new RegExp(amount))
|
||||
assert.match(viewSource, /自定义/)
|
||||
assert.match(viewSource, /src="\/customer-recharge-qr\.png"/)
|
||||
assert.match(viewSource, /alt="平台收款二维码"/)
|
||||
assert.match(viewSource, /请扫描平台收款二维码,付款后上传截图。/)
|
||||
assert.match(viewSource, /accept="\.jpg,\.jpeg,\.png,image\/jpeg,image\/png"/)
|
||||
assert.match(viewSource, /if \(!pendingIdempotencyKey\)[\s\S]*globalThis\.crypto\?\.randomUUID\?\.\(\)/)
|
||||
assert.match(viewSource, /pendingIdempotencyKey = ''[\s\S]*rechargeVisible\.value = false/)
|
||||
assert.match(viewSource, /:disabled="!canSubmit"/)
|
||||
assert.match(viewSource, /await loadAccountData\(\)/)
|
||||
assert.doesNotMatch(viewSource, /payment SDK|支付成功|模拟成功/)
|
||||
assert.doesNotMatch(viewSource, /微信支付|支付宝|payment-methods|paymentMethod/)
|
||||
})
|
||||
|
||||
@@ -3,18 +3,16 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { CircleDollarSign, RefreshCcw, Upload } from '@lucide/vue'
|
||||
import { ElMessage, type UploadFile, type UploadFiles, type UploadInstance } from 'element-plus'
|
||||
import { ApiError } from '@/api/http'
|
||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import {
|
||||
createRechargeRequest,
|
||||
getAccountLedger,
|
||||
getFinanceAccount,
|
||||
getRechargeChannels,
|
||||
getRechargeRequests,
|
||||
type AccountChangeType,
|
||||
type AccountLedgerEntry,
|
||||
type FinanceAccount,
|
||||
type RechargeChannel,
|
||||
type RechargePaymentMethod,
|
||||
type RechargeRequest,
|
||||
type RechargeStatus,
|
||||
} from '../api/account'
|
||||
@@ -23,15 +21,15 @@ const auth = useAuthStore()
|
||||
const account = ref<FinanceAccount | null>(null)
|
||||
const ledger = ref<AccountLedgerEntry[]>([])
|
||||
const rechargeRequests = ref<RechargeRequest[]>([])
|
||||
const channels = ref<RechargeChannel[]>([])
|
||||
const pageLoading = ref(false)
|
||||
const pageError = ref('')
|
||||
const channelsError = ref('')
|
||||
const qrCodeLoadError = ref(false)
|
||||
const ledgerPage = ref(1)
|
||||
const ledgerTotal = ref(0)
|
||||
const ledgerPageSize = ref(10)
|
||||
const rechargePage = ref(1)
|
||||
const rechargeTotal = ref(0)
|
||||
const pageSize = 10
|
||||
const rechargePageSize = ref(10)
|
||||
const rechargeVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const submitError = ref('')
|
||||
@@ -39,11 +37,11 @@ const voucherFile = ref<File | null>(null)
|
||||
const voucherUpload = ref<UploadInstance>()
|
||||
let pendingIdempotencyKey = ''
|
||||
|
||||
const form = reactive({ amount: '100.00', paymentMethod: 'WECHAT' as RechargePaymentMethod })
|
||||
const quickAmounts = ['50.00', '100.00', '200.00', '500.00']
|
||||
const form = reactive({ amount: '1000' })
|
||||
const quickAmounts = ['1000', '5000', '20000', '50000']
|
||||
const amountMode = ref<'preset' | 'custom'>('preset')
|
||||
const canRecharge = computed(() => auth.hasPermission('merchant:finance:recharge:create'))
|
||||
const selectedChannel = computed(() => channels.value.find((item) => item.paymentMethod === form.paymentMethod))
|
||||
const channelReady = computed(() => Boolean(selectedChannel.value?.enabled && selectedChannel.value.qrCodeUrl))
|
||||
const channelReady = computed(() => !qrCodeLoadError.value)
|
||||
const amountValid = computed(() => {
|
||||
const value = Number(form.amount)
|
||||
return Number.isFinite(value) && value > 0 && /^\d+(?:\.\d{1,2})?$/.test(form.amount.trim())
|
||||
@@ -54,7 +52,27 @@ const changeLabels: Record<AccountChangeType, string> = {
|
||||
RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费', CREDIT_LIMIT: '授信调整',
|
||||
}
|
||||
const statusLabels: Record<RechargeStatus, string> = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' }
|
||||
const methodLabels: Record<RechargePaymentMethod, string> = { WECHAT: '微信支付', ALIPAY: '支付宝' }
|
||||
const ledgerColumns: DataTableColumn[] = [
|
||||
{ label: '业务单号', prop: 'businessNo', minWidth: 180 },
|
||||
{ label: '变动类型', slotName: 'changeType', width: 100 },
|
||||
{ label: '变动金额', slotName: 'amount', width: 120, align: 'right' },
|
||||
{ label: '实际余额', slotName: 'actualBalance', width: 120, align: 'right' },
|
||||
{ label: '冻结金额', slotName: 'frozenBalance', width: 120, align: 'right' },
|
||||
{ label: '可用余额', slotName: 'availableBalance', width: 120, align: 'right' },
|
||||
{ label: '操作人', slotName: 'operatorName', width: 120 },
|
||||
{ label: '备注', slotName: 'remark', minWidth: 180 },
|
||||
{ label: '发生时间', slotName: 'createdAt', width: 160 },
|
||||
]
|
||||
const rechargeColumns: DataTableColumn[] = [
|
||||
{ label: '充值申请单号', prop: 'requestNo', minWidth: 180 },
|
||||
{ label: '金额', slotName: 'amount', width: 120, align: 'right' },
|
||||
{ label: '付款截图', prop: 'voucherFileName', minWidth: 150 },
|
||||
{ label: '状态', slotName: 'status', width: 100 },
|
||||
{ label: '提交人', slotName: 'submittedByName', width: 120 },
|
||||
{ label: '审核备注', slotName: 'reviewRemark', minWidth: 180 },
|
||||
{ label: '提交时间', slotName: 'createdAt', width: 160 },
|
||||
{ label: '审核时间', slotName: 'reviewedAt', width: 160 },
|
||||
]
|
||||
|
||||
function messageOf(error: unknown, fallback: string) {
|
||||
return error instanceof ApiError ? error.message : fallback
|
||||
@@ -86,8 +104,8 @@ function amountClass(amount: string) {
|
||||
async function loadAccountData() {
|
||||
pageLoading.value = true
|
||||
pageError.value = ''
|
||||
channelsError.value = ''
|
||||
const requests = [getFinanceAccount(), getAccountLedger(ledgerPage.value, pageSize), getRechargeRequests(rechargePage.value, pageSize)] as const
|
||||
qrCodeLoadError.value = false
|
||||
const requests = [getFinanceAccount(), getAccountLedger(ledgerPage.value, ledgerPageSize.value), getRechargeRequests(rechargePage.value, rechargePageSize.value)] as const
|
||||
try {
|
||||
const [accountResult, ledgerResult, rechargeResult] = await Promise.all(requests)
|
||||
account.value = accountResult
|
||||
@@ -105,20 +123,11 @@ async function loadAccountData() {
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
if (canRecharge.value) {
|
||||
try {
|
||||
channels.value = await getRechargeChannels()
|
||||
} catch (error) {
|
||||
channels.value = []
|
||||
channelsError.value = messageOf(error, '充值渠道加载失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function changeLedgerPage(nextPage: number) {
|
||||
ledgerPage.value = nextPage
|
||||
try {
|
||||
const result = await getAccountLedger(nextPage, pageSize)
|
||||
const result = await getAccountLedger(nextPage, ledgerPageSize.value)
|
||||
ledger.value = result.records
|
||||
ledgerTotal.value = result.total
|
||||
} catch (error) {
|
||||
@@ -127,9 +136,8 @@ async function changeLedgerPage(nextPage: number) {
|
||||
}
|
||||
|
||||
async function changeRechargePage(nextPage: number) {
|
||||
rechargePage.value = nextPage
|
||||
try {
|
||||
const result = await getRechargeRequests(nextPage, pageSize)
|
||||
const result = await getRechargeRequests(nextPage, rechargePageSize.value)
|
||||
rechargeRequests.value = result.records
|
||||
rechargeTotal.value = result.total
|
||||
} catch (error) {
|
||||
@@ -139,13 +147,26 @@ async function changeRechargePage(nextPage: number) {
|
||||
|
||||
function openRecharge() {
|
||||
submitError.value = ''
|
||||
qrCodeLoadError.value = false
|
||||
amountMode.value = 'preset'
|
||||
form.amount = '1000'
|
||||
rechargeVisible.value = true
|
||||
}
|
||||
|
||||
function handleQrCodeError() {
|
||||
qrCodeLoadError.value = true
|
||||
}
|
||||
|
||||
function chooseAmount(amount: string) {
|
||||
amountMode.value = 'preset'
|
||||
form.amount = amount
|
||||
}
|
||||
|
||||
function chooseCustomAmount() {
|
||||
amountMode.value = 'custom'
|
||||
if (quickAmounts.includes(form.amount)) form.amount = ''
|
||||
}
|
||||
|
||||
function handleVoucherChange(uploadFile: UploadFile, _uploadFiles: UploadFiles) {
|
||||
const file = uploadFile.raw
|
||||
if (!file) return
|
||||
@@ -175,7 +196,7 @@ async function submitRecharge() {
|
||||
return
|
||||
}
|
||||
if (!channelReady.value) {
|
||||
submitError.value = `${methodLabels[form.paymentMethod]}收款二维码尚未配置,暂不能提交充值申请`
|
||||
submitError.value = '收款二维码尚未配置,暂不能提交充值申请'
|
||||
return
|
||||
}
|
||||
if (!voucherFile.value) {
|
||||
@@ -187,7 +208,7 @@ async function submitRecharge() {
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await createRechargeRequest(normalizedAmount(), form.paymentMethod, voucherFile.value, pendingIdempotencyKey)
|
||||
await createRechargeRequest(normalizedAmount(), voucherFile.value, pendingIdempotencyKey)
|
||||
pendingIdempotencyKey = ''
|
||||
rechargeVisible.value = false
|
||||
voucherFile.value = null
|
||||
@@ -201,28 +222,26 @@ async function submitRecharge() {
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => form.amount, () => form.paymentMethod, voucherFile], () => {
|
||||
watch([() => form.amount, voucherFile], () => {
|
||||
if (!submitting.value) pendingIdempotencyKey = ''
|
||||
submitError.value = ''
|
||||
})
|
||||
|
||||
watch([ledgerPage, ledgerPageSize], () => { void changeLedgerPage(ledgerPage.value) })
|
||||
watch([rechargePage, rechargePageSize], () => { void changeRechargePage(rechargePage.value) })
|
||||
|
||||
onMounted(() => { void loadAccountData() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="pageLoading" class="finance-account-page" :aria-busy="pageLoading">
|
||||
<div class="account-heading">
|
||||
<div><h2>资金账户</h2><p>查看本企业 CNY 资金、授信额度、账户流水和充值申请状态。</p></div>
|
||||
<el-button v-if="canRecharge" type="primary" @click="openRecharge"><CircleDollarSign :size="17" />账户充值</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="pageError" class="account-state account-state--error" role="alert">
|
||||
<p>{{ pageError }}</p><el-button @click="loadAccountData"><RefreshCcw :size="15" />重新加载</el-button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<section class="balance-grid" aria-label="账户余额">
|
||||
<article><span>实际余额</span><strong>¥ {{ formatAccountAmount(account?.actualBalance) }}</strong><small>当前账户实际资金</small></article>
|
||||
<section class="balance-summary" aria-label="账户余额">
|
||||
<article><div class="balance-title"><span>实际余额</span><el-button v-if="canRecharge" size="small" type="primary" @click="openRecharge"><CircleDollarSign :size="15" />账户充值</el-button></div><strong>¥ {{ formatAccountAmount(account?.actualBalance) }}</strong><small>当前账户实际资金</small></article>
|
||||
<article><span>冻结金额</span><strong>¥ {{ formatAccountAmount(account?.frozenBalance) }}</strong><small>暂不可使用的资金</small></article>
|
||||
<article class="balance-available"><span>总可用余额</span><strong>¥ {{ formatAccountAmount(account?.availableBalance) }}</strong><small>实际可用资金与可用授信合计</small></article>
|
||||
<article><span>授信额度</span><strong>¥ {{ formatAccountAmount(account?.creditLimit) }}</strong><small>{{ account?.creditExpiresOn ? `${account.creditExpiresOn} ${account.creditActive ? '到期' : '已到期'}` : '未设置有效期' }}</small></article>
|
||||
@@ -232,57 +251,46 @@ onMounted(() => { void loadAccountData() })
|
||||
|
||||
<section class="account-card">
|
||||
<div class="section-heading"><div><h3>账户流水</h3><p>每次资金变动均生成唯一业务单号。</p></div></div>
|
||||
<el-table :data="ledger" row-key="businessNo" empty-text="暂无资金流水">
|
||||
<el-table-column prop="businessNo" label="业务单号" min-width="190" />
|
||||
<el-table-column label="变动类型" width="110"><template #default="{ row }">{{ changeLabels[row.changeType as AccountChangeType] }}</template></el-table-column>
|
||||
<el-table-column label="变动金额" width="145" align="right"><template #default="{ row }"><strong :class="amountClass(row.amount)">{{ Number(row.amount) >= 0 ? '+' : '' }}¥ {{ formatAmount(row.amount) }}</strong></template></el-table-column>
|
||||
<el-table-column label="实际余额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.actualBalanceAfter) }}</template></el-table-column>
|
||||
<el-table-column label="冻结金额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.frozenBalanceAfter) }}</template></el-table-column>
|
||||
<el-table-column label="可用余额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.availableBalanceAfter) }}</template></el-table-column>
|
||||
<el-table-column label="已用授信" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.creditUsedAfter) }}</template></el-table-column>
|
||||
<el-table-column label="可用授信" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.availableCreditAfter) }}</template></el-table-column>
|
||||
<el-table-column prop="operatorName" label="操作人" min-width="130"><template #default="{ row }">{{ row.operatorName || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="180"><template #default="{ row }">{{ row.remark || '—' }}</template></el-table-column>
|
||||
<el-table-column label="发生时间" width="170"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<div v-if="ledgerTotal" class="pagination-row"><span>共 {{ ledgerTotal }} 条</span><el-pagination :default-current-page="ledgerPage" :page-size="pageSize" layout="prev, pager, next" :total="ledgerTotal" @current-change="changeLedgerPage" /></div>
|
||||
<DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" :data="ledger" :columns="ledgerColumns" :total="ledgerTotal" :page-sizes="[10, 20, 50]" row-key="businessNo" empty-text="暂无资金流水">
|
||||
<template #changeType="{ row }">{{ changeLabels[row.changeType as AccountChangeType] }}</template>
|
||||
<template #amount="{ row }"><strong :class="amountClass(row.amount)">{{ Number(row.amount) >= 0 ? '+' : '' }}¥ {{ formatAmount(row.amount) }}</strong></template>
|
||||
<template #actualBalance="{ row }">¥ {{ formatAmount(row.actualBalanceAfter) }}</template>
|
||||
<template #frozenBalance="{ row }">¥ {{ formatAmount(row.frozenBalanceAfter) }}</template>
|
||||
<template #availableBalance="{ row }">¥ {{ formatAmount(row.availableBalanceAfter) }}</template>
|
||||
<template #operatorName="{ row }">{{ row.operatorName || '—' }}</template>
|
||||
<template #remark="{ row }">{{ row.remark || '—' }}</template>
|
||||
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<section class="account-card">
|
||||
<div class="section-heading"><div><h3>充值申请</h3><p>付款后上传截图,平台审核通过后资金入账。</p></div></div>
|
||||
<el-table :data="rechargeRequests" row-key="requestNo" empty-text="暂无充值申请">
|
||||
<el-table-column prop="requestNo" label="充值申请单号" min-width="190" />
|
||||
<el-table-column label="金额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.amount) }}</template></el-table-column>
|
||||
<el-table-column label="付款方式" width="120"><template #default="{ row }">{{ methodLabels[row.paymentMethod as RechargePaymentMethod] }}</template></el-table-column>
|
||||
<el-table-column prop="voucherFileName" label="付款截图" min-width="170" />
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status as RechargeStatus)">{{ statusLabels[row.status as RechargeStatus] }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="submittedByName" label="提交人" min-width="120"><template #default="{ row }">{{ row.submittedByName || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="reviewRemark" label="审核备注" min-width="180"><template #default="{ row }">{{ row.reviewRemark || '—' }}</template></el-table-column>
|
||||
<el-table-column label="提交时间" width="170"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="审核时间" width="170"><template #default="{ row }">{{ formatDateTime(row.reviewedAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<div v-if="rechargeTotal" class="pagination-row"><span>共 {{ rechargeTotal }} 条</span><el-pagination :default-current-page="rechargePage" :page-size="pageSize" layout="prev, pager, next" :total="rechargeTotal" @current-change="changeRechargePage" /></div>
|
||||
<DataTable v-model:page="rechargePage" v-model:page-size="rechargePageSize" :data="rechargeRequests" :columns="rechargeColumns" :total="rechargeTotal" :page-sizes="[10, 20, 50]" row-key="requestNo" empty-text="暂无充值申请">
|
||||
<template #amount="{ row }">¥ {{ formatAmount(row.amount) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="statusType(row.status as RechargeStatus)">{{ statusLabels[row.status as RechargeStatus] }}</el-tag></template>
|
||||
<template #submittedByName="{ row }">{{ row.submittedByName || '—' }}</template>
|
||||
<template #reviewRemark="{ row }">{{ row.reviewRemark || '—' }}</template>
|
||||
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
<template #reviewedAt="{ row }">{{ formatDateTime(row.reviewedAt) }}</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="rechargeVisible" title="账户充值" width="760px" :close-on-click-modal="false" :close-on-press-escape="!submitting" :show-close="!submitting" class="recharge-dialog">
|
||||
<div class="recharge-form">
|
||||
<label class="field-label" for="recharge-amount">充值金额</label>
|
||||
<el-input id="recharge-amount" v-model="form.amount" size="large" inputmode="decimal" placeholder="请输入充值金额" aria-label="充值金额"><template #prefix>¥</template></el-input>
|
||||
<el-input v-if="amountMode === 'custom'" id="recharge-amount" v-model="form.amount" size="large" inputmode="decimal" placeholder="请输入充值金额" aria-label="充值金额"><template #prefix>¥</template></el-input>
|
||||
<div class="quick-amounts" aria-label="快捷充值金额">
|
||||
<button v-for="amount in quickAmounts" :key="amount" type="button" :class="{ active: form.amount === amount }" @click="chooseAmount(amount)">¥{{ Number(amount) }}</button>
|
||||
<button v-for="amount in quickAmounts" :key="amount" type="button" :class="{ active: amountMode === 'preset' && form.amount === amount }" @click="chooseAmount(amount)">¥{{ Number(amount).toLocaleString() }}</button>
|
||||
<button type="button" :class="{ active: amountMode === 'custom' }" @click="chooseCustomAmount">自定义</button>
|
||||
</div>
|
||||
|
||||
<div class="payment-methods" aria-label="付款方式">
|
||||
<button v-for="method in (['WECHAT', 'ALIPAY'] as RechargePaymentMethod[])" :key="method" type="button" :class="[`payment-${method.toLowerCase()}`, { active: form.paymentMethod === method }]" @click="form.paymentMethod = method"><span>{{ method === 'WECHAT' ? '微' : '支' }}</span>{{ methodLabels[method] }}</button>
|
||||
<div v-if="channelReady" class="qr-panel">
|
||||
<img src="/customer-recharge-qr.png" alt="平台收款二维码" @error="handleQrCodeError">
|
||||
<div><strong>扫码付款</strong><p>请扫描平台收款二维码,付款后上传截图。</p></div>
|
||||
</div>
|
||||
|
||||
<div v-if="channelsError" class="channel-warning" role="alert">{{ channelsError }}</div>
|
||||
<div v-else-if="channelReady" class="qr-panel">
|
||||
<img :src="selectedChannel?.qrCodeUrl || ''" :alt="`${methodLabels[form.paymentMethod]}收款二维码`">
|
||||
<p>请使用{{ methodLabels[form.paymentMethod] }}扫码完成付款</p>
|
||||
</div>
|
||||
<div v-else class="channel-warning" role="status">{{ methodLabels[form.paymentMethod] }}收款二维码尚未配置,暂不能提交充值申请。</div>
|
||||
<div v-else-if="qrCodeLoadError" class="channel-warning" role="alert">平台收款二维码资源不可用,请联系管理员更新二维码</div>
|
||||
<div v-else class="channel-warning" role="status">收款二维码尚未配置,暂不能提交充值申请。</div>
|
||||
|
||||
<div class="voucher-upload">
|
||||
<span class="field-label">付款截图</span>
|
||||
@@ -300,18 +308,21 @@ onMounted(() => { void loadAccountData() })
|
||||
|
||||
<style scoped>
|
||||
.finance-account-page { display: flex; min-height: 420px; flex-direction: column; gap: 16px; }
|
||||
.account-heading, .section-heading, .pagination-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.account-heading h2, .section-heading h3 { margin: 0; color: var(--navy); }.account-heading h2 { font-size: 22px; }.section-heading h3 { font-size: 16px; }
|
||||
.account-heading p, .section-heading p { margin: 5px 0 0; color: var(--muted); font-size: 13px; }.account-heading .el-button { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.balance-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
.balance-grid article { display: flex; min-height: 132px; flex-direction: column; padding: 22px 24px; border: 1px solid var(--border); border-radius: 10px; background: #fff; }
|
||||
.balance-grid span { color: var(--muted); font-size: 13px; }.balance-grid strong { margin-top: 14px; color: var(--navy); font-size: 28px; line-height: 1; }.balance-grid small { margin-top: auto; color: #8a98a8; }.balance-grid .balance-available { border-color: #b9e1db; background: #f3faf8; }.balance-grid .balance-available strong { color: #058b78; }
|
||||
.account-card { min-width: 0; padding: 18px; border: 1px solid var(--border); border-radius: 8px; background: #fff; }.section-heading { margin-bottom: 14px; }
|
||||
.pagination-row { padding-top: 14px; color: var(--muted); font-size: 12px; }.amount-positive { color: #078a72; }.amount-negative { color: #b42318; }
|
||||
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.section-heading h3 { margin: 0; color: var(--navy); font-size: 16px; }
|
||||
.section-heading p { margin: 5px 0 0; color: var(--muted); font-size: 13px; }
|
||||
.balance-summary { display: grid; overflow: hidden; grid-template-columns: repeat(6, minmax(0, 1fr)); border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }
|
||||
.balance-summary article { display: flex; min-width: 0; min-height: 104px; flex-direction: column; padding: 14px 16px; border-right: 1px solid var(--client-border); }
|
||||
.balance-summary article:last-child { border-right: 0; }
|
||||
.balance-summary span { color: var(--muted); font-size: 12px; }.balance-summary strong { margin-top: 10px; overflow: hidden; color: var(--navy); font-size: 22px; line-height: 1.1; text-overflow: ellipsis; white-space: nowrap; }.balance-summary small { margin-top: auto; overflow: hidden; color: #8a98a8; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.balance-title { display: flex; min-height: 28px; align-items: center; justify-content: space-between; gap: 8px; }.balance-title .el-button { flex: 0 0 auto; height: 28px; padding: 0 9px; }.balance-title .el-button :deep(span) { display: inline-flex; align-items: center; gap: 4px; color: inherit; }
|
||||
.balance-summary .balance-available { background: #f3faf8; }.balance-summary .balance-available strong { color: #058b78; }
|
||||
.account-card { min-width: 0; overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: #fff; }.section-heading { padding: 14px 16px; border-bottom: 1px solid var(--client-border); }
|
||||
.amount-positive { color: #078a72; }.amount-negative { color: #b42318; }
|
||||
.account-state { display: flex; min-height: 240px; align-items: center; justify-content: center; flex-direction: column; gap: 12px; border-radius: 8px; }.account-state--error { border: 1px solid #f5c2bd; background: #fff8f7; color: #b42318; }.account-state p { margin: 0; }
|
||||
.recharge-form { display: flex; flex-direction: column; gap: 16px; }.field-label { color: #4e6075; font-size: 14px; font-weight: 650; }
|
||||
.quick-amounts, .payment-methods { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }.quick-amounts button, .payment-methods button { min-height: 54px; border: 1px solid #dbe3ed; border-radius: 10px; background: #f8fafc; color: #506077; cursor: pointer; font: inherit; }.quick-amounts button.active, .payment-methods button.active { border: 2px solid #1671c9; background: #eef8f7; color: #1467b8; font-weight: 700; }
|
||||
.payment-methods { grid-template-columns: repeat(2, 1fr); }.payment-methods button { display: flex; align-items: center; justify-content: center; gap: 10px; font-size: 17px; font-weight: 650; }.payment-methods span { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 7px; background: #1685ef; color: #fff; font-size: 14px; }.payment-wechat span { background: #09ae42; }
|
||||
.qr-panel { display: flex; align-items: center; flex-direction: column; gap: 14px; padding: 22px; border: 1px dashed #bed0e4; border-radius: 12px; background: #f8fafc; }.qr-panel img { width: 210px; height: 210px; border: 1px solid #dae3ed; border-radius: 10px; background: #fff; object-fit: contain; }.qr-panel p { margin: 0; color: #62758d; }
|
||||
.quick-amounts { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }.quick-amounts button { min-height: 54px; border: 1px solid #dbe3ed; border-radius: 10px; background: #f8fafc; color: #506077; cursor: pointer; font: inherit; }.quick-amounts button.active { border: 2px solid #1671c9; background: #eef8f7; color: #1467b8; font-weight: 700; }
|
||||
.qr-panel { display: grid; grid-template-columns: 210px minmax(0, 1fr); align-items: center; gap: 24px; padding: 20px; border: 1px solid #dbe3ed; border-radius: 12px; background: #f8fafc; }.qr-panel img { width: 210px; height: 210px; border: 1px solid #dae3ed; border-radius: 10px; background: #fff; object-fit: contain; }.qr-panel strong { color: var(--navy); font-size: 17px; }.qr-panel p { margin: 8px 0 0; color: #62758d; }
|
||||
.channel-warning { padding: 24px; border: 1px solid #f0d39b; border-radius: 10px; background: #fff9ed; color: #8b5b12; text-align: center; }.voucher-upload { display: grid; gap: 10px; }.voucher-upload :deep(.el-button) { display: inline-flex; align-items: center; gap: 6px; }.submit-error { margin: 0; color: #b42318; font-size: 13px; }.dialog-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }.dialog-actions .el-button { min-height: 46px; margin: 0; }
|
||||
@media (max-width: 1200px) { .balance-summary { grid-template-columns: repeat(3, minmax(0, 1fr)); }.balance-summary article { border-bottom: 1px solid var(--client-border); }.balance-summary article:nth-child(3n) { border-right: 0; }.balance-summary article:nth-last-child(-n + 3) { border-bottom: 0; } }
|
||||
</style>
|
||||
|
||||
@@ -119,8 +119,9 @@ test('requires both finance statement authorities for the page permission', () =
|
||||
})
|
||||
|
||||
test('keeps actions first and exposes list, balance, detail, confirmation, receipt, empty and error states', () => {
|
||||
const actionIndex = viewSource.indexOf('label="操作"')
|
||||
const billNumberIndex = viewSource.indexOf('label="账单编号"')
|
||||
const tableDefinition = viewSource.slice(viewSource.indexOf('const tableColumns'))
|
||||
const actionIndex = tableDefinition.indexOf("label: '操作'")
|
||||
const billNumberIndex = tableDefinition.indexOf("label: '账单编号'")
|
||||
assert.ok(actionIndex >= 0 && actionIndex < billNumberIndex)
|
||||
for (const expected of ['未收余额', '确认账单', '暂无账单', 'state-panel--error']) {
|
||||
assert.match(viewSource, new RegExp(expected))
|
||||
@@ -133,9 +134,9 @@ test('keeps actions first and exposes list, balance, detail, confirmation, recei
|
||||
assert.match(detailViewSource, /InboundCodeAssociationDialog/)
|
||||
assert.match(detailViewSource, /:inbound-code="inboundAssociationCode"/)
|
||||
assert.match(viewSource, /auth\.hasPermission\('finance:bill:confirm'\)/)
|
||||
assert.match(viewSource, /row\.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm/)
|
||||
assert.match(viewSource, /size="small" type="success" plain/)
|
||||
assert.match(viewSource, /confirmStatement\(row as FinanceBillSummary\)/)
|
||||
assert.match(viewSource, /statement\.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm\.value/)
|
||||
assert.match(viewSource, /label: '确认账单'/)
|
||||
assert.match(viewSource, /run: \(\) => confirmStatement\(statement\)/)
|
||||
assert.match(detailViewSource, /detail\.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm/)
|
||||
assert.match(detailViewSource, /confirmStatement\(detail\)/)
|
||||
assert.match(viewSource, /confirmMerchantFinanceStatement\(statementId, statement\.version, key\)/)
|
||||
@@ -150,6 +151,14 @@ test('keeps actions first and exposes list, balance, detail, confirmation, recei
|
||||
assert.match(detailViewSource, /bill-detail-error/)
|
||||
})
|
||||
|
||||
test('uses the shared compact query, table and pagination layout', () => {
|
||||
assert.match(viewSource, /<QueryToolbar[^>]*layout="grid"/)
|
||||
assert.match(viewSource, /<DataTable[^>]*:row-actions="rowActions"/)
|
||||
assert.match(viewSource, /v-model:page="page" v-model:page-size="pageSize"/)
|
||||
assert.match(viewSource, /:page-sizes="\[20, 50, 100\]"/)
|
||||
assert.doesNotMatch(viewSource, /class="filter-bar"|class="pagination-row"|class="table-empty"/)
|
||||
})
|
||||
|
||||
test('keeps the existing customer finance view permission mapping', () => {
|
||||
assert.match(permissionSource, /code: 'finance:bill:view'/)
|
||||
assert.match(permissionSource, /merchant:finance:statement:list/)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { CircleDollarSign, Eye, RefreshCcw, Search } from '@lucide/vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { RefreshCcw } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ApiError } from '@/api/http'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||
import type { TableAction } from '@/components/common/tableActions'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { confirmMerchantFinanceStatement, getMerchantFinanceBills } from '../api/finance'
|
||||
import type { FinanceBillQuery, FinanceBillStatus, FinanceBillSummary, FinanceCustomerConfirmationStatus } from '../contracts'
|
||||
@@ -28,7 +31,23 @@ const statusOptions: Array<{ label: string; value: FinanceBillStatus }> = [
|
||||
{ label: '作废', value: 'VOIDED' },
|
||||
]
|
||||
|
||||
const hasData = computed(() => bills.value.length > 0)
|
||||
const queryModel = ref<QueryModel>({ keyword: '', status: '' })
|
||||
const queryFields: QueryField[] = [
|
||||
{ key: 'keyword', label: '账单编号', type: 'input', placeholder: '请输入账单编号' },
|
||||
{ key: 'status', label: '账单状态', type: 'select', placeholder: '全部', options: statusOptions },
|
||||
]
|
||||
const tableColumns: DataTableColumn[] = [
|
||||
{ label: '操作', slotName: 'actions', width: 190, fixed: 'left' },
|
||||
{ label: '账单编号', prop: 'billNo', minWidth: 160 },
|
||||
{ label: '出账日期', slotName: 'issueDate', width: 120 },
|
||||
{ label: '到期日', slotName: 'dueDate', width: 120 },
|
||||
{ label: '账单金额', slotName: 'totalAmount', width: 145, align: 'right' },
|
||||
{ label: '已收', slotName: 'receivedAmount', width: 145, align: 'right' },
|
||||
{ label: '未收余额', slotName: 'outstandingAmount', width: 145, align: 'right' },
|
||||
{ label: '状态', slotName: 'status', width: 110 },
|
||||
{ label: '客户确认', slotName: 'confirmationStatus', width: 110 },
|
||||
]
|
||||
|
||||
const canConfirm = computed(() => auth.hasPermission('finance:bill:confirm'))
|
||||
|
||||
function formatAmount(value: string | null | undefined) {
|
||||
@@ -87,6 +106,21 @@ async function loadBills(nextPage = page.value) {
|
||||
|
||||
function openDetail(row: FinanceBillSummary) { void router.push({ name: 'finance-bill-detail', params: { billNo: row.billNo } }) }
|
||||
|
||||
function rowActions(row: object): TableAction[] {
|
||||
const statement = row as FinanceBillSummary
|
||||
const actions: TableAction[] = [{ key: 'detail', label: '查看', run: () => openDetail(statement) }]
|
||||
if (statement.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm.value) {
|
||||
actions.push({
|
||||
key: 'confirm',
|
||||
label: '确认账单',
|
||||
loading: confirmingBillId.value === statement.id,
|
||||
disabled: Boolean(confirmingBillId.value),
|
||||
run: () => confirmStatement(statement),
|
||||
})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
async function confirmStatement(statement: FinanceBillSummary) {
|
||||
if (!canConfirm.value || statement.customerConfirmationStatus !== 'PENDING_CONFIRMATION' || confirmingBillId.value) return
|
||||
try {
|
||||
@@ -118,16 +152,19 @@ async function confirmStatement(statement: FinanceBillSummary) {
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = ''
|
||||
filters.status = undefined
|
||||
void loadBills(1)
|
||||
function applyQuery(model: QueryModel) {
|
||||
filters.keyword = String(model.keyword || '')
|
||||
filters.status = (model.status || undefined) as FinanceBillStatus | undefined
|
||||
if (page.value !== 1) page.value = 1
|
||||
else void loadBills(1)
|
||||
}
|
||||
|
||||
function handlePageChange(nextPage: number) {
|
||||
void loadBills(nextPage)
|
||||
function resetFilters(model: QueryModel) {
|
||||
queryModel.value = model
|
||||
applyQuery(model)
|
||||
}
|
||||
|
||||
watch([page, pageSize], () => { void loadBills(page.value) })
|
||||
onMounted(() => { void loadBills(1) })
|
||||
</script>
|
||||
|
||||
@@ -139,38 +176,19 @@ onMounted(() => { void loadBills(1) })
|
||||
<span class="amount-warning">未收 <strong>¥ {{ formatAmount(totals.outstanding) }}</strong></span>
|
||||
</section>
|
||||
|
||||
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="客户账单筛选" :fields="queryFields" :search-on-reset="false" @search="applyQuery" @reset="resetFilters" />
|
||||
|
||||
<section class="content-card">
|
||||
<form class="filter-bar" @submit.prevent="loadBills(1)">
|
||||
<el-input v-model="filters.keyword" clearable placeholder="搜索账单编号" aria-label="搜索账单编号" class="keyword-input">
|
||||
<template #prefix><Search :size="16" /></template>
|
||||
</el-input>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="账单状态" class="status-select">
|
||||
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
<el-button type="primary" native-type="submit" :loading="loading">查询</el-button>
|
||||
<el-button :disabled="loading" @click="resetFilters">重置</el-button>
|
||||
</form>
|
||||
|
||||
<div v-if="errorMessage" class="state-panel state-panel--error" role="alert">
|
||||
<p>{{ errorMessage }}</p>
|
||||
<el-button @click="loadBills(1)"><RefreshCcw :size="15" />重试</el-button>
|
||||
</div>
|
||||
<el-table v-else v-loading="loading" :data="bills" row-key="billNo" class="finance-table" aria-label="客户账单列表">
|
||||
<el-table-column label="操作" width="190" fixed="left">
|
||||
<template #default="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as FinanceBillSummary)"><Eye :size="15" />查看</el-button><el-button v-if="row.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm" size="small" type="success" plain :loading="confirmingBillId === row.id" :disabled="Boolean(confirmingBillId)" @click="confirmStatement(row as FinanceBillSummary)">确认账单</el-button></div></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="billNo" label="账单编号" min-width="160" />
|
||||
<el-table-column prop="issueDate" label="出账日期" width="120"><template #default="{ row }">{{ formatDate(row.issueDate) }}</template></el-table-column>
|
||||
<el-table-column prop="dueDate" label="到期日" width="120"><template #default="{ row }">{{ formatDate(row.dueDate) }}</template></el-table-column>
|
||||
<el-table-column label="账单金额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.totalAmount) }}</template></el-table-column>
|
||||
<el-table-column label="已收" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.receivedAmount) }}</template></el-table-column>
|
||||
<el-table-column label="未收余额" width="145" align="right"><template #default="{ row }"><strong :class="{ 'amount-warning': row.outstandingAmount !== '0' }">¥ {{ formatAmount(row.outstandingAmount) }}</strong></template></el-table-column>
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="客户确认" width="110"><template #default="{ row }"><el-tag :type="confirmationType(row.customerConfirmationStatus)">{{ confirmationLabel(row.customerConfirmationStatus) }}</el-tag></template></el-table-column>
|
||||
<template #empty><div class="table-empty"><CircleDollarSign :size="32" /><strong>暂无账单</strong><span>平台财务出账后,这里会显示本企业账单。</span></div></template>
|
||||
</el-table>
|
||||
|
||||
<div v-if="!errorMessage && hasData" class="pagination-row"><span>共 {{ total }} 条</span><el-pagination v-model:current-page="page" :page-size="pageSize" layout="prev, pager, next" :total="total" @current-change="handlePageChange" /></div>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="bills" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :row-actions="rowActions" row-key="billNo" empty-text="暂无账单">
|
||||
<template v-if="errorMessage" #error><div class="state-panel state-panel--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="loadBills(1)"><RefreshCcw :size="15" />重试</el-button></div></template>
|
||||
<template #issueDate="{ row }">{{ formatDate(row.issueDate) }}</template>
|
||||
<template #dueDate="{ row }">{{ formatDate(row.dueDate) }}</template>
|
||||
<template #totalAmount="{ row }">¥ {{ formatAmount(row.totalAmount) }}</template>
|
||||
<template #receivedAmount="{ row }">¥ {{ formatAmount(row.receivedAmount) }}</template>
|
||||
<template #outstandingAmount="{ row }"><strong :class="{ 'amount-warning': row.outstandingAmount !== '0' }">¥ {{ formatAmount(row.outstandingAmount) }}</strong></template>
|
||||
<template #status="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
<template #confirmationStatus="{ row }"><el-tag :type="confirmationType(row.customerConfirmationStatus)">{{ confirmationLabel(row.customerConfirmationStatus) }}</el-tag></template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
@@ -179,12 +197,8 @@ onMounted(() => { void loadBills(1) })
|
||||
<style scoped>
|
||||
.finance-bills-page { display: flex; flex-direction: column; gap: 12px; }
|
||||
.finance-summary { display: flex; flex-wrap: wrap; gap: 8px 26px; color: var(--muted); font-size: 13px; }.finance-summary strong { margin-left: 6px; color: var(--navy); font-size: 18px; }.amount-warning, .amount-warning strong { color: #b45309; }
|
||||
.content-card { min-width: 0; padding: 16px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface, #fff); }
|
||||
.filter-bar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }.keyword-input { width: 260px; }.status-select { width: 140px; }
|
||||
.finance-table { width: 100%; }.finance-table :deep(.el-button) { display: inline-flex; align-items: center; gap: 4px; }.table-actions { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||
.content-card { min-width: 0; overflow: hidden; border: 1px solid var(--client-border); border-radius: var(--client-radius); background: var(--surface, #fff); }
|
||||
.state-panel { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 12px; flex-direction: column; color: var(--muted); text-align: center; }.state-panel p { margin: 0; }.state-panel--error { color: #b42318; background: #fff8f7; border: 1px solid #f5c2bd; border-radius: 6px; }
|
||||
.table-empty { display: flex; align-items: center; flex-direction: column; gap: 8px; padding: 42px 12px; color: var(--muted); }.table-empty strong { color: var(--navy); }.table-empty span { font-size: 12px; }
|
||||
.pagination-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 14px; color: var(--muted); font-size: 12px; }
|
||||
.detail-content { min-height: 300px; }.detail-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }.detail-heading h2 { margin: 0 0 5px; color: var(--navy); font-size: 18px; }.detail-heading p { margin: 0; color: var(--muted); font-size: 13px; }.detail-statuses { display: flex; align-items: center; gap: 8px; }.confirmation-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; padding: 14px 16px; border: 1px solid #d7e9e4; border-radius: 8px; background: #f3f9f7; }.confirmation-panel strong { color: var(--navy); }.confirmation-panel p { margin: 4px 0 0; color: var(--muted); font-size: 12px; }.detail-section { margin-top: 22px; }.detail-section h3 { margin: 0 0 10px; color: var(--navy); font-size: 14px; }.sub-empty { padding: 22px; color: var(--muted); font-size: 12px; text-align: center; background: #f7fafb; }.readonly-notice { margin: 22px 0 0; padding: 11px 12px; color: #45615d; background: #edf6f4; font-size: 12px; }
|
||||
@media (max-width: 720px) { .keyword-input, .status-select { width: 100%; }.filter-bar > .el-button { flex: 1; }.content-card { padding: 12px; }.pagination-row { align-items: flex-start; flex-direction: column; }.finance-summary { align-items: flex-start; flex-direction: column; gap: 6px; } }
|
||||
@media (max-width: 720px) { .finance-summary { align-items: flex-start; flex-direction: column; gap: 6px; } }
|
||||
</style>
|
||||
|
||||
@@ -82,7 +82,6 @@ watch(() => [props.modelValue, props.inboundCode] as const, ([open]) => {
|
||||
<div><span>入仓码</span><strong>{{ displayValue(association.inboundCode) }}</strong></div>
|
||||
</header>
|
||||
<dl class="inbound-association-grid">
|
||||
<div><dt>订单号</dt><dd>{{ displayValue(association.orderNo) }}</dd></div>
|
||||
<div><dt>订单状态</dt><dd>{{ getFirstLegStatusLabel(association.orderStatus) }}</dd></div>
|
||||
<div><dt>客户名称</dt><dd>{{ displayValue(association.customerName) }}</dd></div>
|
||||
<div><dt>目的国家</dt><dd>{{ displayValue(association.destinationCountryCode) }}</dd></div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileText, Ship, Zap } from '@lucide/vue'
|
||||
import { FileText, Ship } from '@lucide/vue'
|
||||
import type { ClientModuleManifest } from '../types.ts'
|
||||
|
||||
export const firstLegModule = {
|
||||
@@ -17,8 +17,7 @@ export const firstLegModule = {
|
||||
{
|
||||
path: 'orders/first-leg/create',
|
||||
name: 'first-leg-order-create',
|
||||
component: () => import('./views/FirstLegOrdersView.vue'),
|
||||
props: { createPage: true },
|
||||
component: () => import('./views/FirstLegOrderCreateHubView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '创建订单', workspaceTitle: '头程订单 · 新建', description: '填写发货、目的地和货物信息', permission: 'order:first-leg:create' },
|
||||
},
|
||||
@@ -44,7 +43,6 @@ export const firstLegModule = {
|
||||
component: () => import('./views/QuickOrderView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '快速下单', description: '按渠道、目的仓和产品 SKU 快速生成订单草稿', permission: 'order:first-leg:quick-create' },
|
||||
navigation: { key: 'first-leg-quick-order', title: '快速下单', icon: Zap },
|
||||
},
|
||||
],
|
||||
} satisfies ClientModuleManifest
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { FilePenLine, Zap } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import FirstLegOrdersView from './FirstLegOrdersView.vue'
|
||||
import QuickOrderView from './QuickOrderView.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const canQuickOrder = computed(() => auth.hasPermission('order:first-leg:quick-create'))
|
||||
const activeMode = computed<'standard' | 'quick'>(() => route.query.mode === 'quick' && canQuickOrder.value ? 'quick' : 'standard')
|
||||
|
||||
function selectMode(mode: 'standard' | 'quick') {
|
||||
if (mode === 'quick' && !canQuickOrder.value) return
|
||||
const query = { ...route.query }
|
||||
if (mode === 'quick') query.mode = 'quick'
|
||||
else delete query.mode
|
||||
void router.replace({ name: 'first-leg-order-create', query })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="first-leg-create-hub">
|
||||
<header class="create-hub-heading">
|
||||
<h1>头程下单</h1>
|
||||
<p>填写货物信息,创建新的头程运输订单</p>
|
||||
</header>
|
||||
<nav v-if="canQuickOrder" class="order-mode-switch" aria-label="头程下单方式">
|
||||
<button type="button" :class="{ active: activeMode === 'standard' }" :aria-current="activeMode === 'standard' ? 'page' : undefined" @click="selectMode('standard')"><FilePenLine :size="18" />普通下单</button>
|
||||
<button type="button" :class="{ active: activeMode === 'quick' }" :aria-current="activeMode === 'quick' ? 'page' : undefined" @click="selectMode('quick')"><Zap :size="18" />快速下单</button>
|
||||
</nav>
|
||||
<FirstLegOrdersView v-if="activeMode === 'standard'" create-page />
|
||||
<QuickOrderView v-else />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.first-leg-create-hub{display:grid;min-width:0;gap:12px}.create-hub-heading{display:grid;gap:5px}.create-hub-heading h1{margin:0;color:var(--navy);font-size:24px;line-height:1.25}.create-hub-heading p{margin:0;color:var(--muted);font-size:13px}.order-mode-switch{display:inline-flex;width:max-content;padding:5px;border:1px solid #dbe4f1;border-radius:12px;background:#fff;box-shadow:0 8px 20px rgba(26,43,75,.06)}.order-mode-switch button{display:inline-flex;height:38px;align-items:center;gap:8px;padding:0 20px;border:0;border-radius:8px;background:transparent;color:#607087;font:inherit;font-size:13px;font-weight:650;cursor:pointer}.order-mode-switch button.active{background:linear-gradient(135deg,#4f73ee,#8a56dc);color:#fff;box-shadow:0 6px 14px rgba(79,115,238,.22)}.order-mode-switch button:focus-visible{outline:2px solid var(--primary);outline-offset:2px}
|
||||
</style>
|
||||
@@ -12,6 +12,7 @@ const progressSource = readFileSync(new URL('../components/OrderProgressPanel.vu
|
||||
const customsDialogSource = readFileSync(new URL('../components/CustomsDocumentsDialog.vue', import.meta.url), 'utf8')
|
||||
const customsApiSource = readFileSync(new URL('../api/customs.ts', import.meta.url), 'utf8')
|
||||
const orderStoreSource = readFileSync(new URL('../store/firstLegOrders.ts', import.meta.url), 'utf8')
|
||||
const createHubSource = readFileSync(new URL('./FirstLegOrderCreateHubView.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('routes the confirmed first-leg path to the implemented order view', () => {
|
||||
assert.match(routerSource, /path: 'orders\/first-leg'[\s\S]*?component: \(\) => import\('\.\/views\/FirstLegOrdersView\.vue'\)[\s\S]*?release: 'active'/)
|
||||
@@ -19,7 +20,8 @@ test('routes the confirmed first-leg path to the implemented order view', () =>
|
||||
|
||||
test('opens order creation as a dedicated protected page while keeping draft editing in a large dialog', () => {
|
||||
assert.match(routerSource, /path: 'orders\/first-leg\/create'[\s\S]*?name: 'first-leg-order-create'[\s\S]*?release: 'active'[\s\S]*?permission: 'order:first-leg:create'/)
|
||||
assert.match(routerSource, /name: 'first-leg-order-create'[\s\S]*?props: \{ createPage: true \}/)
|
||||
assert.match(routerSource, /name: 'first-leg-order-create'[\s\S]*?FirstLegOrderCreateHubView\.vue/)
|
||||
assert.match(createHubSource, /<FirstLegOrdersView v-if="activeMode === 'standard'" create-page \/>/)
|
||||
assert.match(source, /router\.push\(\{ name: 'first-leg-order-create' \}\)/)
|
||||
assert.match(source, /defineProps<\{ createPage\?: boolean; detailPage\?: boolean; showCreateAction\?: boolean \}>/)
|
||||
assert.match(source, /const isCreatePage = computed\(\(\) => props\.createPage\)/)
|
||||
@@ -250,7 +252,9 @@ test('keeps all progress stages and business summary usable on mobile', () => {
|
||||
test('keeps the first viewport compact and moves create into the table toolbar', () => {
|
||||
const tableToolbarSource = source.slice(source.indexOf('#toolbar-actions'), source.indexOf('class="order-eligibility is-pending"'))
|
||||
assert.match(source, /#toolbar-actions/)
|
||||
assert.match(source, /class="order-queue-tabs"[\s\S]*确认计费重[\s\S]*上传资料[\s\S]*共 \{\{ filteredOrders\.length \}\} 条/)
|
||||
assert.match(source, /class="order-queue-tabs"[\s\S]*确认计费重[\s\S]*上传资料[\s\S]*草稿/)
|
||||
assert.match(source, /activeOrderQueue\.value === 'draft'[\s\S]*order\.status === 'DRAFT'/)
|
||||
assert.doesNotMatch(source, /共 \{\{ filteredOrders\.length \}\} 条/)
|
||||
assert.match(source, /#toolbar-actions[\s\S]*order:first-leg:create[\s\S]*创建订单[\s\S]*class="order-eligibility is-pending"/)
|
||||
assert.match(tableToolbarSource, /下载唛头[\s\S]*导出订单信息[\s\S]*批量提交草稿[\s\S]*创建订单/)
|
||||
assert.doesNotMatch(source.slice(source.indexOf('<QueryToolbar'), source.indexOf('<div class="client-card order-table-card">')), /下载唛头|导出订单信息|创建订单/)
|
||||
|
||||
@@ -191,7 +191,7 @@ const selectedOrders = ref<FirstLegOrder[]>([])
|
||||
const selectedOrderCount = computed(() => selectedOrders.value.length)
|
||||
const selectedDraftOrders = computed(() => selectedOrders.value.filter((order) => order.status === 'DRAFT'))
|
||||
const orderTableVersion = ref(0)
|
||||
type OrderQueue = 'all' | 'fee' | 'customs'
|
||||
type OrderQueue = 'all' | 'fee' | 'customs' | 'draft'
|
||||
const activeOrderQueue = ref<OrderQueue>('all')
|
||||
const editorOpen = ref(false)
|
||||
const orderFormRef = ref<FormInstance>()
|
||||
@@ -471,10 +471,12 @@ const orderQueueCounts = computed(() => ({
|
||||
all: queryMatchedOrders.value.length,
|
||||
fee: queryMatchedOrders.value.filter((order) => order.businessSummary.customerFeeConfirmationRequired).length,
|
||||
customs: queryMatchedOrders.value.filter((order) => Boolean(eligibleCustomsDetail(order))).length,
|
||||
draft: queryMatchedOrders.value.filter((order) => order.status === 'DRAFT').length,
|
||||
}))
|
||||
const filteredOrders = computed(() => queryMatchedOrders.value.filter((order) => {
|
||||
if (activeOrderQueue.value === 'fee') return order.businessSummary.customerFeeConfirmationRequired
|
||||
if (activeOrderQueue.value === 'customs') return Boolean(eligibleCustomsDetail(order))
|
||||
if (activeOrderQueue.value === 'draft') return order.status === 'DRAFT'
|
||||
return true
|
||||
}))
|
||||
const pagedOrders = computed(() => {
|
||||
@@ -1945,8 +1947,9 @@ async function searchOrders(model: QueryModel) {
|
||||
<button type="button" :class="{ active: activeOrderQueue === 'all' }" @click="selectOrderQueue('all')">全部</button>
|
||||
<button type="button" :class="{ active: activeOrderQueue === 'fee' }" @click="selectOrderQueue('fee')">确认计费重<span v-if="orderQueueCounts.fee" class="queue-count">{{ orderQueueCounts.fee }}</span></button>
|
||||
<button type="button" :class="{ active: activeOrderQueue === 'customs' }" @click="selectOrderQueue('customs')">上传资料<span v-if="orderQueueCounts.customs" class="queue-count">{{ orderQueueCounts.customs }}</span></button>
|
||||
<button type="button" :class="{ active: activeOrderQueue === 'draft' }" @click="selectOrderQueue('draft')">草稿<span v-if="orderQueueCounts.draft" class="queue-count">{{ orderQueueCounts.draft }}</span></button>
|
||||
</nav>
|
||||
<span>共 {{ filteredOrders.length }} 条</span><span v-if="selectedOrderCount">已选 {{ selectedOrderCount }} 条</span>
|
||||
<span v-if="selectedOrderCount">已选 {{ selectedOrderCount }} 条</span>
|
||||
</template>
|
||||
<template #toolbar-actions>
|
||||
<el-popover placement="bottom-end" :width="320" trigger="click" popper-class="order-column-popper">
|
||||
|
||||
@@ -6,14 +6,23 @@ const source = readFileSync(new URL('./QuickOrderView.vue', import.meta.url), 'u
|
||||
const routesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
|
||||
const contractsSource = readFileSync(new URL('../../tenant-management/contracts.ts', import.meta.url), 'utf8')
|
||||
const orderViewSource = readFileSync(new URL('./FirstLegOrdersView.vue', import.meta.url), 'utf8')
|
||||
const createHubSource = readFileSync(new URL('./FirstLegOrderCreateHubView.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('exposes quick order as an active first-leg route with its dedicated permission', () => {
|
||||
test('keeps quick order active but moves it from the menu into the head-leg create page', () => {
|
||||
assert.match(routesSource, /path: 'first-leg\/quick-order'[\s\S]*?release: 'active'[\s\S]*?permission: 'order:first-leg:quick-create'/)
|
||||
assert.match(contractsSource, /code: 'order:first-leg:quick-create'[\s\S]*?label: '快速下单'/)
|
||||
assert.match(contractsSource, /'order:first-leg:quick-create': \['merchant:order:quick-create', 'merchant:order:create'\]/)
|
||||
assert.match(contractsSource, /FRONTEND_PERMISSIONS_REQUIRING_ALL_BACKEND_CODES[\s\S]*?'order:first-leg:quick-create'/)
|
||||
assert.match(routesSource, /path: 'orders\/first-leg\/:draftId\/edit'[\s\S]*?name: 'first-leg-order-edit'[\s\S]*?permission: 'order:first-leg:edit'/)
|
||||
assert.match(orderViewSource, /store\.loadDraftDetail\(id\)/)
|
||||
assert.doesNotMatch(routesSource, /navigation: \{ key: 'first-leg-quick-order'/)
|
||||
assert.match(routesSource, /path: 'orders\/first-leg\/create'[\s\S]*?FirstLegOrderCreateHubView\.vue/)
|
||||
assert.match(createHubSource, />普通下单<\/button>/)
|
||||
assert.match(createHubSource, />快速下单<\/button>/)
|
||||
assert.match(createHubSource, /<h1>头程下单<\/h1>/)
|
||||
assert.match(createHubSource, /auth\.hasPermission\('order:first-leg:quick-create'\)/)
|
||||
assert.match(createHubSource, /<FirstLegOrdersView v-if="activeMode === 'standard'" create-page \/>/)
|
||||
assert.match(createHubSource, /<QuickOrderView v-else \/>/)
|
||||
})
|
||||
|
||||
test('keeps the primary quick-order table focused on inbound code policy, channel, destination and cargo', () => {
|
||||
|
||||
@@ -398,7 +398,7 @@ async function submitQuickOrder() {
|
||||
else {
|
||||
activeBatchKey.value = ''
|
||||
await Promise.all(successes.map((item) => store.loadDraftDetail(item.draftId)))
|
||||
workspace.close(['first-leg-quick-order'])
|
||||
workspace.close(['first-leg-quick-order', 'first-leg-order-create'])
|
||||
await router.push({ name: 'first-leg-orders' })
|
||||
ElMessage.success(`已生成 ${successes.length} 张草稿单,提交后将生成入仓码`)
|
||||
}
|
||||
|
||||
@@ -28,23 +28,32 @@ test('does not accept browser-selected customer or user scope in the API boundar
|
||||
|
||||
test('keeps customer message actions first and covers the requested local states', async () => {
|
||||
const source = await readFile(new URL('views/NotificationsView.vue', moduleRoot), 'utf8')
|
||||
const operationColumn = source.indexOf('label="操作"')
|
||||
const titleColumn = source.indexOf('label="消息标题"')
|
||||
const operationColumn = source.indexOf("label: '操作'")
|
||||
const titleColumn = source.indexOf("label: '消息标题'")
|
||||
assert.ok(operationColumn >= 0 && operationColumn < titleColumn)
|
||||
assert.match(source, /全部标为已读/)
|
||||
assert.match(source, /标为已读/)
|
||||
assert.match(source, /state-panel--error/)
|
||||
assert.match(source, /notification-state--error/)
|
||||
assert.match(source, /暂无消息/)
|
||||
assert.doesNotMatch(source, /清空已读|删除消息/)
|
||||
assert.match(source, /Promise\.all\(\[load\(\), notifications\.refreshUnreadCount\(\)\]\)/)
|
||||
assert.match(source, /auth\.hasPermission\('merchant:notification:read'\)/)
|
||||
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, /canMarkRead\.value && row\.status === 'UNREAD'/)
|
||||
assert.match(source, /notificationCategoryLabel\(row\.type\)/)
|
||||
assert.match(source, /notificationSentAt\(row\.sentAt\)/)
|
||||
})
|
||||
|
||||
test('uses the shared compact list language without a repeated page hero', async () => {
|
||||
const source = await readFile(new URL('views/NotificationsView.vue', moduleRoot), 'utf8')
|
||||
assert.match(source, /<QueryToolbar[^>]*layout="grid"/)
|
||||
assert.match(source, /<DataTable[^>]*:row-actions="rowActions"/)
|
||||
assert.match(source, /#toolbar-actions/)
|
||||
assert.match(source, /empty-text="暂无消息"/)
|
||||
assert.doesNotMatch(source, /#toolbar>|page-heading|eyebrow|table-empty|共 \{\{ total \}\} 条/)
|
||||
})
|
||||
|
||||
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: '订单消息'/)
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Bell, RefreshCcw } from '@lucide/vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { CheckCheck, RefreshCcw } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { useNotificationsStore } from '../public'
|
||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||
import type { TableAction } from '@/components/common/tableActions'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { getMyNotifications, markAllMyNotificationsRead, markMyNotificationRead } from '../api/notifications'
|
||||
import type { NotificationPage, NotificationStatus, NotificationSummary, NotificationType } from '../contracts'
|
||||
import type { NotificationStatus, NotificationSummary } from '../contracts'
|
||||
import { notificationCategoryLabel, notificationSentAt } from '../presentation'
|
||||
import { useNotificationsStore } from '../public'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
@@ -15,44 +18,121 @@ const notifications = useNotificationsStore()
|
||||
const canMarkRead = computed(() => auth.hasPermission('merchant:notification:read'))
|
||||
const canMarkAllRead = computed(() => auth.hasPermission('merchant:notification:read-all'))
|
||||
const loading = ref(false)
|
||||
const markingAll = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const page = ref<NotificationPage>({ records: [], total: 0, page: 1, size: 20, pages: 0 })
|
||||
const filters = reactive<{ status?: NotificationStatus; type?: NotificationType }>({})
|
||||
const records = ref<NotificationSummary[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = reactive({ status: '', type: '' })
|
||||
const queryModel = computed<QueryModel>({ get: () => ({ ...filters }), set: (value) => Object.assign(filters, value) })
|
||||
const queryFields: QueryField[] = [
|
||||
{ key: 'status', label: '消息状态', type: 'select', options: [{ label: '未读', value: 'UNREAD' }, { label: '已读', value: 'READ' }] },
|
||||
{ key: 'type', label: '消息分类', type: 'input', placeholder: '请输入消息分类' },
|
||||
]
|
||||
const tableColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', slotName: 'actions', width: 128, fixed: 'left' },
|
||||
{ prop: 'title', label: '消息标题', minWidth: 210 },
|
||||
{ prop: 'summary', label: '内容摘要', minWidth: 320 },
|
||||
{ key: 'type', label: '分类', slotName: 'type', width: 130 },
|
||||
{ key: 'sentAt', label: '发送时间', slotName: 'sentAt', width: 170 },
|
||||
{ key: 'status', label: '状态', slotName: 'status', width: 90, align: 'center' },
|
||||
]
|
||||
|
||||
function messageOf(error: unknown) { return error instanceof ApiError ? error.message : '消息加载失败,请稍后重试' }
|
||||
async function load(nextPage = page.value.page) {
|
||||
loading.value = true; errorMessage.value = ''
|
||||
try { page.value = await getMyNotifications({ ...filters, page: nextPage, size: page.value.size }) } catch (error) { errorMessage.value = messageOf(error); page.value = { records: [], total: 0, page: nextPage, size: 20, pages: 0 } } finally { loading.value = false }
|
||||
function messageOf(error: unknown) {
|
||||
return error instanceof ApiError ? error.message : '消息加载失败,请稍后重试'
|
||||
}
|
||||
async function markRead(row: NotificationSummary) { if (!canMarkRead.value) return; try { await markMyNotificationRead(row.id); await Promise.all([load(), notifications.refreshUnreadCount()]) } catch (error) { errorMessage.value = messageOf(error) } }
|
||||
async function markAllRead() { if (!canMarkAllRead.value) return; try { await markAllMyNotificationsRead(); await Promise.all([load(), notifications.refreshUnreadCount()]) } catch (error) { errorMessage.value = messageOf(error) } }
|
||||
function openDetail(row: NotificationSummary) { void router.push({ name: 'notification-detail', params: { notificationId: row.id } }) }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const result = await getMyNotifications({
|
||||
status: filters.status ? filters.status as NotificationStatus : undefined,
|
||||
type: filters.type.trim() || undefined,
|
||||
page: currentPage.value,
|
||||
size: pageSize.value,
|
||||
})
|
||||
records.value = result.records
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
records.value = []
|
||||
total.value = 0
|
||||
errorMessage.value = messageOf(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
if (currentPage.value === 1) void load()
|
||||
else currentPage.value = 1
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(filters, { status: '', type: '' })
|
||||
search()
|
||||
}
|
||||
|
||||
async function markRead(row: NotificationSummary) {
|
||||
if (!canMarkRead.value) return
|
||||
try {
|
||||
await markMyNotificationRead(row.id)
|
||||
await Promise.all([load(), notifications.refreshUnreadCount()])
|
||||
} catch (error) {
|
||||
errorMessage.value = messageOf(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
if (!canMarkAllRead.value || markingAll.value) return
|
||||
markingAll.value = true
|
||||
try {
|
||||
await markAllMyNotificationsRead()
|
||||
await Promise.all([load(), notifications.refreshUnreadCount()])
|
||||
} catch (error) {
|
||||
errorMessage.value = messageOf(error)
|
||||
} finally {
|
||||
markingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(row: NotificationSummary) {
|
||||
void router.push({ name: 'notification-detail', params: { notificationId: row.id } })
|
||||
}
|
||||
|
||||
function rowActions(candidate: object): TableAction[] {
|
||||
const row = candidate as NotificationSummary
|
||||
const actions: TableAction[] = [{ key: 'detail', label: '详情', run: () => openDetail(row) }]
|
||||
if (canMarkRead.value && row.status === 'UNREAD') actions.push({ key: 'read', label: '标为已读', run: () => markRead(row) })
|
||||
return actions
|
||||
}
|
||||
|
||||
onMounted(() => { void Promise.all([load(), notifications.refreshUnreadCount()]) })
|
||||
watch([currentPage, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
|
||||
if (nextSize !== previousSize && nextPage !== 1) { currentPage.value = 1; return }
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notification-page">
|
||||
<header class="page-heading"><div><p class="eyebrow">消息中心</p><h1>我的消息</h1><p>仅显示当前登录企业和成员可见的站内消息。</p></div><Bell :size="34" stroke-width="1.5" aria-hidden="true" /></header>
|
||||
<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 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>
|
||||
<section class="notification-page client-ui" aria-label="我的消息">
|
||||
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="消息筛选" :fields="queryFields" :search-on-reset="false" @search="search" @reset="reset" />
|
||||
|
||||
<div class="notification-table-card">
|
||||
<DataTable v-model:page="currentPage" v-model:page-size="pageSize" :data="records" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[10, 20, 50, 100]" :row-actions="rowActions" row-key="id" empty-text="暂无消息">
|
||||
<template #toolbar-actions>
|
||||
<el-button v-if="canMarkAllRead" :loading="markingAll" :disabled="loading || total === 0" @click="markAllRead"><CheckCheck :size="16" />全部标为已读</el-button>
|
||||
</template>
|
||||
<template v-if="errorMessage" #error><div class="notification-state notification-state--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="load"><RefreshCcw :size="15" />重试</el-button></div></template>
|
||||
<template #type="{ row }">{{ notificationCategoryLabel(row.type) }}</template>
|
||||
<template #sentAt="{ row }">{{ notificationSentAt(row.sentAt) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="row.status === 'UNREAD' ? 'danger' : 'info'" effect="light">{{ row.status === 'UNREAD' ? '未读' : '已读' }}</el-tag></template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-page{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}.page-heading p:not(.eyebrow){margin:0;color:var(--muted);font-size:13px}.eyebrow{margin:0;color:var(--primary);font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}.content-card{padding:16px;border:1px solid var(--border);border-radius:8px;background:var(--surface,#fff)}.filter-bar{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px}.mark-all{margin-left:auto}.state-panel{display:flex;min-height:180px;align-items:center;justify-content:center;flex-direction:column;gap:12px;text-align:center}.state-panel--error{color:#b42318;background:#fff8f7;border:1px solid #f5c2bd;border-radius:6px}.state-panel p{margin:0}.table-empty{display:flex;align-items:center;flex-direction:column;gap:8px;padding:42px 12px;color:var(--muted)}.table-empty strong{color:var(--navy)}.table-empty span{font-size:12px}.pagination-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-top:14px;color:var(--muted);font-size:12px}@media(max-width:720px){.content-card{padding:12px}.mark-all{margin-left:0;width:100%}.filter-bar .el-select,.filter-bar>.el-button{flex:1}.pagination-row{align-items:flex-start;flex-direction:column}.page-heading h1{font-size:21px}}
|
||||
/* Keep the notification filters compact and aligned with the other client lists. */
|
||||
.filter-bar { align-items: flex-end; }
|
||||
.filter-bar > .el-select { width: 190px; }
|
||||
.filter-bar > .el-input { width: 260px; }
|
||||
.filter-bar > .el-button { flex: 0 0 auto; min-height: 32px; }
|
||||
.filter-bar > .el-button + .el-button { margin-left: 0; }
|
||||
@media (max-width: 720px) {
|
||||
.filter-bar { align-items: stretch; flex-direction: column; }
|
||||
.filter-bar > .el-select, .filter-bar > .el-input { width: 100%; }
|
||||
.filter-bar > .el-button { width: 100%; }
|
||||
}
|
||||
.notification-page{display:grid;min-width:0;gap:12px}.notification-table-card{min-width:0;overflow:hidden;border:1px solid var(--client-border);border-radius:var(--client-radius);background:#fff}.notification-state{display:flex;min-height:160px;align-items:center;justify-content:center;gap:10px;flex-direction:column;color:var(--client-muted)}.notification-state p{margin:0}.notification-state--error{margin:16px;border:1px solid #f5c2bd;border-radius:6px;background:#fff8f7;color:#b42318}
|
||||
</style>
|
||||
|
||||
@@ -31,7 +31,9 @@ test('uses only authenticated merchant delivery endpoints without customer scope
|
||||
})
|
||||
|
||||
test('keeps list, read-only detail and create permissions independent in active routes', () => {
|
||||
assert.match(routesSource, /path: 'orders\/delivery'[\s\S]*permission: 'merchant:warehouse:delivery:list'[\s\S]*navigation:/)
|
||||
const listRoute = routesSource.match(/path: 'orders\/delivery',[\s\S]*?\n\s*},/)?.[0] ?? ''
|
||||
assert.match(listRoute, /permission: 'merchant:warehouse:delivery:list'/)
|
||||
assert.doesNotMatch(listRoute, /navigation:/)
|
||||
assert.match(routesSource, /path: 'orders\/delivery\/create'[\s\S]*DeliveryOrderCreateView\.vue[\s\S]*permission: 'merchant:warehouse:delivery:create'/)
|
||||
assert.match(routesSource, /path: 'orders\/delivery\/:deliveryNo'[\s\S]*DeliveryOrderDetailView\.vue[\s\S]*props: true[\s\S]*permission: 'merchant:warehouse:delivery:list'/)
|
||||
assert.match(routesSource, /orders-delivery-create[\s\S]*keepAlive: false/)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ClipboardList, Package, ReceiptText, Truck } from '@lucide/vue'
|
||||
import { ClipboardList, ReceiptText } from '@lucide/vue'
|
||||
import type { ClientModuleManifest } from '../types.ts'
|
||||
|
||||
export const orderManagementModule = {
|
||||
@@ -19,7 +19,6 @@ export const orderManagementModule = {
|
||||
component: () => import('./views/InboundOrdersView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '入库订单列表', description: '查询海外仓入库订单', permission: 'merchant:warehouse:inbound:list' },
|
||||
navigation: { key: 'orders-inbound', icon: Package },
|
||||
},
|
||||
{
|
||||
path: 'orders/inbound/:planNo/detail',
|
||||
@@ -35,7 +34,6 @@ export const orderManagementModule = {
|
||||
component: () => import('./views/DeliveryOrdersView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '派送订单列表', description: '查询海外仓派送订单', permission: 'merchant:warehouse:delivery:list' },
|
||||
navigation: { key: 'orders-delivery', icon: Truck },
|
||||
},
|
||||
{
|
||||
path: 'orders/delivery/create',
|
||||
|
||||
@@ -138,12 +138,11 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="delivery-page" aria-labelledby="delivery-list-title">
|
||||
<section class="delivery-page" aria-label="派送订单">
|
||||
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="派送订单筛选" :fields="queryFields" :search-on-reset="false" @search="search" @reset="reset" />
|
||||
|
||||
<div class="delivery-card delivery-table-card">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[10, 20, 50, 100]" :row-actions="rowActions" row-key="deliveryNo" empty-text="暂无派送单">
|
||||
<template #toolbar><strong id="delivery-list-title">派送订单</strong><span>共 {{ total }} 条</span></template>
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />创建派送单</el-button></template>
|
||||
<template v-if="errorMessage" #error><div class="delivery-state delivery-state--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="loadOrders">重试</el-button></div></template>
|
||||
<template #status="{ row }"><el-tag :type="statusType(row.status)" effect="light">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
|
||||
@@ -143,7 +143,6 @@ watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousP
|
||||
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="入库订单筛选" :fields="queryFields" :search-on-reset="false" @search="search" @reset="resetQuery" />
|
||||
<div class="inbound-table-card">
|
||||
<DataTable v-model:page="query.page" v-model:page-size="query.size" :data="rows" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[10, 20, 50, 100]" :row-actions="rowActions" row-key="planNo" empty-text="暂无入库订单">
|
||||
<template #toolbar><strong>入库订单</strong><span>共 {{ total }} 条</span></template>
|
||||
<template #overseasWarehouse="{ row }">{{ overseasWarehouseLabel(row.overseasWarehouseCode, row.overseasWarehouseName) }}</template>
|
||||
<template #destination="{ row }">{{ text(row.fbaWarehouseCode) }}</template>
|
||||
<template #status="{ row }"><el-tag effect="light">{{ statusLabel(row.status) }}</el-tag></template>
|
||||
|
||||
@@ -57,6 +57,14 @@ test('keeps overseas warehouse writes behind operation-specific button permissio
|
||||
assert.match(returns, /v-if="canCreate" type="primary" @click="openCreate"/)
|
||||
})
|
||||
|
||||
test('keeps all four overseas warehouse create buttons in the shared left-aligned table toolbar', () => {
|
||||
for (const service of ['labeling', 'transfer', 'disposal']) {
|
||||
assert.match(routes, new RegExp(`props: \\{ service: '${service}' \\}`))
|
||||
}
|
||||
assert.match(services, /<DataTable[\s\S]*?<template #toolbar-actions><el-button v-if="canCreate"/)
|
||||
assert.match(returns, /<DataTable[\s\S]*?<template #toolbar-actions><el-button v-if="canCreate"/)
|
||||
})
|
||||
|
||||
test('publishes all overseas operation permissions in the enterprise role editor', () => {
|
||||
for (const permission of [
|
||||
'merchant:overseas-warehouse:last-mile-order:list',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Boxes, PackageX, RefreshCcw, Tags, Truck, Warehouse } from '@lucide/vue'
|
||||
import { PackageX, RefreshCcw, Tags, Truck, Warehouse } from '@lucide/vue'
|
||||
import type { ClientModuleManifest } from '../types.ts'
|
||||
|
||||
export const overseasWarehouseModule = {
|
||||
@@ -65,13 +65,11 @@ export const overseasWarehouseModule = {
|
||||
path: 'overseas-warehouse/return-inventory', name: 'overseas-return-inventory',
|
||||
component: () => import('./views/ReturnInventoryView.vue'), release: 'active',
|
||||
meta: { title: '退货库存', description: '按箱号和 SKU 查询海外仓退货实收库存', permission: 'merchant:overseas-warehouse:return-inventory:list' },
|
||||
navigation: { key: 'overseas-return-inventory', icon: Boxes },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/return-cartons', name: 'overseas-return-cartons',
|
||||
component: () => import('./views/ReturnInventoryView.vue'), props: { cartonOnly: true }, release: 'active',
|
||||
meta: { title: '退货箱号管理', description: '查询签收及合箱生成的退货箱号和箱内库存', permission: 'merchant:overseas-warehouse:return-inventory:list' },
|
||||
navigation: { key: 'overseas-return-cartons', icon: Boxes },
|
||||
},
|
||||
{
|
||||
path: 'overseas-warehouse/return-cartons/:cartonMark/detail', name: 'overseas-return-carton-detail',
|
||||
|
||||
@@ -223,7 +223,6 @@ onMounted(() => { if (props.detailPage) void Promise.all([loadCartonDetail(), lo
|
||||
<el-alert v-if="warehouseError" :title="warehouseError" type="warning" show-icon :closable="false"><el-button link type="primary" @click="loadWarehouses">重试加载海外仓</el-button></el-alert>
|
||||
<div class="return-inventory-table">
|
||||
<DataTable v-model:page="query.page" v-model:page-size="query.size" :data="currentRows" :columns="currentColumns" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :row-actions="rowActions" :empty-text="loadError ? '库存加载失败,请核对权限或重试' : '暂无数据'">
|
||||
<template #toolbar><strong>{{ props.cartonOnly ? '退货箱号' : activeTab === 'summary' ? 'SKU 汇总库存' : '箱号明细' }}</strong><span>共 {{ total }} 条</span></template>
|
||||
<template #warehouse="{ row }">{{ warehouseLabel(row.overseasWarehouseId) }}</template>
|
||||
<template #status="{ row }"><el-tag effect="light">{{ cartonStatusLabel(row.status) }}</el-tag></template>
|
||||
<template #dimensions="{ row }">{{ row.length && row.width && row.height ? `${row.length} × ${row.width} × ${row.height} cm` : '—' }}</template>
|
||||
|
||||
@@ -167,7 +167,6 @@ watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousP
|
||||
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="退货签收筛选" :fields="queryFields" @search="search" />
|
||||
<div class="return-table-card">
|
||||
<DataTable v-model:page="query.page" v-model:page-size="query.size" :data="rows" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[15, 30, 50, 100]" :row-actions="rowActions" row-key="returnNo" empty-text="暂无退货预报">
|
||||
<template #toolbar><strong>退货签收</strong><span>共 {{ total }} 条</span></template>
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新建退货预报</el-button></template>
|
||||
<template #warehouse="{ row }">{{ operationWarehouseLabel(row.overseasWarehouseId) }}</template>
|
||||
<template #relabel="{ row }"><el-tag v-if="row.needRelabel" type="warning" effect="light">需要换标</el-tag><span v-else>否</span></template>
|
||||
|
||||
@@ -1080,7 +1080,6 @@ function selectRelabelInventory(inboundOrderId: string) {
|
||||
<QueryToolbar v-model="listQueryModel" layout="grid" :ariaLabel="`${config.title}筛选`" :fields="listQueryFields" :search-on-reset="false" @search="query" @reset="resetListQuery" />
|
||||
<div class="warehouse-card warehouse-table-card">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="submittedRows" :columns="listColumns" :loading="loading" :total="total" :page-sizes="[15, 30, 50, 100]" :row-actions="listRowActions" :empty-text="config.emptyText">
|
||||
<template #toolbar><strong>{{ config.title }}</strong><span>共 {{ total }} 条</span></template>
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />{{ props.service === 'relabel' ? '新建退货换标单' : props.service === 'transfer' ? '新增订单' : `新增${config.title}` }}</el-button></template>
|
||||
<template v-if="props.service === 'transfer' && submittedRows.some((item) => item.__status === 'WAIT_CUSTOMER_CONFIRMATION')" #before-table><div class="warehouse-list-notice">部分入库完成订单已有 PDA 实测数据,请在对应行确认测量数据。</div></template>
|
||||
</DataTable>
|
||||
|
||||
@@ -42,7 +42,7 @@ test('keeps account and notification routes as fixed-header utilities', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('organises every implemented business page under five task-oriented sidebar entries', () => {
|
||||
test('organises customer-relevant business pages under five task-oriented sidebar entries', () => {
|
||||
assert.deepEqual(
|
||||
clientNavigation.filter((node) => !node.hidden).map((node) => ({
|
||||
title: node.title,
|
||||
@@ -52,11 +52,11 @@ test('organises every implemented business page under five task-oriented sidebar
|
||||
{ title: '首页', children: undefined },
|
||||
{
|
||||
title: '订单中心',
|
||||
children: ['头程订单', '快速建草稿', '海外采购', '美国本土卡', '美本土散板', '入库订单', '派送订单'],
|
||||
children: ['头程订单', '海外采购', '美国本土卡', '美本土散板'],
|
||||
},
|
||||
{
|
||||
title: '海外仓',
|
||||
children: ['贴标申请', '仓储转运', '货物废弃', '退货预报与签收', '退货库存', '退货箱号管理'],
|
||||
children: ['贴标申请', '仓储转运', '货物废弃', '退货预报与签收'],
|
||||
},
|
||||
{
|
||||
title: '费用与账单',
|
||||
@@ -86,18 +86,13 @@ test('flattens visible leaves with unique paths', () => {
|
||||
assert.deepEqual(paths, [
|
||||
'/',
|
||||
'/orders/first-leg',
|
||||
'/first-leg/quick-order',
|
||||
'/cross-border/procurement',
|
||||
'/cross-border/domestic-trucking',
|
||||
'/cross-border/pallet-shipping',
|
||||
'/orders/inbound',
|
||||
'/orders/delivery',
|
||||
'/overseas-warehouse/labeling',
|
||||
'/overseas-warehouse/storage-transfer',
|
||||
'/overseas-warehouse/cargo-disposal',
|
||||
'/overseas-warehouse/return-relabel',
|
||||
'/overseas-warehouse/return-inventory',
|
||||
'/overseas-warehouse/return-cartons',
|
||||
'/finance/account',
|
||||
'/finance/bills',
|
||||
'/company/profile',
|
||||
@@ -340,7 +335,7 @@ test('keeps the dashboard route permission synchronized with navigation', () =>
|
||||
assert.match(dashboardRoute, /permission: 'dashboard:view'/)
|
||||
})
|
||||
|
||||
test('keeps only the unified my-orders placeholder cataloged while activating implemented business pages', () => {
|
||||
test('keeps internal operational pages routable without exposing them in customer navigation', () => {
|
||||
const routerSource = [
|
||||
'first-leg',
|
||||
'cross-border',
|
||||
@@ -357,15 +352,24 @@ test('keeps only the unified my-orders placeholder cataloged while activating im
|
||||
['overseas-warehouse/cargo-disposal', 'WarehouseServiceView.vue'],
|
||||
['overseas-warehouse/return-relabel', 'ReturnOrdersView.vue'],
|
||||
['overseas-warehouse/return-inventory', 'ReturnInventoryView.vue'],
|
||||
['overseas-warehouse/return-cartons', 'ReturnInventoryView.vue'],
|
||||
['orders/my', 'MyOrdersView.vue'],
|
||||
['orders/inbound', 'InboundOrdersView.vue'],
|
||||
['orders/delivery', 'DeliveryOrdersView.vue'],
|
||||
]
|
||||
|
||||
const hiddenActivePaths = new Set([
|
||||
'first-leg/quick-order',
|
||||
'orders/inbound',
|
||||
'orders/delivery',
|
||||
'overseas-warehouse/return-inventory',
|
||||
'overseas-warehouse/return-cartons',
|
||||
])
|
||||
for (const [path, view] of routes) {
|
||||
if (path === 'first-leg/quick-order' || path.startsWith('cross-border/') || path.startsWith('overseas-warehouse/') || path === 'orders/inbound' || path === 'orders/delivery') {
|
||||
if (path !== 'orders/my') {
|
||||
assert.match(routerSource, new RegExp(`path: '${path}'[\\s\\S]*?\\./views/${view.replace('.', '\\.')}[\\s\\S]*?release: 'active'[\\s\\S]*?permission:`))
|
||||
assert.equal(getNavigationLeaves(clientNavigation).some((item) => item.path === `/${path}`), true)
|
||||
const visible = !hiddenActivePaths.has(path)
|
||||
assert.equal(getNavigationLeaves(clientNavigation).some((item) => item.path === `/${path}`), visible)
|
||||
continue
|
||||
}
|
||||
assert.match(routerSource, new RegExp(`path: '${path}'[\\s\\S]*?\\./views/${view.replace('.', '\\.')}[\\s\\S]*?release: 'prototype'[\\s\\S]*?permission:`))
|
||||
|
||||
@@ -52,12 +52,9 @@ export const clientNavigation: ClientNavigationNode[] = [
|
||||
icon: ClipboardList,
|
||||
children: [
|
||||
requireLeaf('first-leg-orders', '头程订单'),
|
||||
requireLeaf('first-leg-quick-order', '快速建草稿'),
|
||||
requireLeaf('cross-border-procurement'),
|
||||
requireLeaf('cross-border-domestic-trucking'),
|
||||
requireLeaf('cross-border-pallet-shipping'),
|
||||
requireLeaf('orders-inbound', '入库订单'),
|
||||
requireLeaf('orders-delivery', '派送订单'),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -69,8 +66,6 @@ export const clientNavigation: ClientNavigationNode[] = [
|
||||
requireLeaf('overseas-storage-transfer'),
|
||||
requireLeaf('overseas-cargo-disposal'),
|
||||
requireLeaf('overseas-return-relabel', '退货预报与签收'),
|
||||
requireLeaf('overseas-return-inventory'),
|
||||
requireLeaf('overseas-return-cartons'),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user