This commit is contained in:
2026-08-11 10:46:01 +08:00
parent 88788dc473
commit de0a90e853
12 changed files with 517 additions and 19 deletions
+17 -4
View File
@@ -29,6 +29,19 @@
- `src/styles/`:全局设计令牌和布局;优先复用现有 `--primary`、`--navy`、`--border`、`--muted`。
- 本应用多个模块稳定复用的前端模型放到应用内 `shared/`,客户工作台专属类型保留在对应业务模块。不要链接应用目录之外的 TypeScript 包,服务端契约以 `baiyue-firstleg` Controller/OpenAPI 为准。
## 模块快速查询
先从 `src/modules/registry.ts` 确认模块,再按目标模块的 `routes.ts → views/ → api/、store/、contracts.ts 或 public.ts` 查询。页面是否正式开放以 `routes.ts` 的 `release` 为准,不从任务或进度文档推断。
| 业务 | 模块入口 | 联查模块或服务端 |
|---|---|---|
| 登录、注册与会话 | `src/modules/auth/` | Backend `auth/`、`iam/` |
| 企业资料、成员与角色 | `src/modules/tenant-management/`、`src/modules/account/` | Backend `iam/`、`file/` |
| 工作台与系统入口 | `src/modules/dashboard/`、`src/modules/system/` | `src/navigation/clientNavigation.ts`、`src/router/index.ts` |
| 头程下单、订单与履约进度 | `src/modules/first-leg/` | Backend `order/`、`firstleg/`、`warehouse/` |
| 统一订单展示 | `src/modules/order-management/` | `src/modules/first-leg/` 与对应后端订单契约 |
| 跨境、财务与海外仓候选能力 | `src/modules/cross-border/`、`src/modules/finance/`、`src/modules/overseas-warehouse/` | 先核对各自 `routes.ts` 的发布状态与阶段范围,不自动扩展实现 |
## 租户、认证与数据安全
- 任何查询和变更都必须以服务端确认的当前租户为边界;绝不从可篡改的前端参数推断授权租户。
@@ -65,10 +78,10 @@ pnpm build
按阶段开展客户工作台任务时,先核对对应阶段文档,再按“模块 `routes.ts` → 页面/组件 → `api`、store、contracts”定位;阶段范围以用户确认、需求确认表和设计文档为准,原型只用于视觉与交互补充。本表只描述 Client 页面与客户可见契约,服务端实现详见后端模块指引。
| 阶段 | 当前状态与边界 | Client 代码入口 | 开始前核对 |
| 阶段 | Client 范围边界 | 代码入口 | 开发首读 |
|---|---|---|---|
| 一期:客户与头程订单闭环 | 登录/注册、企业资料与资质、成员/角色权限、工作台和头程订单已接入一期后端;当前重点为测试 Gateway 验收,仓库查询与草稿创建仍受后端 `S0500` 阻断。订单停在已确认的七状态,不对客户开放取消写操作。 | `src/modules/auth/`、`tenant-management/`、`account/`、`system/`、`dashboard/`、`first-leg/`、`order-management/`;路由由各模块 `routes.ts` 通过 `src/modules/registry.ts` 组装。 | `../../docs/一期/README.md`、`../../docs/一期/一期开发进度表.md`、`../../docs/一期/一期头程订单闭环设计.md`;核对资格门禁、幂等键、状态机、仓库主数据及真实接口错误处理。 |
| 二期:到仓与头程计费 | 客户侧以一期订单为承接面,展示已授权的入库、测量和费用结果;页面终点为 `WAIT_SPLIT + APPROVED + CONFIRMED`,不暴露 Admin 内部备注、文件或未授权原始证据。 | `src/modules/first-leg/`(订单详情中的到仓、测量、费用只读区);同步维护本模块 `api`、store、类型和状态映射。 | `../../docs/二期/README.md`、`../../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、相关联调报告;`二期开发进度表.md` 为旧口径,使用前核对日期与验收终点。 |
| 三期:国内出仓 | 当前 NO-GO、需求确认未完成;Client 暂不新增正式业务入口。 | 规划复用 `src/modules/first-leg/`、`order-management/` 的订单承接与状态展示;新增模块、路由或 `release: 'active'` 页面前须完成契约、权限和产品确认。 | `../../docs/三期/README.md`、`../../docs/三期/三期开发规划.md`、`../../docs/三期/三期需求确认表.md`、`../../docs/三期/三期页面与原型清单.md`;不得把原型中的完整 WMS、报关或海运流程自动纳入 Client 三期范围。 |
| 一期:客户与头程订单闭环 | 登录/注册、企业资料与资质、成员/角色权限、工作台和头程订单;订单停在已确认的七状态,不对客户开放取消写操作 | `src/modules/auth/`、`tenant-management/`、`account/`、`system/`、`dashboard/`、`first-leg/` | `../../docs/一期/README.md`、一期闭环设计;核对资格门禁、幂等键、状态机、仓库主数据及真实接口错误处理 |
| 二期:到仓与头程计费 | 客户侧展示已授权的入库、测量和费用结果,终点为 `WAIT_SPLIT + APPROVED + CONFIRMED`;不暴露 Admin 内部备注、文件或未授权原始证据 | `src/modules/first-leg/` | `../../docs/二期/README.md`、二期主流程 |
| 三期:国内出仓 | 客户仅查看柜次、装柜进度、授权回执和船期,不暴露照片、锁码或后台内部资料 | `src/modules/first-leg/`;统一订单候选入口在 `order-management/` | `../../docs/三期/README.md`、需求确认表、开工门禁、开发规划和页面清单;不自动纳入完整 WMS、报关后台操作或海运运营流程 |
阶段边界:一期负责客户入驻与下单,二期负责到仓和计费并停在等待三期分柜,三期才进入国内出仓;不要把 PDA、分柜、装柜或完整财务应收应付提前实现到客户工作台。
+2 -2
View File
@@ -23,8 +23,8 @@
- 一期共享模型服务客户认证、企业资料/成员权限和头程订单;修改订单状态、仓库摘要、资质或权限枚举前,先核对阶段契约并搜索 `src/modules/` 全部消费者。
- 二期仅承接客户可见的到仓与计费结果。`WAIT_SPLIT + APPROVED + CONFIRMED` 为当前终点;内部测量明细、备注和文件权限不得通过共享类型绕过。
- 三期当前 NO-GO;不得预留分柜、装柜、PDA 或报关字段,待需求阻断解除并冻结契约后再兼容新增。
- 三期仅在服务端字段、权限和 OpenAPI 已稳定,且确有多个 Client 模块复用时才新增共享导出;先读 `../../../docs/三期/三期开工门禁.md`。只建客户可见的柜次、进度、授权回执和船期类型,不通过 shared 暴露照片、锁码或后台内部资料。
定位规则:从 `src/index.ts` 的导出开始,反查模块 `api`、store 和 `contracts.ts` 消费者;字段含义不明确时回到服务端 DTO/OpenAPI,不在 shared 中扩展业务语义。
阶段文档入口(路径相对本文件所在目录):一期 `../../../docs/一期/README.md`;二期 `../../../docs/二期/README.md` 与 `../../../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`;三期 `../../../docs/三期/README.md`、`../../../docs/三期/三期开发规划.md` 和 `../../../docs/三期/三期需求确认表.md`。阶段文档优先级高于历史进度表和原型。
阶段文档入口(路径相对本文件所在目录):一期 `../../../docs/一期/README.md`;二期 `../../../docs/二期/README.md` 与 `../../../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`;三期 `../../../docs/三期/README.md`、`../../../docs/三期/三期开发规划.md` 和 `../../../docs/三期/三期需求确认表.md`。范围与契约以阶段确认和设计文档为准,原型只作补充。
+1
View File
@@ -33,6 +33,7 @@ declare module 'vue' {
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
+122
View File
@@ -0,0 +1,122 @@
import type {
FinanceBillDetail,
FinanceBillLine,
FinanceBillStatus,
FinanceBillSummary,
FinanceReceiptRecord,
} from '../contracts'
type WireScalar = string | number
export interface FinanceBillWire {
id: WireScalar
bill_no: string
customer_name_snapshot: string
currency_code: string
bill_date: string | null
due_date: string | null
status: FinanceBillStatus
total_amount: WireScalar
received_amount: WireScalar
outstanding_amount: WireScalar
lines?: FinanceBillLineWire[]
}
export interface FinanceBillLineWire {
id: WireScalar
source_no: string | null
order_id: WireScalar | null
fee_item_name: string
amount: WireScalar
currency_code: string
}
export interface FinanceReceiptWire {
id: WireScalar
receipt_no: string
received_amount: WireScalar
received_at: string
payment_method: string | null
voucher_no: string | null
status: string
bill_id: WireScalar | null
applied_amount: WireScalar | null
}
function text(value: WireScalar) {
return String(value)
}
function mapBillSummary(bill: FinanceBillWire): FinanceBillSummary {
return {
id: text(bill.id),
billNo: bill.bill_no,
customerName: bill.customer_name_snapshot,
currencyCode: 'CNY',
totalAmount: text(bill.total_amount),
receivedAmount: text(bill.received_amount),
outstandingAmount: text(bill.outstanding_amount),
issueDate: bill.bill_date,
dueDate: bill.due_date,
status: bill.status,
}
}
function mapBillLine(line: FinanceBillLineWire): FinanceBillLine {
return {
id: text(line.id),
orderId: line.order_id == null ? null : text(line.order_id),
feeNo: line.source_no,
feeName: line.fee_item_name,
amount: text(line.amount),
currencyCode: 'CNY',
}
}
export function mapFinanceBills(bills: FinanceBillWire[]) {
return bills.map(mapBillSummary)
}
export function mapFinanceBillDetail(bill: FinanceBillWire): FinanceBillDetail {
return {
...mapBillSummary(bill),
lines: (bill.lines ?? []).map(mapBillLine),
receipts: [],
}
}
export function mapFinanceReceipts(receipts: FinanceReceiptWire[], billId: string): FinanceReceiptRecord[] {
return receipts
.filter((receipt) => receipt.bill_id != null && text(receipt.bill_id) === billId)
.map((receipt) => ({
id: text(receipt.id),
receiptNo: receipt.receipt_no,
receivedAt: receipt.received_at,
amount: text(receipt.received_amount),
currencyCode: 'CNY',
method: receipt.payment_method,
voucherNo: receipt.voucher_no,
allocatedAmount: receipt.applied_amount == null ? '0' : text(receipt.applied_amount),
}))
}
export function sumDecimalStrings(values: string[]) {
let scale = 0
const parsed = values.map((value) => {
const normalized = value.trim() || '0'
const [integer = '0', fraction = ''] = normalized.split('.')
scale = Math.max(scale, fraction.length)
return { integer, fraction }
})
const total = parsed.reduce((sum, value) => {
const sign = value.integer.startsWith('-') ? -1n : 1n
const integer = value.integer.replace(/^[+-]/, '') || '0'
const digits = `${integer}${value.fraction.padEnd(scale, '0')}`
return sum + sign * BigInt(digits || '0')
}, 0n)
if (scale === 0) return total.toString()
const negative = total < 0n
const absolute = (negative ? -total : total).toString().padStart(scale + 1, '0')
const result = `${absolute.slice(0, -scale)}.${absolute.slice(-scale)}`
return negative ? `-${result}` : result
}
+55
View File
@@ -0,0 +1,55 @@
import { apiRequest } from '@/api/http'
import type { FinanceBillDetail, FinanceBillPage, FinanceBillQuery, FinanceReceiptRecord } from '../contracts'
import {
mapFinanceBillDetail,
mapFinanceBills,
mapFinanceReceipts,
sumDecimalStrings,
type FinanceBillWire,
type FinanceReceiptWire,
} from './adapters'
/** The customer scope is resolved by the authenticated merchant session. */
export async function getMerchantFinanceBills(params: FinanceBillQuery = {}): Promise<FinanceBillPage> {
const response = await apiRequest<FinanceBillWire[]>({
method: 'GET',
url: '/client/finance/v1/bills',
})
const keyword = params.keyword?.trim().toLocaleLowerCase('zh-CN')
const filtered = mapFinanceBills(response).filter((bill) => {
const matchesStatus = !params.status || bill.status === params.status
const matchesKeyword = !keyword || bill.billNo.toLocaleLowerCase('zh-CN').includes(keyword)
|| bill.customerName.toLocaleLowerCase('zh-CN').includes(keyword)
return matchesStatus && matchesKeyword
})
const page = Math.max(1, params.page ?? 1)
const size = Math.max(1, params.size ?? 20)
const start = (page - 1) * size
return {
records: filtered.slice(start, start + size),
total: filtered.length,
page,
size,
pages: Math.ceil(filtered.length / size),
totalBilledAmount: sumDecimalStrings(filtered.map((bill) => bill.totalAmount)),
totalReceivedAmount: sumDecimalStrings(filtered.map((bill) => bill.receivedAmount)),
totalOutstandingAmount: sumDecimalStrings(filtered.map((bill) => bill.outstandingAmount)),
currencyCode: 'CNY',
}
}
export async function getMerchantFinanceBill(id: string): Promise<FinanceBillDetail> {
const response = await apiRequest<FinanceBillWire>({
method: 'GET',
url: `/client/finance/v1/bills/${encodeURIComponent(id)}`,
})
return mapFinanceBillDetail(response)
}
export async function getMerchantFinanceReceipts(billId: string): Promise<FinanceReceiptRecord[]> {
const response = await apiRequest<FinanceReceiptWire[]>({
method: 'GET',
url: '/client/finance/v1/receipts',
})
return mapFinanceReceipts(response, billId)
}
+55
View File
@@ -0,0 +1,55 @@
import type { PageResult } from '@/modules/first-leg/api/firstLeg'
export type FinanceBillStatus = 'DRAFT' | 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
export interface FinanceBillQuery {
keyword?: string
status?: FinanceBillStatus
page?: number
size?: number
}
export interface FinanceBillSummary {
id: string
billNo: string
customerName: string
currencyCode: 'CNY'
totalAmount: string
receivedAmount: string
outstandingAmount: string
issueDate: string | null
dueDate: string | null
status: FinanceBillStatus
}
export interface FinanceBillLine {
id: string
orderId: string | null
feeNo: string | null
feeName: string
amount: string
currencyCode: 'CNY'
}
export interface FinanceReceiptRecord {
id: string
receiptNo: string
receivedAt: string
amount: string
currencyCode: 'CNY'
method: string | null
voucherNo: string | null
allocatedAmount: string
}
export interface FinanceBillDetail extends FinanceBillSummary {
lines: FinanceBillLine[]
receipts: FinanceReceiptRecord[]
}
export interface FinanceBillPage extends PageResult<FinanceBillSummary> {
totalBilledAmount: string
totalReceivedAmount: string
totalOutstandingAmount: string
currencyCode: 'CNY'
}
+6 -6
View File
@@ -6,12 +6,12 @@ export const financeModule = {
navigationGroup: { title: '财务结算', icon: ReceiptText },
routes: [
{
path: 'finance/payables',
name: 'finance-payables',
component: () => import('./views/PayableBillsView.vue'),
release: 'prototype',
meta: { title: '应付账单', description: '查询企业应付账单', permission: 'merchant:finance:payables:list' },
navigation: { key: 'finance-payables', icon: ReceiptText },
path: 'finance/bills',
name: 'finance-bills',
component: () => import('./views/FinanceBillsView.vue'),
release: 'active',
meta: { title: '客户账单', description: '查看本企业账单、收款记录和未收余额', permission: 'finance:bill:view' },
navigation: { key: 'finance-bills', title: '客户账单', icon: ReceiptText },
},
],
} satisfies ClientModuleManifest
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const routesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
const viewSource = readFileSync(new URL('./FinanceBillsView.vue', import.meta.url), 'utf8')
const apiSource = readFileSync(new URL('../api/finance.ts', import.meta.url), 'utf8')
const adapterSource = readFileSync(new URL('../api/adapters.ts', import.meta.url), 'utf8')
const permissionSource = readFileSync(new URL('../../tenant-management/contracts.ts', import.meta.url), 'utf8')
test('activates the customer finance route with a read-only permission', () => {
assert.match(routesSource, /path: 'finance\/bills'/)
assert.match(routesSource, /FinanceBillsView\.vue/)
assert.match(routesSource, /release: 'active'/)
assert.match(routesSource, /permission: 'finance:bill:view'/)
assert.doesNotMatch(routesSource, /finance\/payables|应付账单/)
})
test('uses client finance read APIs without browser-selected customer scope', () => {
assert.match(apiSource, /method: 'GET'/)
assert.match(apiSource, /\/client\/finance\/v1\/bills/)
assert.match(apiSource, /\/client\/finance\/v1\/receipts/)
assert.doesNotMatch(apiSource, /\/merchant\/v1\/finance/)
assert.doesNotMatch(apiSource, /customerId|tenantId/)
assert.doesNotMatch(apiSource, /method: '(POST|PUT|PATCH|DELETE)'/)
assert.doesNotMatch(apiSource, /params:\s*\{\s*billId/)
})
test('adapts the finance snake-case wire contract and filters receipts locally by bill id', () => {
for (const field of ['bill_no', 'customer_name_snapshot', 'bill_date', 'total_amount', 'outstanding_amount', 'fee_item_name', 'order_id', 'receipt_no', 'bill_id', 'applied_amount']) {
assert.match(adapterSource, new RegExp(field))
}
assert.match(adapterSource, /filter\(\(receipt\).*receipt\.bill_id/s)
assert.match(viewSource, /loadDetail\(row\.id\)/)
assert.doesNotMatch(viewSource, /loadDetail\(row\.billNo\)/)
})
test('keeps actions first and exposes list, balance, detail, receipt, empty and error states', () => {
const actionIndex = viewSource.indexOf('label="操作"')
const billNumberIndex = viewSource.indexOf('label="账单编号"')
assert.ok(actionIndex >= 0 && actionIndex < billNumberIndex)
for (const expected of ['未收余额', '账单详情', '收款记录', '暂无账单', '暂无收款记录', 'state-panel--error']) {
assert.match(viewSource, new RegExp(expected))
}
for (const forbidden of ['确认账单', '发起争议', '新增收款', '核销', '修改金额']) {
assert.doesNotMatch(viewSource, new RegExp(forbidden))
}
})
test('catalogs customer finance as an assignable view-only role permission', () => {
assert.match(permissionSource, /code: 'finance:bill:view'/)
assert.match(permissionSource, /不包含任何财务写操作/)
assert.match(permissionSource, /merchant:finance:statement:list/)
assert.match(permissionSource, /merchant:finance:statement:view/)
})
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { CircleDollarSign, Eye, RefreshCcw, Search, WalletCards } from '@lucide/vue'
import { ApiError } from '@/api/http'
import { getMerchantFinanceBill, getMerchantFinanceBills, getMerchantFinanceReceipts } from '../api/finance'
import type { FinanceBillDetail, FinanceBillQuery, FinanceBillStatus, FinanceBillSummary } from '../contracts'
const loading = ref(false)
const detailLoading = ref(false)
const errorMessage = ref('')
const detailError = ref('')
const bills = ref<FinanceBillSummary[]>([])
const detail = ref<FinanceBillDetail | null>(null)
const selectedBillId = ref('')
const detailVisible = ref(false)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const totals = reactive({ billed: '0', received: '0', outstanding: '0' })
const filters = reactive<FinanceBillQuery>({ keyword: '', status: undefined, page: 1, size: 20 })
const statusOptions: Array<{ label: string; value: FinanceBillStatus }> = [
{ label: '已出账', value: 'ISSUED' },
{ label: '部分收款', value: 'PARTIALLY_PAID' },
{ label: '已收清', value: 'PAID' },
{ label: '作废', value: 'VOIDED' },
]
const hasData = computed(() => bills.value.length > 0)
function formatAmount(value: string | null | undefined) {
if (!value) return '0.00'
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : value
}
function formatDate(value: string | null | undefined) {
if (!value) return '-'
return value.slice(0, 10)
}
function statusLabel(status: FinanceBillStatus) {
return ({ DRAFT: '草稿', ISSUED: '已出账', PARTIALLY_PAID: '部分收款', PAID: '已收清', VOIDED: '作废' })[status] ?? status
}
function statusType(status: FinanceBillStatus) {
return ({ ISSUED: 'info', PARTIALLY_PAID: 'warning', PAID: 'success', VOIDED: 'danger', DRAFT: 'info' })[status] as 'info' | 'warning' | 'success' | 'danger'
}
function messageOf(error: unknown) {
return error instanceof ApiError ? error.message : '账单加载失败,请稍后重试'
}
async function loadBills(nextPage = page.value) {
loading.value = true
errorMessage.value = ''
page.value = nextPage
try {
const response = await getMerchantFinanceBills({ ...filters, keyword: filters.keyword?.trim() || undefined, page: nextPage, size: pageSize.value })
bills.value = response.records
total.value = response.total
totals.billed = response.totalBilledAmount
totals.received = response.totalReceivedAmount
totals.outstanding = response.totalOutstandingAmount
} catch (error) {
bills.value = []
total.value = 0
totals.billed = '0'
totals.received = '0'
totals.outstanding = '0'
errorMessage.value = messageOf(error)
} finally {
loading.value = false
}
}
async function openDetail(row: FinanceBillSummary) {
selectedBillId.value = row.id
await loadDetail(row.id)
}
async function loadDetail(billId: string) {
detailVisible.value = true
detailLoading.value = true
detailError.value = ''
detail.value = null
try {
const [bill, receipts] = await Promise.all([getMerchantFinanceBill(billId), getMerchantFinanceReceipts(billId)])
detail.value = { ...bill, receipts }
} catch (error) {
detailError.value = messageOf(error)
} finally {
detailLoading.value = false
}
}
function resetFilters() {
filters.keyword = ''
filters.status = undefined
void loadBills(1)
}
function handlePageChange(nextPage: number) {
void loadBills(nextPage)
}
onMounted(() => { void loadBills(1) })
</script>
<template>
<div class="finance-bills-page">
<header class="page-heading">
<div>
<p class="eyebrow">财务结算</p>
<h1>客户账单</h1>
<p>查看本企业账单、收款记录和未收余额。币种固定为 RMB。</p>
</div>
<WalletCards :size="34" stroke-width="1.5" aria-hidden="true" />
</header>
<section class="summary-grid" aria-label="账单汇总">
<article class="summary-card"><span>账单总额</span><strong>¥ {{ formatAmount(totals.billed) }}</strong><small>CNY</small></article>
<article class="summary-card"><span>已收金额</span><strong>¥ {{ formatAmount(totals.received) }}</strong><small>CNY</small></article>
<article class="summary-card summary-card--warning"><span>未收余额</span><strong>¥ {{ formatAmount(totals.outstanding) }}</strong><small>请按账单核对</small></article>
</section>
<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="90" fixed="left">
<template #default="{ row }"><el-button link type="primary" @click="openDetail(row as FinanceBillSummary)"><Eye :size="15" />查看</el-button></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>
<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>
</section>
<el-drawer v-model="detailVisible" title="账单详情" size="min(680px, 100%)" destroy-on-close>
<div v-loading="detailLoading" class="detail-content">
<div v-if="detailError" class="state-panel state-panel--error" role="alert"><p>{{ detailError }}</p><el-button @click="loadDetail(selectedBillId)">重试</el-button></div>
<template v-else-if="detail">
<div class="detail-heading"><div><p class="eyebrow">{{ detail.billNo }}</p><h2>账单明细</h2></div><el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag></div>
<dl class="detail-meta"><div><dt>出账日期</dt><dd>{{ formatDate(detail.issueDate) }}</dd></div><div><dt>到期日</dt><dd>{{ formatDate(detail.dueDate) }}</dd></div><div><dt>币种</dt><dd>RMB/CNY</dd></div><div><dt>未收余额</dt><dd class="amount-warning">¥ {{ formatAmount(detail.outstandingAmount) }}</dd></div></dl>
<section class="detail-section"><h3>费用明细</h3><el-table :data="detail.lines" size="small"><el-table-column prop="orderId" label="订单ID" min-width="120" /><el-table-column prop="feeNo" label="费用来源编号" min-width="140" /><el-table-column prop="feeName" label="费用项目" min-width="130" /><el-table-column label="金额" width="130" align="right"><template #default="{ row }">¥ {{ formatAmount(row.amount) }}</template></el-table-column></el-table></section>
<section class="detail-section"><h3>收款记录</h3><el-table v-if="detail.receipts.length" :data="detail.receipts" size="small"><el-table-column prop="receivedAt" label="收款日期" width="110"><template #default="{ row }">{{ formatDate(row.receivedAt) }}</template></el-table-column><el-table-column prop="receiptNo" label="收款编号" min-width="130" /><el-table-column label="本次金额" width="130" align="right"><template #default="{ row }">¥ {{ formatAmount(row.allocatedAmount) }}</template></el-table-column><el-table-column prop="method" label="方式" min-width="100" /></el-table><div v-else class="sub-empty">暂无收款记录</div></section>
<p class="readonly-notice">账单金额和收款记录仅供查看。如有疑问,请线下联系平台财务。</p>
</template>
</div>
</el-drawer>
</div>
</template>
<style scoped>
.finance-bills-page { display: flex; flex-direction: column; gap: 16px; }
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; color: var(--navy); }
.page-heading h1, .detail-heading h2 { 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; }
.summary-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.summary-card { display: flex; min-height: 106px; flex-direction: column; justify-content: center; gap: 7px; padding: 18px 20px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface, #fff); }
.summary-card span, .summary-card small { color: var(--muted); font-size: 12px; }.summary-card strong { color: var(--navy); font-size: 23px; }.summary-card--warning strong, .amount-warning { 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; }
.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-meta { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 20px 0; }.detail-meta div { padding: 12px; border-radius: 6px; background: #f7fafb; }.detail-meta dt { color: var(--muted); font-size: 11px; }.detail-meta dd { margin: 5px 0 0; color: var(--navy); font-size: 14px; font-weight: 600; }.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) { .summary-grid { grid-template-columns: 1fr; }.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; }.page-heading h1 { font-size: 21px; } }
@media (max-width: 420px) { .detail-meta { grid-template-columns: 1fr; }.page-heading > svg { display: none; } }
</style>
@@ -1,5 +0,0 @@
<script setup lang="ts">
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
</script>
<template><PrototypeFeatureView title="应付账单" description="查询企业应付账单" /></template>
@@ -25,6 +25,11 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
label: '业务总览',
items: [{ code: 'dashboard:view', label: '查看总览', description: '所有企业成员默认可用,可查看企业统计和最近订单' }],
},
{
key: 'finance',
label: '财务账单',
items: [{ code: 'finance:bill:view', label: '查看客户账单', description: '查看本企业账单、收款记录和未收余额;不包含任何财务写操作' }],
},
{
key: 'orders',
label: '头程订单',
@@ -82,4 +87,5 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
'company:role:write': ['merchant:role:create', 'merchant:role:edit', 'merchant:role:status', 'merchant:role:delete'],
'company:permission:list': ['merchant:role:list'],
'company:permission:assign': ['merchant:role:grant'],
'finance:bill:view': ['merchant:finance:statement:list', 'merchant:finance:statement:view'],
}
+5 -2
View File
@@ -53,6 +53,10 @@ test('keeps only implemented navigation groups visible', () => {
title: '头程物流',
children: ['订单列表'],
},
{
title: '财务结算',
children: ['客户账单'],
},
{
title: '企业管理',
children: ['企业资料', '企业成员', '角色管理', '菜单权限'],
@@ -78,6 +82,7 @@ test('flattens visible leaves with unique paths', () => {
assert.deepEqual(paths, [
'/',
'/orders/first-leg',
'/finance/bills',
'/company/profile',
'/organization/members',
'/organization/roles',
@@ -299,7 +304,6 @@ test('keeps every prototype view cataloged with permission metadata but unreleas
'cross-border',
'overseas-warehouse',
'order-management',
'finance',
].map((moduleName) => readFileSync(new URL(`../modules/${moduleName}/routes.ts`, import.meta.url), 'utf8')).join('\n')
const routes = [
['first-leg/import', 'BatchImportView.vue'],
@@ -314,7 +318,6 @@ test('keeps every prototype view cataloged with permission metadata but unreleas
['orders/my', 'MyOrdersView.vue'],
['orders/inbound', 'InboundOrdersView.vue'],
['orders/delivery', 'DeliveryOrdersView.vue'],
['finance/payables', 'PayableBillsView.vue'],
]
for (const [path, view] of routes) {