项目中心-支付模块

This commit is contained in:
wangzzpeng
2026-09-30 08:04:16 +08:00
parent 6b88c3805f
commit ce8210fb3f
35 changed files with 2618 additions and 175 deletions

View File

@@ -17,6 +17,7 @@
"axios": "^1.20.0",
"element-plus": "^2.14.5",
"pinia": "^4.0.3",
"qrcode-generator": "^2.0.4",
"vue": "^3.5.42",
"vue-router": "^4.6.4"
},

8
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
pinia:
specifier: ^4.0.3
version: 4.0.3(@vue/devtools-api@6.6.4)(typescript@6.0.3)(vue@3.5.42(typescript@6.0.3))
qrcode-generator:
specifier: ^2.0.4
version: 2.0.4
vue:
specifier: ^3.5.42
version: 3.5.42(typescript@6.0.3)
@@ -642,6 +645,9 @@ packages:
prr@1.0.1:
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
qrcode-generator@2.0.4:
resolution: {integrity: sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==}
rolldown@1.2.8:
resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1282,6 +1288,8 @@ snapshots:
prr@1.0.1:
optional: true
qrcode-generator@2.0.4: {}
rolldown@1.2.8:
dependencies:
'@oxc-project/types': 0.149.0

View File

@@ -2,19 +2,25 @@ import type { PageResult } from '@/types/api'
import type {
Bill,
BillQuery,
BillVo,
FinanceStats,
Invoice,
InvoiceQuery,
InvoiceTitle,
InvoiceTitlePayload,
PayMethod,
PayOrderResponse,
Payment,
PaymentResult,
} from '@/types/business/finance'
import { toBills, toPlanBills, type PlanLike } from '@/utils/billVo'
import { toPayMethod } from '@/utils/payMeta'
import { request } from '@/utils/request'
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
// 说明:账单 / 支付相关接口已接后端真数据;仅「发票」与「统计」仍是 mock。
// 另有已下线的遗留演示扫码页(pay.vue)用的一组 mock,见文件中部注释。
/** 账单 mock(可变:支付后状态在前端内存中翻转演示) */
/** 遗留演示页用的假账单(真账单走 `GET /web/bill/list`,不读这里) */
const MOCK_BILLS: Bill[] = [
{
id: 'bill-001',
@@ -163,6 +169,17 @@ export function getFinanceStats(period?: string): Promise<FinanceStats> {
return Promise.resolve(structuredClone(DEFAULT_STATS))
}
/** 真账单 id 一定是纯数字(`vm_bill.id` 是 bigint);非数字只可能来自已下线的演示mock */
function isRealBillId(id: string): boolean {
return /^\d+$/.test(id)
}
/** 拉取「当前登录用户」的全部账单(`GET /web/bill/list`,不带 ids) */
async function fetchOwnBills(): Promise<Bill[]> {
const rows = await request<BillVo[]>({ url: '/web/bill/list', method: 'get' })
return toBills(rows)
}
function matchBillKeyword(item: Bill, keyword: string): boolean {
if (!keyword) return true
return (
@@ -173,26 +190,39 @@ function matchBillKeyword(item: Bill, keyword: string): boolean {
)
}
/** 账单列表(筛选 + 分页) */
export function getBillList(query: BillQuery): Promise<PageResult<Bill>> {
const filtered = MOCK_BILLS.filter((item) => {
/**
* 账单列表(筛选 + 分页)。
*
* <p>⚠️ `GET /web/bill/list` 只按登录用户返回**全量**账单,后端没有 pageNum / keyword /
* status 查询参数;单个客户的账单量级是「几条 ~ 几十条」,故筛选与分页放在前端做。
* 若日后账单量变大,应给该端点补后端分页参数,这里再切成一次请求。</p>
*/
export async function getBillList(query: BillQuery): Promise<PageResult<Bill>> {
const all = await fetchOwnBills()
const filtered = all.filter((item) => {
if (query.status !== 'all' && item.status !== query.status) return false
return matchBillKeyword(item, query.keyword)
})
const start = (query.page - 1) * query.pageSize
return Promise.resolve({
list: filtered.slice(start, start + query.pageSize).map((item) => ({ ...item })),
total: filtered.length,
})
return { list: filtered.slice(start, start + query.pageSize), total: filtered.length }
}
/** 账单详情(不存在返回 null) */
export function getBillDetail(id: string): Promise<Bill | null> {
const found = MOCK_BILLS.find((item) => item.id === id) ?? null
return Promise.resolve(found ? { ...found } : null)
export async function getBillDetail(id: string): Promise<Bill | null> {
const rows = await request<BillVo[]>({ url: '/web/bill/list', method: 'get', params: { ids: id } })
return toBills(rows)[0] ?? null
}
/** 创建支付单(生成二维码支付信息) */
// ═══════════════════════════════════════════════════════════════════════
// 遗留演示扫码页(`/finance/bills/:id/pay`,页面 pay.vue)
// ---------------------------------------------------------------------
// 该页已无任何入口(收银台 checkout.vue 取代了它),但按既定决定保留。
// 它用的是下面这几个 mock 函数,与真账单互不干扰:真账单 id 一定是数字,
// 假账单 id 形如 `bill-001`,两条路径靠 `isRealBillId` 分流。
// 若要下线该页,这一整段(含 MOCK_PAYMENTS / MOCK_RESULTS)可一并删除。
// ═══════════════════════════════════════════════════════════════════════
/** 创建支付单(遗留页演示用:生成二维码支付信息) */
export function createPayment(billId: string, method: PayMethod): Promise<Payment> {
const bill = MOCK_BILLS.find((item) => item.id === billId)
if (!bill) return Promise.reject(new Error('账单不存在'))
@@ -206,13 +236,38 @@ export function createPayment(billId: string, method: PayMethod): Promise<Paymen
return Promise.resolve({ ...payment })
}
/** 切换支付方式(刷新二维码) */
/** 切换支付方式(遗留页演示用:刷新二维码) */
export function switchPaymentMethod(billId: string, method: PayMethod): Promise<Payment> {
return createPayment(billId, method)
}
/** 查询支付结果(无支付记录且未支付返回 null;演示数据 payTime/tradeNo 固定) */
export function getPayResult(billId: string): Promise<PaymentResult | null> {
/**
* 查询支付结果。
*
* <p><b>真账单</b>:直接读 `vm_bill` —— 账单被回调置为 PAID 时,`pay_time` /
* `transaction_no` / `pay_channel` 已由后端一并回写,这里拼出结果页所需结构;
* 未支付则返回 null。</p>
*
* <p><b>假账单</b>(id 形如 `bill-001`):走下面的遗留演示逻辑。真账单 id 一定是数字,
* 故只有已下线的 pay.vue 会命中这条分支。</p>
*/
export async function getPayResult(billId: string): Promise<PaymentResult | null> {
if (isRealBillId(billId)) {
const bill = await getBillDetail(billId)
if (!bill || bill.status !== '已支付') return null
return {
method: toPayMethod(bill.payChannel),
amount: bill.amount,
payTime: bill.payTime ?? '—',
tradeNo: bill.tradeNo ?? '—',
bills: [bill],
}
}
return getLegacyPayResult(billId)
}
/** 遗留演示:假账单的支付结果(详见上方说明,随 pay.vue 一起可删) */
function getLegacyPayResult(billId: string): Promise<PaymentResult | null> {
const existing = MOCK_RESULTS.get(billId)
if (existing) return Promise.resolve(structuredClone(existing))
@@ -314,3 +369,98 @@ export function setDefaultTitle(id: string): Promise<InvoiceTitle[]> {
export function exportReport(): Promise<void> {
return Promise.resolve()
}
// ═══════════════════════════════════════════════════════════════════════
// 收银台(确认支付)——以下为**真实后端接口**,不是 mock
// 后端类:vermis-modules/…/controller/pc/pay/WebScanPayController,类级 @SaIgnore(匿名可调)
// ═══════════════════════════════════════════════════════════════════════
/**
* 取收银台「订单明细」——**复用项目详情「合同财务」那个现成接口**,不另开端点。
*
* <p>`GET /web/requirementPool/get/finance/{requirementId}`(`ProjectRequirementUserController`)
* 的 `{id}` 是**需求 id**;后端把「需求 + 项目」拼成一条联合结构(`loadProject` 用
* `vm_rd_project.requirement_id` 反查项目),再把该项目的付款计划随 `billPlans` 一并下发。</p>
*
* <p>本栈支付口径是 `vm_bill_plan.id`(`vm_finance_payment.bill_id` 实测存的就是计划 id),
* 故订单明细直接取这份 `billPlans`,只保留**本次要付的期**:
* URL 带 `ids`(期次 id)时按期次过滤,不带则取状态为「待支付」的期。</p>
*
* @param requirementId 需求 id(项目详情路由 `:id`)
* @param ids 期次 id(`vm_bill_plan.id`)列表,可选,用于精确指定付哪一期
*/
export async function getCheckoutBills(requirementId: string, ids: string[] = []): Promise<Bill[]> {
if (!/^\d+$/.test(requirementId)) return []
const vo = await request<{ billPlans?: PlanLike[] }>({
url: `/web/requirementPool/get/finance/${requirementId}`,
method: 'get',
})
const all = toPlanBills(vo?.billPlans)
return ids.length > 0
? all.filter((bill) => ids.includes(bill.id))
: all.filter((bill) => bill.status === '待支付')
}
/**
* 支付结果(收银台「我已完成支付」→ 结果页)。
*
* <p>口径同 {@link getCheckoutBills}:按**需求 id + 期次 id** 从 `billPlans` 里取出该期,
* 支付单状态为 `SUCCESS` 才算已支付——计划本身的 status 可能还没被回调推到 DONE。</p>
*/
export async function getPlanPayResult(requirementId: string, planId: string): Promise<PaymentResult | null> {
const bills = await getCheckoutBills(requirementId, [planId])
const bill = bills[0]
if (!bill || bill.status !== '已支付') return null
return {
method: toPayMethod(bill.payChannel),
amount: bill.amount,
payTime: bill.payTime ?? '—',
tradeNo: bill.tradeNo ?? '—',
bills: [bill],
}
}
/**
* 微信 Native 下单(PC 网站扫码):`POST /pay/web/wechat/native`,`@SaIgnore`。
*
* @param orderNo 商户订单号(前端生成,见 `utils/payMeta.ts#createMerchantOrderNo`)
* @param amount 支付金额,单位 **元**(后端内部 `×100` 转「分」提交微信)
* @param billIds 本笔支付覆盖的账单 id(合并支付传多张)。
* 后端落 `vm_finance_payment`(status=CREATED)时把它序列化进
* `request_payload`,支付成功回调据此为**每张账单**落一行
* `vm_finance_payment_bill`,缺了它回调分摊落库会跳过。
* @returns `codeUrl`(形如 `weixin://wxpay/bizpayurl?pr=xxx`),前端渲染成二维码供扫
*/
export function createWechatNativeOrder(
orderNo: string,
amount: number,
billIds: string[] = [],
): Promise<PayOrderResponse> {
return request<PayOrderResponse>({
url: '/pay/web/wechat/native',
method: 'post',
data: { orderNo, amount, billIds },
})
}
/**
* 支付宝电脑网站支付下单:`POST /pay/web/alipay/page`,`@SaIgnore`。
*
* <p>入参对应后端 `IFinancePaymentBo`。其 `outTradeNo` / `status` 上的 `@NotBlank`
* 只声明在 AddGroup / EditGroup 分组上,而控制器用的是默认分组 ⇒ 只传
* `outTradeNo` + `amount` 即可(`payChannel` / `status` 由后端自己补),不会校验失败。</p>
*
* @returns `formBody` —— 后端走 `pageExecute(request, "GET")`,实测内容是
* **支付宝收银台跳转地址**(`https://…/gateway.do?…`)而不是 HTML 表单;前端两种形态都兜住。
*/
export function createAlipayPageOrder(
outTradeNo: string,
amount: number,
billIds: string[] = [],
): Promise<PayOrderResponse> {
return request<PayOrderResponse>({
url: '/pay/web/alipay/page',
method: 'post',
data: { outTradeNo, amount, billIds },
})
}

18
src/api/oss.ts Normal file
View File

@@ -0,0 +1,18 @@
import service from '@/utils/request'
/**
* 同源预览:后端从 OSS 读取字节后以 `inline` 方式返回,绕开阿里云 OSS 桶「强制下载」配置。
*
* <p>⚠️ 该端点返回的是<b>裸字节</b>(非 `R.ok()` 包装的 `ApiResponse<T>`),故不走统一的
* `request<T>` 解包逻辑,直接用底层 axios 实例拿 `blob`:请求拦截器已自动带上
* `Authorization` 与 `clientid` 头,后端据此放行(端点需登录)。</p>
*
* <p>用 blob 而非 `window.open(url)` 的关键:blob URL 自身不带 `Content-Disposition`,
* 天然绕开 OSS 的强制下载;且全程同源,不受 OSS 跨域 / 强制下载限制。</p>
*/
export async function fetchOssPreviewBlob(ossId: string | number): Promise<Blob> {
const resp = await service.get(`/web/workOrderDraft/preview/${ossId}`, {
responseType: 'blob',
})
return resp.data as Blob
}

View File

@@ -0,0 +1,247 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { AppButton } from '@/components'
import { useOssStore } from '@/stores/modules/oss'
import { PREVIEW_TEXT_MAX_BYTES, formatBytes } from '@/utils/filePreview'
const ossStore = useOssStore()
const { visible, file, kind, objectUrl, textContent, loading, error, truncated, totalBytes } = storeToRefs(ossStore)
function onClose(): void {
ossStore.closePreview()
}
function onDownload(): void {
const target = file.value
if (!target?.url) return
const link = document.createElement('a')
link.href = target.url
link.download = target.name
link.target = '_blank'
link.rel = 'noopener'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="preview-mask" @click.self="onClose">
<div class="preview-dialog">
<header class="preview-dialog__head">
<span class="preview-dialog__name" :title="file?.name">{{ file?.name }}</span>
<button class="preview-dialog__close" type="button" aria-label="关闭" @click="onClose">
×
</button>
</header>
<div class="preview-dialog__body">
<!-- 加载中 -->
<div v-if="loading" class="preview-dialog__center">
预览加载中…
</div>
<!-- 加载失败 / 无 ossId 等错误 -->
<div v-else-if="error" class="preview-dialog__center">
<p class="preview-dialog__msg">{{ error }}</p>
<AppButton v-if="file?.url" type="primary" @click="onDownload">
下载文件
</AppButton>
</div>
<!-- 不支持内联预览(office 等) -->
<div v-else-if="kind === 'unsupported'" class="preview-dialog__center">
<p class="preview-dialog__msg">
当前格式({{ (file?.name ?? '').split('.').pop() }})不支持在线预览,请下载后查看。
</p>
<AppButton v-if="file?.url" type="primary" @click="onDownload">
下载文件
</AppButton>
</div>
<!-- 图片 -->
<img
v-else-if="kind === 'image' && objectUrl"
:src="objectUrl"
class="preview-dialog__image"
alt="预览"
>
<!-- PDF(blob 已带 application/pdf,iframe 内联渲染) -->
<iframe
v-else-if="kind === 'pdf' && objectUrl"
:src="objectUrl"
class="preview-dialog__frame"
title="预览"
/>
<!-- 视频 -->
<video
v-else-if="kind === 'video' && objectUrl"
:src="objectUrl"
class="preview-dialog__media"
controls
/>
<!-- 音频 -->
<audio
v-else-if="kind === 'audio' && objectUrl"
:src="objectUrl"
class="preview-dialog__media"
controls
/>
<!-- 文本(大文件截断预览,避免卡死浏览器) -->
<div v-else-if="kind === 'text'" class="preview-dialog__text-wrap">
<p v-if="truncated" class="preview-dialog__hint">
<span>文件较大({{ formatBytes(totalBytes ?? 0) }}),仅预览前 {{ formatBytes(PREVIEW_TEXT_MAX_BYTES) }},完整内容请下载。</span>
<AppButton v-if="file?.url" type="primary" @click="onDownload">下载文件</AppButton>
</p>
<pre class="preview-dialog__text">{{ textContent }}</pre>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style lang="less" scoped>
.preview-mask {
position: fixed;
inset: 0;
z-index: 2000;
.flex(row, center);
background: rgba(0, 0, 0, 0.55);
padding: @spacing-lg;
overflow: auto;
}
.preview-dialog {
width: min(960px, 100%);
max-height: calc(100vh - @spacing-xxl);
.flex(column, flex-start, stretch);
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-lg;
overflow: hidden;
&__head {
.flex(row, space-between, center);
flex-shrink: 0;
gap: @spacing-sm;
padding: @spacing-base @spacing-lg;
border-bottom: 1px solid @border-color;
}
&__name {
min-width: 0;
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__close {
flex-shrink: 0;
width: 28px;
height: 28px;
border: none;
border-radius: @radius-base;
background: transparent;
color: @text-color-secondary;
font-size: 20px;
line-height: 1;
cursor: pointer;
transition: background 0.2s, color 0.2s;
&:hover {
background: @bg-color-light;
color: @text-color;
}
}
&__body {
flex: 1;
min-height: 0;
.flex(column, flex-start, stretch);
padding: @spacing-base;
overflow: auto;
}
&__center {
.flex(column, center, center);
gap: @spacing-base;
width: 100%;
min-height: 240px;
}
&__msg {
font-size: @font-size-base;
color: @text-color-secondary;
text-align: center;
}
&__image {
max-width: 100%;
max-height: 70vh;
margin: auto;
object-fit: contain;
border-radius: @radius-base;
}
&__frame {
flex: 1;
width: 100%;
min-height: 60vh;
border: none;
}
&__media {
width: 100%;
max-height: 70vh;
margin: auto;
}
&__text-wrap {
display: flex;
flex-direction: column;
width: 100%;
min-height: 0;
}
&__hint {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: @spacing-sm;
flex-shrink: 0;
margin: 0 0 @spacing-sm;
padding: @spacing-sm @spacing-base;
border-radius: @radius-base;
background: #fff7e6;
border: 1px solid #ffd591;
color: #ad6800;
font-size: @font-size-sm;
line-height: 1.6;
}
&__text {
width: 100%;
margin: 0;
padding: @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
color: @text-color;
font-size: @font-size-sm;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
max-height: 70vh;
overflow: auto;
}
}
</style>

View File

@@ -8,6 +8,7 @@ export { default as PaginationBar } from './PaginationBar.vue'
export { default as StatusTag } from './StatusTag.vue'
export { default as FileList } from './FileList.vue'
export { default as UploadArea } from './UploadArea.vue'
export { default as FilePreviewDialog } from './FilePreviewDialog.vue'
export { default as StepForm } from './StepForm.vue'
export { default as SmsCodeInput } from './SmsCodeInput.vue'
export { default as PasswordStrength } from './PasswordStrength.vue'

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { AppFooter, AppHeader } from '@/components'
import { AppFooter, AppHeader, FilePreviewDialog } from '@/components'
import FloatingActions from './components/FloatingActions.vue'
const route = useRoute()
@@ -21,6 +21,8 @@ const showFooter = computed(() => route.meta.footer === true)
</main>
<AppFooter v-if="showFooter" />
<FloatingActions />
<!-- 全局文件预览弹窗(同源 OSS 预览,绕开桶强制下载) -->
<FilePreviewDialog />
</div>
</template>

View File

@@ -29,6 +29,8 @@ const layoutRoutes: RouteRecordRaw = {
{ path: 'finance/bills/:id', name: 'BillDetail', component: () => import('@/views/finance/bill-detail.vue'), meta: { title: '账单详情', requiresAuth: true } },
{ path: 'finance/bills/:id/pay', name: 'BillPay', component: () => import('@/views/finance/pay.vue'), meta: { title: '扫码支付', requiresAuth: true } },
{ path: 'finance/bills/:id/pay/result', name: 'PayResult', component: () => import('@/views/finance/pay-result.vue'), meta: { title: '支付结果', requiresAuth: true } },
// 收银台(确认支付):由账单明细勾选/单笔「去支付」带入 `?ids=bill-001,bill-002`
{ path: 'finance/checkout', name: 'Checkout', component: () => import('@/views/finance/checkout.vue'), meta: { title: '确认支付', requiresAuth: true } },
{ path: 'finance/invoices', name: 'Invoices', component: () => import('@/views/finance/invoices.vue'), meta: { title: '发票申请', requiresAuth: true } },
{ path: 'finance/invoice-titles', name: 'InvoiceTitles', component: () => import('@/views/finance/invoice-titles.vue'), meta: { title: '发票抬头', requiresAuth: true } },
{ path: 'message', name: 'Message', component: () => import('@/views/message'), meta: { title: '消息中心', requiresAuth: true } },

View File

@@ -10,20 +10,25 @@ import type {
InvoiceTitle,
InvoiceTitlePayload,
PayMethod,
PayOrderResponse,
Payment,
PaymentResult,
} from '@/types/business/finance'
import {
applyInvoice as applyInvoiceApi,
createAlipayPageOrder as createAlipayPageOrderApi,
createInvoiceTitle,
createPayment as createPaymentApi,
createWechatNativeOrder as createWechatNativeOrderApi,
deleteInvoiceTitle,
exportReport as exportReportApi,
getBillDetail,
getBillList,
getCheckoutBills,
getFinanceStats,
getInvoiceList,
getInvoiceTitles,
getPlanPayResult,
getPayResult,
setDefaultTitle as setDefaultTitleApi,
switchPaymentMethod as switchPaymentMethodApi,
@@ -53,6 +58,13 @@ export const useFinanceStore = defineStore('finance', () => {
const payment = ref<Payment | null>(null)
const result = ref<PaymentResult | null>(null)
// —— 收银台(确认支付页)
/** 收银台待支付账单(由路由 `?ids=` 拉取) */
const checkoutBills = ref<Bill[]>([])
const checkoutLoading = ref(false)
/** 最近一次下单响应(微信 codeUrl / 支付宝跳转地址) */
const payOrder = ref<PayOrderResponse | null>(null)
// —— 发票
const invoices = ref<Invoice[]>([])
const invoiceTotal = ref(0)
@@ -108,8 +120,48 @@ export const useFinanceStore = defineStore('finance', () => {
payment.value = await switchPaymentMethodApi(billId, method)
}
async function fetchResult(billId: string): Promise<void> {
result.value = await getPayResult(billId)
async function fetchResult(billId: string, requirementId = ''): Promise<void> {
result.value = requirementId
? await getPlanPayResult(requirementId, billId)
: await getPayResult(billId)
}
// —— 收银台(确认支付)
/**
* 拉取收银台订单明细。
*
* <p>入参是**需求 id**(项目详情路由 `:id`):数据走项目详情那个现成接口
* `GET /web/requirementPool/get/finance/{需求id}` 的 `billPlans`,不另开端点。
* `ids` 为可选的期次 id(`vm_bill_plan.id`),用于精确指定付哪一期;
* 不传则取该需求下状态为「待支付」的期。</p>
*/
async function loadCheckout(requirementId: string, ids: string[] = []): Promise<void> {
checkoutLoading.value = true
try {
checkoutBills.value = await getCheckoutBills(requirementId, ids)
} finally {
checkoutLoading.value = false
}
}
/**
* 微信 Native 下单(拿到 codeUrl 后由页面渲染二维码)。
*
* @param billIds 本笔支付覆盖的账单 id。后端据此落 `vm_finance_payment` 并把它写进
* `request_payload`,支付成功回调再为每张账单落 `vm_finance_payment_bill`。
*/
async function payByWechatNative(orderNo: string, amount: number, billIds: string[]): Promise<void> {
payOrder.value = await createWechatNativeOrderApi(orderNo, amount, billIds)
}
/** 支付宝电脑网站支付下单(拿到跳转地址后由页面跳转) */
async function payByAlipayPage(orderNo: string, amount: number, billIds: string[]): Promise<void> {
payOrder.value = await createAlipayPageOrderApi(orderNo, amount, billIds)
}
/** 清空下单响应(关闭二维码弹窗时调用) */
function clearPayOrder(): void {
payOrder.value = null
}
// —— 发票
@@ -178,6 +230,9 @@ export const useFinanceStore = defineStore('finance', () => {
currentBill,
payment,
result,
checkoutBills,
checkoutLoading,
payOrder,
invoices,
invoiceTotal,
invoiceQuery,
@@ -192,6 +247,10 @@ export const useFinanceStore = defineStore('finance', () => {
createPayment,
switchPaymentMethod,
fetchResult,
loadCheckout,
payByWechatNative,
payByAlipayPage,
clearPayOrder,
fetchInvoices,
setInvoiceQuery,
searchInvoices,

114
src/stores/modules/oss.ts Normal file
View File

@@ -0,0 +1,114 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { fetchOssPreviewBlob } from '@/api/oss'
import { resolvePreviewKind, PREVIEW_TEXT_MAX_BYTES, type PreviewKind } from '@/utils/filePreview'
import type { FileInfo } from '@/types/api'
/**
* OSS 文件预览全局状态。
*
* <p>所有 `FileList` 的「预览」统一走这里,弹一个全局 `FilePreviewDialog`:
* 有 `ossId` 的文件经同源后端接口取 blob 内联渲染(绕开桶强制下载);
* 无 `ossId` 的图片可退化为公网直链(子资源渲染不受强制下载影响),其余提示「不支持在线预览」。</p>
*/
export const useOssStore = defineStore('oss', () => {
const visible = ref(false)
const file = ref<FileInfo | null>(null)
const kind = ref<PreviewKind>('unsupported')
const objectUrl = ref<string | null>(null)
const textContent = ref<string | null>(null)
const loading = ref(false)
const error = ref('')
/** 文本类文件超过预览上限被截断渲染 */
const truncated = ref(false)
/** 被截断文件的实际大小(字节) */
const totalBytes = ref(0)
function revokeObjectUrl(): void {
if (objectUrl.value && objectUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(objectUrl.value)
}
objectUrl.value = null
}
async function openPreview(target: FileInfo): Promise<void> {
closePreview()
file.value = target
visible.value = true
loading.value = true
error.value = ''
textContent.value = null
truncated.value = false
totalBytes.value = 0
const previewKind = resolvePreviewKind(target.name)
kind.value = previewKind
// 不支持内联渲染(office / 未知类型):直接提示,等用户下载
if (previewKind === 'unsupported') {
loading.value = false
return
}
// 无 ossId:图片可走公网直链(<img> 子资源渲染不受强制下载影响),其余无法预览
if (target.ossId === undefined || target.ossId === null) {
if (previewKind === 'image' && target.url) {
objectUrl.value = target.url
loading.value = false
return
}
error.value = '该文件暂不支持在线预览,请使用下载'
loading.value = false
return
}
try {
const blob = await fetchOssPreviewBlob(target.ossId)
if (previewKind === 'text') {
// 整文件塞进 <pre> 会卡死浏览器(如 5MB+ 的 .sql/.log),
// 仅读取前 PREVIEW_TEXT_MAX_BYTES 渲染,超出截断并提示下载
const total = blob.size
if (total > PREVIEW_TEXT_MAX_BYTES) {
textContent.value = await blob.slice(0, PREVIEW_TEXT_MAX_BYTES).text()
truncated.value = true
totalBytes.value = total
} else {
textContent.value = await blob.text()
}
} else {
// 图片 / PDF / 音视频由浏览器原生处理,5MB 量级毫无压力
objectUrl.value = URL.createObjectURL(blob)
}
loading.value = false
} catch {
error.value = '预览加载失败,请使用下载'
loading.value = false
}
}
function closePreview(): void {
revokeObjectUrl()
visible.value = false
file.value = null
kind.value = 'unsupported'
textContent.value = null
truncated.value = false
totalBytes.value = 0
loading.value = false
error.value = ''
}
return {
visible,
file,
kind,
objectUrl,
textContent,
loading,
error,
truncated,
totalBytes,
openPreview,
closePreview,
}
})

View File

@@ -84,3 +84,31 @@
@milestone-done: #34c724; // 里程碑已完成圆点
@milestone-idle: #cecece; // 里程碑待开始圆环 + 连接线(设计稿实取)
@team-avatar-bg: #54bcbd; // 团队头像底色(设计稿实取)
// 收银台 / 确认支付(按「支付」设计稿标注校准)
// ⚠️ 换算系数:设计稿 250px 宽 ≈ 375 视口(缩放 1.5×),下列数值 = 设计稿实测值 × 1.5 后取整。
// 页面底色沿用全站 @bg-color(#f5f6f8),与设计稿实取的 #f5f5f5 仅差 1/255,不再单独开变量。
@checkout-card: #ffffff; // 卡片底色
@checkout-line: #e1e1e1; // 订单明细卡描边(设计稿实取 (225,225,225))
@checkout-amount: #ff5500; // 合计金额(设计稿实取)
@checkout-method-active-bg: #d3f3df; // 选中支付行底色(设计稿实取)
@checkout-wechat: #28c445; // 微信绿(图标 / 选中 radio)
@checkout-alipay: #1677ff; // 支付宝蓝(图标)
@checkout-cta: #f59e0b; // 确认支付按钮(设计稿实取)
@checkout-cta-hover: #e08d05; // 确认支付 hover
@checkout-muted: #9b9b9b; // 次级说明文字(设计稿实取)
@checkout-radio-idle: #c8c8c8; // 未选 radio 圆圈描边
// 项目详情 → 合同财务(付款计划卡 / 发票·合同文件行)
// 来源 docs/prototype/项目中心-演示.html 第 1 帧(与 @project-accent 同源,换算同为 设计px × 0.77)。
// ⚠️ 小字号例外:设计 12px/10px 折算后是 9/8px(不可读),按可读下限取 12/11px,不跟缩。
@finance-card-width: 508px; // 计划卡定宽(设计 660px),竖排左对齐
@finance-card-radius: 12px; // 计划卡圆角(设计 15px ≈ @radius-lg)
@finance-card-shadow: 0 0 2px rgba(0, 0, 0, 0.4); // 计划卡阴影(设计 0 0 3px rgba(0,0,0,.4)),设计稿无描边
@finance-meta-color: #6c6c6c; // 字段行「账单号:/ 关联项目:」(设计实取 (108,108,108))
@finance-row-bg: #f8fafc; // 发票/合同文件行底色(设计实取 (248,250,252),无描边无圆角)
@finance-paid: #22c55e; // 已支付:徽标实底 + 金额文字
@finance-pending: #f59e0b; // 待支付:徽标实底 + 「立即支付」按钮底色(设计实取)
@finance-pending-amount: #f6ab29; // 待支付:金额文字(设计实取,比徽标略暖)
@finance-idle: #9a9a9a; // 未到期:徽标实底 + 金额文字(设计实取)
@finance-cta-hover: #e08d05; // 「立即支付」hover(与收银台 CTA 同色系)

2
src/types/api.d.ts vendored
View File

@@ -28,4 +28,6 @@ export interface FileInfo {
size: number
uploadTime: string
url: string
/** OSS 主键(同源预览用);本地未上传文件为 undefined */
ossId?: string | number
}

View File

@@ -1,7 +1,86 @@
// 财务中心实体(账单/支付/发票/抬头/统计)
/** 账单状态 */
export type BillStatus = '待支付' | '已支付' | '未到期'
/**
* 账单状态(页面展示口径,中文)。
*
* <p>与后端 {@code vm_bill.status} 的英文编码(见 `BillStatusCode`)是多对一的展示映射,
* 转换收口在 `utils/billVo.ts#toBillStatus`。</p>
*/
export type BillStatus = '待支付' | '已支付' | '未到期' | '已取消'
/** 后端账单状态编码(`vm_bill.status` 值域) */
export type BillStatusCode = 'UNSTARTED' | 'PENDING' | 'PAID' | 'CANCELLED'
/**
* 后端账单视图对象 —— `GET /web/bill/list` 的响应元素(对应后端 `BillVo`)。
*
* <p>金额单位 **元**;`id` 为雪花 id 时 JSON 里是字符串,小 id 是数字,一律 `String(id)` 归一。</p>
*/
export interface BillVo {
id: number | string
billNo?: string
projectId?: number | string
projectNo?: string
projectName?: string
userId?: number | string
amount?: number
/** 业务类型字典值:internet_software / hardware_circuit / iot_solution / manufacturing */
bizType?: string
/** 账单类型:ADVANCE / PROGRESS / FINAL / OTHER */
billType?: string
/** 期次名,如「第2期·尾款」(页面「账单名称」列) */
periodName?: string
/** 占项目总额比例(%) */
periodRatio?: number
status?: BillStatusCode | string
/** 支付通道:WECHAT / ALIPAY / BANK / WORKWX */
payChannel?: string
/** 交易流水号(支付成功回写) */
transactionNo?: string
/** 交易名称(支付时回传) */
tradeName?: string
payTime?: string
dueDate?: string
createTime?: string
}
/**
* 后端分期计划视图对象(`vm_bill_plan` + 该期支付单信息,对应 `BillPlanVo`)。
*
* <p>由项目详情「合同财务」那个现成接口 `GET /web/requirementPool/get/finance/{需求id}`
* 的 `billPlans` 下发;收银台「订单明细」复用同一份数据
* (见 `api/finance.ts#getCheckoutBills`),没有再单开端点。</p>
*
* <p>⚠️ 本栈支付口径是**计划 id**:`vm_finance_payment.bill_id` 实测存的就是 `vm_bill_plan.id`,
* 所以收银台 `?ids=` 传的是这个 `id`,而不是 `vm_bill.id`。</p>
*/
export interface BillPlanVo {
id: number | string
projectId?: number | string
projectNo?: string
projectName?: string
userId?: number | string
bizType?: string
/** ⚠️ BigDecimal ⇒ 后端以**字符串**下发("120000.00"),用前必须 `Number()` 归一 */
totalAmount?: number | string
/** 分期总期数 */
periods?: number
/** 本期期次序号(1 起) */
currentPeriod?: number
/** 每期比例规则,如 "40,30,30" */
periodRule?: string
nextDate?: string
/** 计划状态:ACTIVE / DONE / CLOSED / UNSTARTED */
status?: string
/** 该期支付单的商户订单号(页面「账单号」用它);未支付为空 */
orderNo?: string
transactionNo?: string
payChannel?: string
payAmount?: number | string
/** 支付单状态:CREATED / SUCCESS / FAILED / REFUNDED */
payStatus?: string
payTime?: string
}
/** 发票状态 */
export type InvoiceStatus = '已开票' | '开票中' | '未开票'
@@ -9,17 +88,44 @@ export type InvoiceStatus = '已开票' | '开票中' | '未开票'
/** 支付方式 */
export type PayMethod = 'wechat' | 'alipay'
/** 账单 */
/**
* 后端统一支付响应 `org.dromara.pay.domain.PaymentResponse`(字段名与后端一致)。
*
* <p>扫码(Native)渠道回 `codeUrl`;支付宝电脑网站支付回 `formBody`
* ——注意该字段名虽叫 formBody,实测后端走 `pageExecute(request, "GET")`,
* 内容其实是**收银台跳转地址**而非 HTML 表单,前端两种形态都兜住。</p>
*/
export interface PayOrderResponse {
/** 支付渠道:WECHAT / ALIPAY / UNIONPAY */
channel: string
/** 支付场景:JSAPI / NATIVE / WEB_SCAN / WEB_PAGE / APP */
scene: string
/** 扫码支付二维码内容:微信 Native 为 code_url,支付宝订单码为 qr_code */
codeUrl?: string
/** 支付宝电脑网站支付返回的收银台跳转地址 / HTML 表单 */
formBody?: string
/** 支付宝 APP 支付订单串 */
orderString?: string
/** H5 支付链接 */
h5Url?: string
}
/** 账单(页面视图模型;由 `utils/billVo.ts#toBill` 从后端 `BillVo` 映射而来) */
export interface Bill {
id: string
/** 分期计划名,如 '第一期·预付款 比例33%' */
/** 分期计划名,如 '第一期·预付款 比例33%'(后端 `periodName`) */
planName: string
billNo: string
/** 交易流水号(后端 `transactionNo`) */
tradeNo?: string
projectName: string
amount: number
status: BillStatus
createTime: string
/** 支付通道英文编码(WECHAT / ALIPAY / BANK / WORKWX) */
payChannel?: string
/** 支付时间(后端 `payTime`) */
payTime?: string
}
/** 账单查询条件 */

View File

@@ -122,7 +122,22 @@ export interface TeamMember {
/** 项目账单(付款计划) */
export interface ProjectBill {
id: string
/**
* 数据来源:
* - `bill` = 真账单(`vm_bill`)—— `id` 是账单 id,可跳收银台支付;
* - `plan` = 仅付款计划(`vm_bill_plan`)—— 后端未重启(响应无 `bills`)时的只读回落,
* **没有账单 id,不可支付**(计划 id 与账单 id 值域重叠,拿来当收银台入参会查到别的账单)。
*/
source: 'bill' | 'plan'
planName: string
/**
* 期次占比(%),已归一成**可展示文本**(如 `"30"`;非整数比例保留一位小数)。
*
* <p>数据源:账单 `vm_bill.period_ratio`(BigDecimal,字符串下发),回落卡则由
* `periodRule` 第 N 段折算。设计稿卡片标题是「第2期·进度款 比例30%」⇒ 标题 = 期次名 + 比例。
* 缺失/非法时为空串,模板据此不渲染「比例」片段。</p>
*/
periodRatio?: string
billNo: string
amount: number
status: '待支付' | '已支付' | '未到期'
@@ -337,6 +352,39 @@ export interface RequirementMember {
isOwner: boolean | null
}
/**
* 账单(后端 `Bill`,表 `vm_bill`)—— 「合同财务」页签卡片的**数据源**。
*
* <p>设计稿卡片上的字段(「账单号」+ 已支付/待支付/未到期)只有这张表才有;
* 且 `id` 直接就是收银台 `?ids=` 要传的**账单 id**(不是付款计划 id)。</p>
*/
export interface RequirementBill {
id: string
billNo: string | null
projectId: string | null
projectNo: string | null
projectName: string | null
userId: string | null
/** ⚠️ BigDecimal ⇒ 后端以**字符串**下发("24000.00"),用前必须 `Number()` 归一 */
amount: string | number | null
contractId: string | null
/** ADVANCE / PROGRESS / FINAL / OTHER */
billType: string | null
/** 期次名,如「第2期·进度款」 */
periodName: string | null
/** 占项目总额比例(%),如 40.00 */
periodRatio: string | number | null
/** UNSTARTED / PENDING / PAID / CANCELLED */
status: string | null
payChannel: string | null
transactionNo: string | null
tradeName: string | null
payTime: string | null
dueDate: string | null
/** 创建时间(继承 BaseEntity,后端会下发) */
createTime?: string | null
}
/** 付款计划(后端 `BillPlan`,表 `vm_bill_plan`) */
export interface RequirementBillPlan {
id: string
@@ -350,6 +398,21 @@ export interface RequirementBillPlan {
periodRule: string | null
nextDate: string | null
status: string | null
/**
* 该期支付单的商户订单号(`vm_finance_payment.out_trade_no`)——
* **页面「账单号」就显示它**;未支付(该期还没下过单)时为 null。
*/
orderNo: string | null
/** 支付成功回写的交易流水号(`vm_finance_payment.transaction_no`);未支付为 null */
transactionNo: string | null
/** 支付通道:WECHAT/ALIPAY/...;未支付为 null */
payChannel: string | null
/** 该期实付金额(元);未支付为 null */
payAmount: string | number | null
/** 支付单状态:CREATED/SUCCESS/FAILED/REFUNDED;未支付为 null */
payStatus: string | null
/** 支付时间;未支付为 null */
payTime: string | null
}
/** 发票(后端 `Invoice`,表 `vm_invoice`) */
@@ -456,8 +519,17 @@ export interface RequirementFilesVo extends RequirementBaseVo {
requirementAttachments: RequirementAttachment[]
}
/** 「合同财务」页签(后端 `ProjectRequirementFinanceVo`):基础信息 + 付款计划 + 发票 + 合同 */
/**
* 「合同财务」页签(后端 `ProjectRequirementFinanceVo`):基础信息 + 账单 + 付款计划 + 发票 + 合同。
*
* <p><b>卡片优先用 `bills`</b>(真账单:账单号 / 本期金额 / 真状态 / 可支付 id)。
* 该字段**只在含「账单下发」改动的后端构建里才有**——运行态若是旧构建,响应里根本没有
* 这个 key,所以声明成可选,由 `utils/projectVo.ts` 判别后回落到 `billPlans`(只读卡片)。</p>
*/
export interface RequirementFinanceVo extends RequirementBaseVo {
/** 真账单(`vm_bill`);**旧构建不下发该字段(undefined)**,新构建即使无账单也会给空数组 */
bills?: RequirementBill[]
/** 付款计划(`vm_bill_plan`):分期规则,作为 `bills` 缺失时的只读回落 */
billPlans: RequirementBillPlan[]
invoices: RequirementInvoice[]
contracts: RequirementContract[]

136
src/utils/billVo.ts Normal file
View File

@@ -0,0 +1,136 @@
import type { Bill, BillStatus, BillVo } from '@/types/business/finance'
/**
* 后端账单实体 → 页面视图模型的映射收口。
*
* <p>后端 `vm_bill` 用的是英文状态编码(`UNSTARTED/PENDING/PAID/CANCELLED`)与
* `periodName` / `transactionNo` 等字段名,页面用的是中文状态与 `planName` / `tradeNo`,
* 两侧口径的换算**只在这里做**,页面与 store 一律只认 `Bill`。</p>
*/
/** 后端状态编码 → 页面中文状态 */
const STATUS_TEXT: Record<string, BillStatus> = {
UNSTARTED: '未到期',
PENDING: '待支付',
PAID: '已支付',
CANCELLED: '已取消',
}
/**
* 后端状态编码 → 页面中文状态。
*
* <p>值域外(历史脏数据,如早期 `updateBillStatus` 硬编码写进去的 `'1'`)一律回落
* 「未到期」——宁可少标一个「待支付」按钮,也不要在页面上渲染出 `undefined`。</p>
*/
export function toBillStatus(code?: string): BillStatus {
if (!code) return '未到期'
return STATUS_TEXT[code] ?? '未到期'
}
/** 单条账单映射;入参为空时返回 null(便于 `?ids=` 查不到时直接判空) */
export function toBill(vo: BillVo): Bill {
return {
// 雪花 id 序列化成 string、小 id 是 number ⇒ 统一归一成 string
id: String(vo.id),
planName: vo.periodName ?? '',
billNo: vo.billNo ?? '',
tradeNo: vo.transactionNo,
projectName: vo.projectName ?? '',
amount: Number(vo.amount ?? 0),
status: toBillStatus(vo.status),
// 后端 Jackson 已按 yyyy-MM-dd HH:mm:ss 序列化,直接透出
createTime: vo.createTime ?? '',
payChannel: vo.payChannel,
payTime: vo.payTime,
}
}
/** 列表映射(过滤掉没有 id 的异常行) */
export function toBills(list: BillVo[] | null | undefined): Bill[] {
if (!list || list.length === 0) return []
return list.filter((item) => item != null && item.id != null).map(toBill)
}
/**
* 后端**计划**状态编码(`vm_bill_plan`)→ 页面中文状态。
*
* <p>为什么不能复用 {@link toBillStatus}:计划表用的是 `DONE/ACTIVE`,账单表用的是
* `PAID/PENDING`,值域不重合——代跑会把「已支付」「待支付」全落成「未到期」。
* (`utils/projectVo.ts` 里那份私有的 `toBillStatus` 是同一口径的另一种写法。)</p>
*/
export function toPlanStatus(code?: string | null): BillStatus {
const value = (code ?? '').toUpperCase()
if (value === 'DONE' || value === 'PAID' || value === 'COMPLETED') return '已支付'
if (value === 'ACTIVE' || value === 'PENDING') return '待支付'
if (value === 'CLOSED' || value === 'CANCELLED') return '已取消'
return '未到期'
}
/**
* 「付款计划」字段子集 —— 入参用**宽松结构**,两种声明都能喂进来。
*
* <p>同一张 `vm_bill_plan` 在前端有两份声明:`types/business/finance.d.ts#BillPlanVo`
* (字段可选)与 `types/business/project.d.ts#RequirementBillPlan`(字段可空 `| null`)。
* 后者赋给前者会被 TS 的可空检查拦下(`string | null` 不能给 `string | undefined`),
* 故映射函数统一收这个子集类型,两边都满足。</p>
*/
export interface PlanLike {
id?: number | string | null
projectName?: string | null
totalAmount?: number | string | null
periods?: number | null
currentPeriod?: number | null
periodRule?: string | null
status?: string | null
orderNo?: string | null
transactionNo?: string | null
payChannel?: string | null
payStatus?: string | null
payTime?: string | null
}
/** 期次名,与后端 `BillServiceImpl#buildPeriodName` 同口径:首期预付款、末期尾款、中间进度款 */
function periodLabel(period: number, periods: number): string {
if (period <= 1) return '预付款'
if (period >= periods) return '尾款'
return '进度款'
}
/**
* 分期计划(`vm_bill_plan`)→ 账单/订单条目。
*
* <p>两处都消费它:项目详情「合同财务」卡片(`utils/projectVo.ts#toPlanCard`)与收银台
* 「订单明细」(`api/finance.ts`,数据来自 `GET /web/requirementPool/get/finance/{需求id}`
* 下发的 `billPlans`)。期次名与金额按后端 `BillServiceImpl#resolveRatio/resolveAmount`
* 同口径折算;「账单号」取该期支付单的 `out_trade_no`(未支付为空 ⇒ 页面显示「—」)。</p>
*/
export function toPlanBill(vo: PlanLike): Bill {
const period = vo.currentPeriod ?? 0
const periods = vo.periods ?? 0
const rule = (vo.periodRule ?? '').split(',').map((part) => Number(part.trim()))
const segment = rule[period - 1]
const ratio = Number.isFinite(segment) ? segment : periods > 0 ? 100 / periods : 0
const total = Number(vo.totalAmount ?? 0)
// 支付成功以支付单为准:计划状态可能没被回调推到 DONE,但流水已经是 SUCCESS
const paid = (vo.payStatus ?? '').toUpperCase() === 'SUCCESS'
return {
id: String(vo.id ?? ''),
planName: period > 0 && periods > 0
? `第${period}期·${periodLabel(period, periods)}`
: (vo.periodRule ?? '付款计划'),
billNo: vo.orderNo ?? '',
tradeNo: vo.transactionNo ?? undefined,
projectName: vo.projectName ?? '',
amount: Math.round(total * ratio) / 100,
status: paid ? '已支付' : toPlanStatus(vo.status),
createTime: '',
payChannel: vo.payChannel ?? undefined,
payTime: vo.payTime ?? undefined,
}
}
/** 计划列表映射(过滤掉没有 id 的异常行) */
export function toPlanBills(list: readonly PlanLike[] | null | undefined): Bill[] {
if (!list || list.length === 0) return []
return list.filter((item) => item != null && item.id != null).map(toPlanBill)
}

94
src/utils/fileMeta.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* 文件「类型标识」的唯一口径(后缀 → 标识色 / 缩写)。
*
* <p>设计稿里文件行左侧是一个按类型着色的标识块(PDF 红、Word 蓝、Excel 绿…),
* 需求提报页(`views/demand/cpns/DemandAttachments.vue`)已按这套色板实现,
* 项目详情「合同财务」的发票/合同行沿用同一份,避免两处各写一张色表后走偏。</p>
*
* <p>用法:`fileToneOf(file)` 取色、`fileLabelOf(file)` 取缩写,把它们放到
* `style="{ color, backgroundColor: color + '1a' }"`(1a ≈ 10% 透明)即可。</p>
*/
/** 文件标识色(设计稿口径)。未命中的后缀统一走中性灰。 */
const FILE_TONES: Record<string, string> = {
pdf: '#f54a45',
doc: '#2f6bff',
docx: '#2f6bff',
xls: '#1f9d55',
xlsx: '#1f9d55',
csv: '#1f9d55',
ppt: '#f08a24',
pptx: '#f08a24',
zip: '#f08a24',
rar: '#f08a24',
'7z': '#f08a24',
png: '#7a3fd6',
jpg: '#7a3fd6',
jpeg: '#7a3fd6',
gif: '#7a3fd6',
webp: '#7a3fd6',
}
/** 缩写展示(长后缀截断,压缩包统一 ZIP) */
const FILE_LABELS: Record<string, string> = {
doc: 'DOC',
docx: 'DOC',
xls: 'XLS',
xlsx: 'XLS',
csv: 'CSV',
ppt: 'PPT',
pptx: 'PPT',
zip: 'ZIP',
rar: 'ZIP',
'7z': 'ZIP',
}
/**
* 文件对象的**最小**形状:详情页/提报页/消息页的 `FileInfo` 都能满足。
*
* <p>`type` 是需求提报页带上来的后缀(可能带前导点 `.pdf`,后端 `fileSuffix` 口径如此);
* `name` / `originalName` 二者取其一(提报页用 `originalName`,详情页用 `name`)。</p>
*
* <p>⚠️ `url` 是**最后**的兜底:合同财务页的发票行,展示名取的是发票抬头
* (如「贵州命勤科技有限公司」)而不是文件名 ⇒ 只能从 `invoiceFile` 地址上认后缀,
* 否则那一行的类型标识会全落成灰底 `FILE`。</p>
*/
export interface FileLike {
name?: string | null
originalName?: string | null
type?: string | null
url?: string | null
}
/** 取后缀(小写、无前导点);优先显式 `type`,其次文件名,最后从 url 猜 */
export function fileSuffixOf(file: FileLike | string): string {
if (typeof file === 'string') return suffixFromName(file)
const type = (file.type ?? '').replace(/^\./, '').toLowerCase()
if (type) return type
const fromName = suffixFromName(file.name ?? file.originalName ?? '')
if (fromName) return fromName
return suffixFromUrl(file.url ?? '')
}
function suffixFromName(name: string): string {
const dot = name.lastIndexOf('.')
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''
}
/** 从地址认后缀:先剥掉 query/hash,再取最后一段的扩展名(带签名参数的 OSS 直链常见) */
function suffixFromUrl(url: string): string {
const path = url.split('?')[0].split('#')[0]
const last = path.slice(path.lastIndexOf('/') + 1)
return suffixFromName(last)
}
/** 标识色(未命中后缀 → 中性灰 `#8a94a6`) */
export function fileToneOf(file: FileLike | string): string {
return FILE_TONES[fileSuffixOf(file)] ?? '#8a94a6'
}
/** 缩写(未命中 → 后缀前 3 位大写;连后缀都没有 → `FILE`) */
export function fileLabelOf(file: FileLike | string): string {
const suffix = fileSuffixOf(file)
return FILE_LABELS[suffix] ?? (suffix ? suffix.slice(0, 3).toUpperCase() : 'FILE')
}

52
src/utils/filePreview.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* 预览类型判定(纯函数,无副作用,便于单测与复用)。
*
* <p>为什么集中在一个文件:前端多处 `FileList` 预览都按「后缀 → 渲染方式」分流,
* 统一这里避免每个页面各写一套正则 / 后缀表。后端同源预览端点(`/web/workOrderDraft/preview/{ossId}`)
* 按后缀纠正 Content-Type,这里与之后端口径保持一致的归类。</p>
*/
export type PreviewKind = 'image' | 'pdf' | 'video' | 'audio' | 'text' | 'unsupported'
const IMAGE_SUFFIXES = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']
const PDF_SUFFIXES = ['pdf']
const VIDEO_SUFFIXES = ['mp4', 'webm', 'mov', 'avi', 'm4v']
const AUDIO_SUFFIXES = ['mp3', 'wav', 'ogg', 'm4a']
// 文本类:含 html/htm/svg —— 统一按纯文本展示源码,避免内联渲染触发存储型 XSS
const TEXT_SUFFIXES = ['txt', 'sql', 'log', 'csv', 'json', 'xml', 'md', 'yml', 'yaml', 'html', 'htm', 'svg']
/** 取小写字尾(不含点);无点返回空串 */
export function getSuffix(name: string): string {
const idx = name.lastIndexOf('.')
return idx >= 0 ? name.slice(idx + 1).toLowerCase() : ''
}
/** 按文件名判定预览类型;office / 未知后缀一律 unsupported(前端不内联,给下载) */
export function resolvePreviewKind(name: string): PreviewKind {
const ext = getSuffix(name)
if (IMAGE_SUFFIXES.includes(ext)) return 'image'
if (PDF_SUFFIXES.includes(ext)) return 'pdf'
if (VIDEO_SUFFIXES.includes(ext)) return 'video'
if (AUDIO_SUFFIXES.includes(ext)) return 'audio'
if (TEXT_SUFFIXES.includes(ext)) return 'text'
return 'unsupported'
}
/** 是否可被前端内联预览(true = image/pdf/video/audio/text) */
export function isPreviewable(name: string): boolean {
return resolvePreviewKind(name) !== 'unsupported'
}
/**
* 文本内联预览上限(1MB)。
* 整文件塞进 `<pre>` 会卡死浏览器(如 5MB+ 的 .sql/.log),故只读取前 1MB 渲染,超出截断并提示下载。
*/
export const PREVIEW_TEXT_MAX_BYTES = 1024 * 1024
/** 人类可读体积(B/KB/MB/GB) */
export function formatBytes(bytes: number): string {
if (!bytes || bytes < 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)))
const val = bytes / Math.pow(1024, i)
return `${i === 0 ? val : val.toFixed(1)} ${units[i]}`
}

68
src/utils/payMeta.ts Normal file
View File

@@ -0,0 +1,68 @@
import type { PayMethod } from '@/types/business/finance'
/** 收银台「选择支付方式」条目 */
export interface PayMethodMeta {
/** 前端标识,与 `PayMethod` 同源 */
value: PayMethod
/** 展示名 */
label: string
/** 副标题(设计稿:微信 = 推荐使用 / 支付宝 = 其他用户选择) */
hint: string
/** 后端 `PayChannelEnum` 取值 */
channel: 'WECHAT' | 'ALIPAY'
}
/** 支付方式展示元数据(顺序即收银台渲染顺序,默认选中第一项) */
export const PAY_METHODS: PayMethodMeta[] = [
{ value: 'wechat', label: '微信支付', hint: '推荐使用', channel: 'WECHAT' },
{ value: 'alipay', label: '支付宝支付', hint: '其他用户选择', channel: 'ALIPAY' },
]
/** 前端支付方式 → 后端渠道枚举 */
export function toPayChannel(method: PayMethod): PayMethodMeta['channel'] {
return PAY_METHODS.find((item) => item.value === method)?.channel ?? 'WECHAT'
}
/**
* 后端渠道编码 → 展示名。
*
* <p>同一份语义在两张表里大小写不一致(`vm_bill.pay_channel` 存 `WECHAT`,
* `vm_finance_payment.pay_channel` 存 `wechat`),这里统一 `toUpperCase` 兜住。</p>
*/
const CHANNEL_LABELS: Record<string, string> = {
WECHAT: '微信支付',
ALIPAY: '支付宝支付',
BANK: '银行转账',
WORKWX: '企业微信',
}
/** 后端渠道编码 → 中文展示名;未知编码原样返回,空值返回「—」 */
export function toChannelLabel(code?: string): string {
if (!code) return '—'
return CHANNEL_LABELS[code.toUpperCase()] ?? code
}
/** 后端渠道编码 → 前端 `PayMethod`(用于支付结果页);无法识别时按微信兜底 */
export function toPayMethod(code?: string): PayMethod {
return code?.toUpperCase() === 'ALIPAY' ? 'alipay' : 'wechat'
}
function pad(value: number, length = 2): string {
return String(value).padStart(length, '0')
}
/**
* 生成商户订单号。
*
* <p>后端没有「生成订单号」的接口(Native / 支付宝都要求调用方传 `outTradeNo`),
* 这里按后端 `SequenceUtils.getDateId("PAY")` 的同款口径在前端生成:
* 形如 `PAY202609281530120731` = `PAY` + yyyyMMddHHmmss + 4 位随机。</p>
*
* <p>微信 Native 与支付宝的 `out_trade_no` 都要求 **6–32 位**、同一商户号下唯一,
* 19 位满足要求;`PAY` 前缀也与后端「PAY…」的既有流水号风格一致,便于对账。</p>
*/
export function createMerchantOrderNo(now: Date = new Date()): string {
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
+ `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
return `PAY${stamp}${pad(Math.floor(Math.random() * 10000), 4)}`
}

View File

@@ -7,6 +7,7 @@ import type {
ProjectStatus,
RequirementAfterSale,
RequirementAttachment,
RequirementBill,
RequirementBillPlan,
RequirementContract,
RequirementDetailBundle,
@@ -27,7 +28,7 @@ import { PROJECT_STATUS_LABEL } from '@/utils/projectMeta'
* (需求表同一行),这里统一取 `bundle.progress` 那一支当基础信息(UI 上它最先渲染)。</p>
*
* <p><b>未立项是常态</b>:需求在 `vm_rd_project` 里没有对应行时,后端返回的
* `projectStage` / `projectMilestones` / `members` / `billPlans` / `invoices` / `contracts`
* `projectStage` / `projectMilestones` / `members` / `bills` / `billPlans` / `invoices` / `contracts`
* 全为空 —— 这里一律兜底成空数组 / 0 / undefined,详情页显示为空态,不报错。</p>
*/
@@ -107,6 +108,20 @@ function toDate(value: string | null | undefined): string | undefined {
return value ? value.slice(0, 10) : undefined
}
/**
* 期次占比 → 可展示文本。
*
* <p>后端 `period_ratio` / 计划 `periodRule` 都是「百分数」语义(30 表示 30%),但一个是
* BigDecimal 字符串(`"40.00"`)、一个是逗号串里的一段(`"40,30,30"` 的第 2 段 `"30"`)⇒
* 统一走 Number 归一。设计稿卡片标题写的是「比例33%」这种整数口径,非整数(按 3 期均分的
* 33.33)保留一位小数,避免出现 `比例33.333333%`。</p>
*/
function toRatioText(value: string | number | null | undefined): string {
const num = Number(value)
if (!Number.isFinite(num) || num <= 0) return ''
return Number.isInteger(num) ? String(num) : String(Math.round(num * 10) / 10)
}
/** 需求附件 → 详情页文件项(文件 tab 用) */
function attachmentToFile(item: RequirementAttachment): FileInfo {
return {
@@ -114,6 +129,8 @@ function attachmentToFile(item: RequirementAttachment): FileInfo {
size: item.fileSize ?? 0,
url: item.url ?? '',
uploadTime: item.createTime ?? '',
// 透传 ossId,供详情页「预览」走同源后端接口(绕开 OSS 桶强制下载)
ossId: item.ossId ?? undefined,
}
}
@@ -139,22 +156,73 @@ function contractToFile(item: RequirementContract): FileInfo {
}
/**
* 付款计划。
* 账单(`vm_bill`)→ 详情页「付款计划」卡片。
*
* <p>`BillPlan` 没有「单据号」也没有「期次名」,所以:`planName` 用「第 N 期/共 M 期」
* (**期次优先**,`periodRule` 的 '30,30,40' 只作兜底——直接把比例当名字显示不可读),
* `billNo` 回落到 `projectNo`。</p>
* <p><b>这是卡片的正解数据源。</b>设计稿卡片上的「账单号」与三态「已支付/待支付/未到期」
* 只有 `vm_bill` 才有:`billNo` 是真账单号、`amount` 是**本期**金额、
* `status` 是账单自己的 `PAID/PENDING/UNSTARTED`,而 `id` 正是收银台 `?ids=` 要传的
* **账单 id**,所以卡片上的「立即支付」可以直接跳过去。</p>
*
* <p>期次名直接用后端下发的 `periodName`(如「第2期·进度款」)——不再像过去那样
* 从付款计划拼「第N期/共M期」。</p>
*/
function toProjectBill(item: RequirementBillPlan): ProjectBill {
const period = item.currentPeriod != null && item.periods != null
? `第${item.currentPeriod}期/共${item.periods}期`
: ''
function toProjectBill(item: RequirementBill): ProjectBill {
return {
id: String(item.id),
planName: period || item.periodRule || '付款计划',
billNo: item.projectNo ?? '',
amount: toNumber(item.totalAmount),
source: 'bill',
planName: item.periodName ?? '',
// 设计稿标题「第2期·进度款 比例30%」= 期次名 + 期次占比(后端 period_ratio)
periodRatio: toRatioText(item.periodRatio),
billNo: item.billNo ?? '',
amount: toNumber(item.amount),
status: toBillStatus(item.status),
// 后端存的是 transactionNo,页面叫 tradeNo(详见 types/business/finance.d.ts 的同一处映射)
tradeNo: item.transactionNo ?? undefined,
projectName: item.projectName ?? '',
}
}
/** 期次名,与后端 `BillServiceImpl#buildPeriodName` 同口径:首期预付款、末期尾款、中间进度款 */
function planPeriodLabel(period: number, periods: number): string {
if (period <= 1) return '预付款'
if (period >= periods) return '尾款'
return '进度款'
}
/**
* 分期计划(`vm_bill_plan`)→ 卡片。**这是「合同财务」卡片的数据源。**
*
* <p>为什么是计划而不是账单:本栈的支付口径就是**计划 id** ——
* `vm_finance_payment.bill_id` 实测存的是 `vm_bill_plan.id`(该列 5 个取值在
* `vm_bill_plan` 全命中、在 `vm_bill` 只命中 2 个)。所以卡片「立即支付」传的也是计划 id,
* 下单落库、回调回写全都对得上,链路自洽。</p>
*
* <p>字段来源:期次名/比例/金额由 `currentPeriod` + `periodRule` 折算(与后端
* `BillServiceImpl#resolveRatio/resolveAmount` 同口径);「账单号」= 该期支付单的
* `out_trade_no`(后端别名 `orderNo`,未支付时为空 ⇒ 模板显示「—」);
* 「交易流水号」= `transactionNo`;状态优先看支付单(`payStatus=SUCCESS` 即已支付),
* 否则用计划状态(`DONE/ACTIVE/UNSTARTED`)。</p>
*/
function toPlanCard(item: RequirementBillPlan): ProjectBill {
const period = item.currentPeriod ?? 0
const periods = item.periods ?? 0
const rule = (item.periodRule ?? '').split(',').map((part) => Number(part.trim()))
const segment = rule[period - 1]
const ratio = Number.isFinite(segment) ? segment : periods > 0 ? 100 / periods : 0
// 支付成功以支付单为准:计划状态可能没被回调推到 DONE,但流水已经是 SUCCESS
const paid = (item.payStatus ?? '').toUpperCase() === 'SUCCESS'
return {
id: String(item.id), // 计划 id —— 与收银台 ?ids= / vm_finance_payment.bill_id 同一口径
source: 'plan',
planName: period > 0 && periods > 0
? `第${period}期·${planPeriodLabel(period, periods)}`
: (item.periodRule ?? '付款计划'),
// 比例口径与后端一致(见上方 ratio 折算),标题照样带「比例30%」
periodRatio: toRatioText(ratio),
billNo: item.orderNo ?? '',
amount: Math.round(toNumber(item.totalAmount) * ratio) / 100,
status: paid ? '已支付' : toBillStatus(item.status),
tradeNo: item.transactionNo ?? undefined,
projectName: item.projectName ?? '',
}
}
@@ -232,7 +300,16 @@ export function toProjectDetail(bundle: RequirementDetailBundle): Project {
.sort((a, b) => Number(b.isOwner ?? false) - Number(a.isOwner ?? false))
.map(toTeamMember),
files: (files.requirementAttachments ?? []).map(attachmentToFile),
paymentPlans: (finance.billPlans ?? []).map(toProjectBill),
// ⚠️ 卡片优先 = 真账单(vm_bill)。判别位是 `bills` **这个 key 在不在**:
// 新构建无论有没有账单都会下发(无账单 = `[]` ⇒ 走空态,那才是「真的没有账单」);
// 旧构建(未重启)压根没这个 key ⇒ 回落`billPlans`(只读卡,`source='plan'`,不带支付按钮)。
// 教训:只认 `bills` 会让未重启的后端上整个页签「数据凭空消失」,所以必须留回落。
// 卡片主数据源 = 付款计划(`vm_bill_plan`):本栈的支付口径就是计划 id
// (`vm_finance_payment.bill_id` 实测存的就是它)。仅当计划为空时才回落 `bills`
// (`vm_bill`),不再像上一轮那样优先读 `bills`。
paymentPlans: (finance.billPlans ?? []).length > 0
? (finance.billPlans ?? []).map(toPlanCard)
: (finance.bills ?? []).map(toProjectBill),
invoiceRecords: (finance.invoices ?? []).map(invoiceToFile),
contractFile: contract ? contractToFile(contract) : undefined,
afterSales: afterSales.afterSale

View File

@@ -7,6 +7,7 @@ import { AppButton, StatusTag } from '@/components'
import { useFinanceStore } from '@/stores/modules/finance'
import type { BillStatus } from '@/types/business/finance'
import { formatMoney } from '@/utils/format'
import { toChannelLabel } from '@/utils/payMeta'
const route = useRoute()
const router = useRouter()
@@ -15,21 +16,21 @@ const { currentBill } = storeToRefs(financeStore)
const billId = computed(() => String(route.params.id))
const STATUS_VARIANTS: Record<BillStatus, 'warning' | 'success' | 'info'> = {
const STATUS_VARIANTS: Record<BillStatus, 'warning' | 'success' | 'info' | 'danger'> = {
待支付: 'warning',
已支付: 'success',
未到期: 'info',
已取消: 'danger',
}
async function load(): Promise<void> {
// 支付信息(渠道 / 时间 / 流水号)已由后端回写在账单本身上,无需再查支付结果
await financeStore.fetchDetail(billId.value)
if (currentBill.value?.status === '已支付') {
await financeStore.fetchResult(billId.value)
}
}
function onPay(): void {
void router.push(`/finance/bills/${billId.value}/pay`)
// 进收银台(确认支付):选支付方式 → 微信出码 / 支付宝跳转
void router.push({ path: '/finance/checkout', query: { ids: billId.value } })
}
watch(billId, () => {
@@ -98,24 +99,29 @@ onMounted(() => {
<div class="bill-detail__fields">
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">付款金额</span>
<span class="bill-detail__value bill-detail__value--amount">¥{{ formatMoney(financeStore.result?.amount ?? currentBill.amount) }}</span>
<span class="bill-detail__value bill-detail__value--amount">¥{{ formatMoney(currentBill.amount) }}</span>
</div>
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">支付方式</span>
<span class="bill-detail__value">微信支付</span>
<span class="bill-detail__value">{{ toChannelLabel(currentBill.payChannel) }}</span>
</div>
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">支付时间</span>
<span class="bill-detail__value">{{ financeStore.result?.payTime ?? '—' }}</span>
<span class="bill-detail__value">{{ currentBill.payTime ?? '—' }}</span>
</div>
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">交易流水号</span>
<span class="bill-detail__value">{{ financeStore.result?.tradeNo ?? currentBill.tradeNo ?? '—' }}</span>
<span class="bill-detail__value">{{ currentBill.tradeNo ?? '—' }}</span>
</div>
</div>
</div>
</template>
<!-- 已取消 -->
<div v-else-if="currentBill.status === '已取消'" class="bill-detail__tip text-secondary">
该账单已取消,如有疑问请联系项目负责人。
</div>
<!-- 未到期:提示 -->
<div v-else class="bill-detail__tip text-secondary">
账单尚未到期,到期后可前往账单明细完成支付。

View File

@@ -50,13 +50,13 @@ function onView(id: string): void {
}
function onPay(id: string): void {
void router.push(`/finance/bills/${id}/pay`)
void router.push({ path: '/finance/checkout', query: { ids: id } })
}
function onBatchPay(ids: string[]): void {
const first = ids[0]
if (!first) return
void router.push(`/finance/bills/${first}/pay`)
if (ids.length === 0) return
// 单笔 / 多笔都进收银台(收银台按 `?ids=` 聚合出「订单明细」与合计)
void router.push({ path: '/finance/checkout', query: { ids: ids.join(',') } })
}
onMounted(() => {

View File

@@ -0,0 +1,406 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
// 页面容器:数据只经 store,不直接 import api
import { useFinanceStore } from '@/stores/modules/finance'
import type { PayMethod } from '@/types/business/finance'
import { createMerchantOrderNo, PAY_METHODS } from '@/utils/payMeta'
import { formatMoney } from '@/utils/format'
import CheckoutBillCard from './cpns/CheckoutBillCard.vue'
import PayMethodList from './cpns/PayMethodList.vue'
import WechatQrDialog from './cpns/WechatQrDialog.vue'
/**
* 收银台(确认支付)页 —— 路由 `/finance/checkout?requirementId=<需求id>[&ids=<期次id>]`。
*
* <p><b>版式(Web 两栏)</b>:面包屑 → 左栏「订单明细卡 + 选择支付方式卡」(占满剩余宽)
* + 右栏 360px **粘性**「支付金额 / 订单数量 / 支付方式 / 确认支付」;窄屏(≤992px)回落单栏。</p>
*
* <p>⚠️ 与移动端设计稿的差异(本轮改动原因):早期按「支付」移动端设计稿(250 设计px ≈ 375 视口)
* 做成一根 **480px 居中窄列 + 底部固定橙色操作条**,在 PC 上左右各留一大片空白(实测 1229 视口下
* 卡片仅占 480px)。本轮改成 Web 两栏,并同步把卡片从移动端尺度抬到桌面尺度:卡标题 14→16px
* (与同目录 `PayMethodDialog` / `FinanceChart` / `WechatQrDialog` 的 16px/600 对齐)、
* 条目内边距 12/14→16/18px、金额字号上调;**底部固定操作条取消**,CTA 移入右侧汇总栏。</p>
*
* <p><b>订单明细数据源</b>:**复用项目详情「合同财务」那个现成接口**
* `GET /web/requirementPool/get/finance/{需求id}` —— 入参是需求 id,后端把「需求 + 项目」
* 拼成联合结构后把付款计划下发在 `billPlans` 里。本栈支付口径是 `vm_bill_plan.id`,
* 故订单明细直接取这份 `billPlans`(见 `api/finance.ts#getCheckoutBills`)。</p>
*
* <p><b>支付</b>(后端 `controller/pc/pay/WebScanPayController`,类级 `@SaIgnore`):</p>
* <ul>
* <li>微信 → `POST /pay/web/wechat/native` `{orderNo, amount, billIds}` ⇒ `codeUrl` → 本页弹窗渲染二维码;</li>
* <li>支付宝 → `POST /pay/web/alipay/page` `{outTradeNo, amount, billIds}` ⇒ `formBody`
* (实测是收银台跳转地址,新标签页打开)。</li>
* </ul>
*
* <p><b>`billIds` 为什么必须下发</b>:后端在下单成功时落一条 `vm_finance_payment`
* (status = CREATED),并把期次 id 列表写进 `request_payload`;支付成功回调再按这张清单
* 为**每期**落一行 `vm_finance_payment_bill` 并把 `vm_bill_plan` 置为 DONE。
* 不带 `billIds` 时后端只落支付单,分摊明细与回写都会跳过。</p>
*/
const route = useRoute()
const router = useRouter()
const financeStore = useFinanceStore()
const { checkoutBills, checkoutLoading, payOrder } = storeToRefs(financeStore)
/** 路由带入的**需求 id**(项目详情「立即支付」跳转时带上) */
const requirementId = computed(() => {
const raw = route.query.requirementId
return String(Array.isArray(raw) ? raw[0] : (raw ?? '')).trim()
})
/** 路由**可选**带入的期次 id(`?ids=`):用于精确指定付哪一期 */
const planIds = computed(() => {
const raw = route.query.ids
const text = Array.isArray(raw) ? raw.join(',') : String(raw ?? '')
return text.split(',').map((id) => id.trim()).filter(Boolean)
})
/** 下单要带的期次清单(`billIds`):以**实际加载到的期次**为准(`vm_bill_plan.id`) */
const billIds = computed(() => checkoutBills.value.map((bill) => bill.id))
const method = ref<PayMethod>('wechat')
const submitting = ref(false)
const qrVisible = ref(false)
const totalAmount = computed(() =>
checkoutBills.value.reduce((sum, bill) => sum + Number(bill.amount || 0), 0),
)
/** 右侧汇总栏的「支付方式」展示名:与左侧选择卡同源(`PAY_METHODS`),不另写一份文案 */
const methodLabel = computed(
() => PAY_METHODS.find((item) => item.value === method.value)?.label ?? '—',
)
const canPay = computed(() => checkoutBills.value.length > 0 && !submitting.value)
async function load(): Promise<void> {
await financeStore.loadCheckout(requirementId.value, planIds.value)
}
/**
* 打开支付宝收银台。
*
* <p>后端 `pageExecute(request, "GET")` 实测返回的是**跳转地址**(`https://…/gateway.do?…`),
* 直接开新标签页即可;万一日后改回 POST 模式返回自动提交的 HTML 表单,这里也兜住。</p>
*/
function openAlipay(body: string): void {
if (/^\s*https?:\/\//i.test(body)) {
window.open(body, '_blank', 'noopener')
return
}
const holder = document.createElement('div')
holder.style.display = 'none'
holder.innerHTML = body
document.body.appendChild(holder)
const form = holder.querySelector('form')
if (form) {
form.submit()
window.setTimeout(() => holder.remove(), 0)
return
}
holder.remove()
ElMessage.error('支付宝下单返回内容无法识别')
}
async function onConfirm(): Promise<void> {
if (!canPay.value) return
submitting.value = true
try {
// 商户订单号由前端生成(后端两个下单接口都要求调用方传 outTradeNo)
const orderNo = createMerchantOrderNo()
if (method.value === 'wechat') {
await financeStore.payByWechatNative(orderNo, totalAmount.value, billIds.value)
const codeUrl = payOrder.value?.codeUrl
if (!codeUrl) {
ElMessage.error('微信下单失败:未返回二维码内容')
return
}
qrVisible.value = true
return
}
await financeStore.payByAlipayPage(orderNo, totalAmount.value, billIds.value)
const body = payOrder.value?.formBody
if (!body) {
ElMessage.error('支付宝下单失败:未返回跳转地址')
return
}
openAlipay(body)
}
catch {
// 失败原因已由 utils/request.ts 拦截器统一弹出,这里只需兜住 Promise 拒绝
}
finally {
submitting.value = false
}
}
/** 二维码过期 / 用户主动刷新:换一个新商户单号重新下单 */
async function onRefreshQr(): Promise<void> {
try {
await financeStore.payByWechatNative(createMerchantOrderNo(), totalAmount.value, billIds.value)
}
catch {
// 同上
}
}
/**
* 「我已完成支付」→ 结果页。
*
* <p>⚠️ 账单置为 PAID 由**后端异步回调**(微信/支付宝 → MQ → `WechatPayController#handleCallback`)
* 完成,用户点按钮这一刻回调可能还没到,结果页会短暂显示「暂无支付结果」,
* 刷新即可。若要做成稳定体感,需要后端提供「按订单号查支付状态」的端点供结果页轮询。</p>
*/
function onPaid(): void {
qrVisible.value = false
const first = checkoutBills.value[0]
if (!first) return
// 结果页同样按「需求 id + 期次 id」查(复用财务接口),故把需求 id 一并带过去
void router.push({
path: `/finance/bills/${first.id}/pay/result`,
query: { requirementId: requirementId.value },
})
}
function goBack(): void {
void router.push('/finance/bills')
}
watch([requirementId, planIds], () => {
void load()
}, { immediate: true })
onBeforeUnmount(() => {
financeStore.clearPayOrder()
})
</script>
<template>
<div class="container checkout">
<nav class="checkout__breadcrumb text-secondary">
<RouterLink to="/finance" class="link">
财务中心
</RouterLink>
<span> &gt; </span>
<RouterLink to="/finance/bills" class="link">
账单明细
</RouterLink>
<span> &gt; </span>
<span>确认支付</span>
</nav>
<div v-if="checkoutBills.length > 0" class="checkout__layout">
<div class="checkout__main">
<CheckoutBillCard :bills="checkoutBills" />
<PayMethodList v-model="method" />
</div>
<aside class="checkout__aside">
<section class="checkout__summary">
<h2 class="checkout__summary-title">
支付金额
</h2>
<p class="checkout__summary-amount">
¥{{ formatMoney(totalAmount) }}
</p>
<div class="checkout__summary-meta">
<div class="checkout__summary-row">
<span class="checkout__summary-label">订单数量</span>
<span class="checkout__summary-value">{{ checkoutBills.length }} 笔</span>
</div>
<div class="checkout__summary-row">
<span class="checkout__summary-label">支付方式</span>
<span class="checkout__summary-value">{{ methodLabel }}</span>
</div>
</div>
<button
class="checkout__cta"
:disabled="!canPay"
@click="onConfirm"
>
{{ submitting ? '处理中…' : '确认支付' }}
</button>
<p class="checkout__summary-tip">
支付完成后订单状态将自动更新
</p>
</section>
</aside>
</div>
<section v-else-if="!checkoutLoading" class="checkout__empty">
<p class="checkout__empty-text">
未找到待支付账单,请返回账单明细重新选择
</p>
<button class="checkout__empty-btn" @click="goBack">
返回账单明细
</button>
</section>
<WechatQrDialog
v-model:visible="qrVisible"
:content="payOrder?.codeUrl ?? ''"
:amount="totalAmount"
@refresh="onRefreshQr"
@paid="onPaid"
/>
</div>
</template>
<style lang="less" scoped>
.checkout {
// ⚠️ 第三个参数必须给 stretch:`.flex()` 的 align 默认 center,会给面包屑这类
// 内容宽子元素「压窄 + 水平居中」——实测面包屑跑到页面正中间去了。
.flex(column, flex-start, stretch);
gap: @spacing-base;
padding-top: @spacing-lg;
// 底部固定操作条已取消(Web 版 CTA 在右侧汇总栏),这里只留常规页脚呼吸位
padding-bottom: @spacing-xxl;
&__breadcrumb {
font-size: @font-size-base;
}
// Web 两栏:左「订单明细 + 选择支付方式」自适应,右 360px 汇总栏
// ⚠️ 这里**不能**写 `align-items: start` —— 那会让右栏收缩成自身内容高度,
// 侧栏里的 `position: sticky` 就没有可滑动空间,粘性会整个失效(实测踩过)。
// 保持默认 stretch,右栏与左栏等高,sticky 才有生效范围。
&__layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: @spacing-base;
width: 100%;
}
&__main {
// `.flex()` 的 align 默认 center,会让两张卡按内容宽度收缩 ⇒ 必须显式拉满
.flex(column, flex-start, stretch);
gap: @spacing-base;
min-width: 0;
}
&__aside {
min-width: 0;
}
// 汇总栏:跟随滚动(顶栏 64px 是 sticky,故留出顶栏高度 + 一档间距)
&__summary {
position: sticky;
top: calc(@header-height + @spacing-base);
.flex(column, flex-start, stretch);
gap: @spacing-base;
padding: @spacing-lg;
border-radius: @radius-lg;
background: @checkout-card;
}
&__summary-title {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__summary-amount {
font-size: @font-size-xxl;
font-weight: 700;
line-height: 1.2;
color: @checkout-amount;
word-break: break-all;
}
&__summary-meta {
.flex(column, flex-start, stretch);
gap: @spacing-sm;
padding-top: @spacing-base;
border-top: 1px solid @checkout-line;
}
&__summary-row {
.flex(row, space-between, center);
gap: @spacing-base;
font-size: @font-size-base;
}
&__summary-label {
color: @text-color-secondary;
}
&__summary-value {
color: @heading-color;
font-weight: 600;
}
&__cta {
width: 100%;
height: 44px;
border-radius: @radius-round;
background: @checkout-cta;
color: @white;
font-size: @font-size-lg;
font-weight: 600;
transition: background 0.2s;
&:hover:not(:disabled) {
background: @checkout-cta-hover;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&__summary-tip {
font-size: @font-size-sm;
color: @checkout-muted;
text-align: center;
}
&__empty {
.flex(column, center);
gap: @spacing-base;
padding: @spacing-xxl @spacing-base;
border-radius: @radius-lg;
background: @checkout-card;
}
&__empty-text {
font-size: @font-size-base;
color: @text-color-secondary;
}
&__empty-btn {
height: 36px;
padding: 0 @spacing-base;
border: 1px solid @checkout-cta;
border-radius: @radius-round;
background: @checkout-card;
color: @checkout-cta;
font-size: @font-size-base;
}
// 窄屏兜底:全站未启用响应式,这里只做「侧栏落到主栏下方」的单栏回落,
// 避免主栏被 360px 侧栏挤成一条缝。
@media (max-width: 992px) {
&__layout {
grid-template-columns: minmax(0, 1fr);
}
&__summary {
position: static;
}
}
}
</style>

View File

@@ -46,10 +46,11 @@ function toggleAll(): void {
const allSelected = () => props.bills.length > 0 && props.bills.every((b) => selection.value.has(b.id))
const STATUS_VARIANTS: Record<BillStatus, 'warning' | 'success' | 'info'> = {
const STATUS_VARIANTS: Record<BillStatus, 'warning' | 'success' | 'info' | 'danger'> = {
待支付: 'warning',
已支付: 'success',
未到期: 'info',
已取消: 'danger',
}
</script>

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Bill } from '@/types/business/finance'
import { formatMoney } from '@/utils/format'
/**
* 收银台「订单明细」卡。
*
* <p>结构:卡标题「订单明细」→ 每笔账单一条**描边内嵌行**(期次名 / 账单号 / 关联项目 + 右侧金额)
* → 底部「已选 N笔订单,合计」+ 橙色合计金额。</p>
*
* <p>多笔账单时内嵌行依次堆叠(原型只画了 1 笔,这里按同一规格扩展)。</p>
*
* <p>⚠️ 字号/内边距按 **Web(桌面)** 尺度取值,不再是移动端设计稿那套:
* 卡标题 16px/600 与同目录 `PayMethodDialog`、`FinanceChart`、`WechatQrDialog` 对齐;
* 内嵌行内边距 16×18、字段行 12px、金额 16px、合计 20px/700。</p>
*/
interface Props {
/** 待支付账单(一笔或多笔合并支付) */
bills: Bill[]
}
const props = defineProps<Props>()
/** ⚠️ 金额可能以字符串下发(BigDecimal 序列化)⇒ 必须 Number() 归一,否则 reduce 会拼字符串 */
const totalAmount = computed(() =>
props.bills.reduce((sum, bill) => sum + Number(bill.amount || 0), 0),
)
</script>
<template>
<section class="checkout-bill">
<h2 class="checkout-bill__title">
订单明细
</h2>
<ul class="checkout-bill__list">
<li v-for="bill in bills" :key="bill.id" class="checkout-bill__item">
<div class="checkout-bill__main">
<p class="checkout-bill__plan">
{{ bill.planName }}
</p>
<p class="checkout-bill__meta">
账单号:{{ bill.billNo || '—' }}
</p>
<p class="checkout-bill__meta">
关联项目:{{ bill.projectName || '—' }}
</p>
</div>
<p class="checkout-bill__amount">
¥{{ formatMoney(bill.amount) }}
</p>
</li>
</ul>
<div class="checkout-bill__total">
<span class="checkout-bill__total-label">已选 {{ bills.length }}笔订单,合计</span>
<span class="checkout-bill__total-amount">¥{{ formatMoney(totalAmount) }}</span>
</div>
</section>
</template>
<style lang="less" scoped>
.checkout-bill {
padding: @spacing-lg;
border-radius: @radius-lg;
background: @checkout-card;
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__list {
.flex(column, flex-start);
// `.flex()` 的 align 默认 center,会让内嵌行按内容宽度收缩 ⇒ 这里必须显式拉满
align-items: stretch;
gap: @spacing-sm;
margin-top: @spacing-base;
}
// 描边内嵌行(移动端设计稿实取描边 #e1e1e1、圆角约 7 设计px ≈ 10px)
&__item {
.flex(row, space-between, center);
gap: @spacing-base;
padding: 16px 18px;
border: 1px solid @checkout-line;
border-radius: 10px;
background: @checkout-card;
}
&__main {
.flex(column, flex-start);
// 同上:期次名 / 账单号 / 关联项目 必须左对齐,不能居中
align-items: flex-start;
gap: @spacing-xs;
min-width: 0;
}
&__plan {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__meta {
font-size: @font-size-sm;
color: @checkout-muted;
.ellipsis();
}
&__amount {
flex-shrink: 0;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__total {
.flex(row, space-between, center);
gap: @spacing-base;
margin-top: @spacing-base;
}
&__total-label {
font-size: @font-size-sm;
color: @text-color;
}
&__total-amount {
font-size: @font-size-xl;
font-weight: 700;
color: @checkout-amount;
}
}
</style>

View File

@@ -0,0 +1,225 @@
<script setup lang="ts">
import type { PayMethod } from '@/types/business/finance'
import { PAY_METHODS } from '@/utils/payMeta'
/**
* 收银台「选择支付方式」卡。
*
* <p>结构:卡头「选择支付方式」+ 下方两行支付方式**满宽铺满卡片**(选中行整行底色点亮,
* 微信 = 浅绿),右侧圆形单选钮。</p>
*
* <p>⚠️ 卡头**不带序号徽标**:早期按移动端设计稿做过一个黄色方形「1」,
* 与原型 `docs/prototype/财务中心-演示.html` 的「选择支付方式」弹窗(只有标题 + × 关闭)
* 不一致,已按用户要求删除。</p>
*
* <p>⚠️ 字号/尺寸按 **Web(桌面)** 尺度取值:卡标题 16px/600(与同目录 `PayMethodDialog` 对齐)、
* 行高 72px、图标 40px、单选钮 20px —— 不再沿用移动端设计稿那套 57px 行高 / 32px 图标。</p>
*/
interface Props {
/** 当前选中的支付方式 */
modelValue: PayMethod
}
const props = defineProps<Props>()
const emit = defineEmits<{ 'update:modelValue': [method: PayMethod] }>()
function select(value: PayMethod): void {
if (value === props.modelValue) return
emit('update:modelValue', value)
}
</script>
<template>
<section class="pay-method">
<div class="pay-method__head">
<h2 class="pay-method__title">
选择支付方式
</h2>
</div>
<ul class="pay-method__list">
<li
v-for="item in PAY_METHODS"
:key="item.value"
class="pay-method__row"
:class="[`pay-method__row--${item.value}`, { 'is-active': item.value === modelValue }]"
role="radio"
:aria-checked="item.value === modelValue"
:aria-label="item.label"
tabindex="0"
@click="select(item.value)"
@keydown.enter="select(item.value)"
@keydown.space.prevent="select(item.value)"
>
<span class="pay-method__icon" :class="`pay-method__icon--${item.value}`" aria-hidden="true">
<!-- 微信:绿底白双气泡 -->
<svg
v-if="item.value === 'wechat'"
class="pay-method__logo"
viewBox="0 0 32 32"
aria-hidden="true"
>
<rect class="pay-method__bubble" x="2.5" y="4.5" width="17" height="14" rx="7" />
<rect
class="pay-method__bubble-front"
x="12.5"
y="12.5"
width="17"
height="15"
rx="7.5"
/>
<circle class="pay-method__eye" cx="8.5" cy="11.5" r="1.5" />
<circle class="pay-method__eye" cx="14.5" cy="11.5" r="1.5" />
<circle class="pay-method__eye" cx="19" cy="20" r="1.3" />
<circle class="pay-method__eye" cx="24" cy="20" r="1.3" />
</svg>
<!-- 支付宝:蓝底白「支」 -->
<template v-else>支</template>
</span>
<span class="pay-method__text">
<span class="pay-method__name">{{ item.label }}</span>
<span class="pay-method__hint">{{ item.hint }}</span>
</span>
<span
class="pay-method__radio"
:class="[`pay-method__radio--${item.value}`, { 'is-checked': item.value === modelValue }]"
/>
</li>
</ul>
</section>
</template>
<style lang="less" scoped>
.pay-method {
border-radius: @radius-lg;
background: @checkout-card;
overflow: hidden;
// 卡头:与「订单明细」卡标题同规格(左右内边距与卡内其它内容对齐)
&__head {
padding: @spacing-lg @spacing-lg @spacing-base;
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
// 支付方式行:满宽铺满卡片(移动端设计稿实取 x 5..242 = 整卡宽)
&__row {
.flex(row, flex-start, center);
gap: @spacing-base;
height: 72px;
padding: 0 @spacing-lg;
background: @checkout-card;
cursor: pointer;
transition: background 0.2s;
&--wechat.is-active {
background: @checkout-method-active-bg;
}
&--alipay.is-active {
background: fade(@checkout-alipay, 12%);
}
}
&__icon {
.flex(row, center, center);
width: 40px;
height: 40px;
border-radius: @radius-round;
flex-shrink: 0;
&--wechat {
background: @checkout-wechat;
}
&--alipay {
background: @checkout-alipay;
color: @white;
font-size: 20px;
font-weight: 600;
line-height: 1;
}
}
&__logo {
width: 28px;
height: 28px;
}
&__bubble {
fill: @white;
}
// 前景气泡描边取图标底色 → 与后景气泡之间留出白-绿分隔缝
&__bubble-front {
fill: @white;
stroke: @checkout-wechat;
stroke-width: 1.8;
}
&__eye {
fill: @checkout-wechat;
}
&__text {
.flex(column, flex-start);
// `.flex()` 的 align 默认 center,会让「名称 / 副标题」各自居中 ⇒ 必须显式左对齐
align-items: flex-start;
gap: 2px;
flex: 1;
min-width: 0;
}
&__name {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__hint {
font-size: @font-size-sm;
color: @checkout-muted;
}
&__radio {
position: relative;
width: 20px;
height: 20px;
border: 1.5px solid @checkout-radio-idle;
border-radius: @radius-round;
background: transparent;
flex-shrink: 0;
transition: all 0.2s;
// 选中:实心圆 + 白色对勾
&.is-checked::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 5px;
height: 10px;
border-right: 2px solid @white;
border-bottom: 2px solid @white;
transform: translate(-50%, -60%) rotate(45deg);
}
&--wechat.is-checked {
border-color: @checkout-wechat;
background: @checkout-wechat;
}
&--alipay.is-checked {
border-color: @checkout-alipay;
background: @checkout-alipay;
}
}
}
</style>

View File

@@ -0,0 +1,274 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import qrcode from 'qrcode-generator'
import { useCountDown } from '@/composables/useCountDown'
import { formatMoney } from '@/utils/format'
/**
* 微信 Native 支付二维码弹窗。
*
* <p>后端 `/pay/web/wechat/native` 返回 `codeUrl`(形如 `weixin://wxpay/bizpayurl?pr=xxx`),
* 本组件用 `qrcode-generator` 把它渲染成二维码(`createSvgTag` → `v-html`),
* 用户用微信「扫一扫」完成支付。</p>
*
* <p>⚠️ 后端**没有「查询支付结果」接口**,所以没有轮询——付完由用户点「我已完成支付」
* 进入支付结果页;二维码倒计时只是展示(Native 侧 `code_url` 真实有效期由微信控制)。</p>
*/
interface Props {
visible: boolean
/** 二维码内容(微信 codeUrl) */
content: string
/** 订单金额(元) */
amount: number
/** 二维码倒计时(秒),默认 5 分钟 */
expiresIn?: number
}
const props = withDefaults(defineProps<Props>(), { expiresIn: 300 })
const emit = defineEmits<{
'update:visible': [visible: boolean]
/** 重新下单换一张二维码 */
refresh: []
/** 用户声明已付款 */
paid: []
}>()
const { seconds, start, reset: resetCountDown } = useCountDown(props.expiresIn)
const started = ref(false)
const expired = computed(() => started.value && seconds.value <= 0)
const countdownText = computed(() => {
const m = Math.floor(seconds.value / 60)
const s = seconds.value % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
})
/** 内容仅来自后端下发的 codeUrl,本地生成 SVG,非用户输入 */
const qrSvg = computed(() => {
if (!props.content) return ''
try {
const qr = qrcode(0, 'M')
qr.addData(props.content)
qr.make()
return qr.createSvgTag({ cellSize: 4, margin: 2, scalable: true })
}
catch {
return ''
}
})
watch(
() => [props.visible, props.content] as const,
([visible, content]) => {
if (visible && content) {
start(props.expiresIn)
started.value = true
return
}
if (!visible) {
resetCountDown()
started.value = false
}
},
{ immediate: true },
)
function close(): void {
emit('update:visible', false)
}
function refresh(): void {
start(props.expiresIn)
started.value = true
emit('refresh')
}
function onPaid(): void {
emit('paid')
}
function onBoxClick(): void {
if (expired.value) refresh()
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="wx-qr" @click.self="close">
<div class="wx-qr__panel">
<h3 class="wx-qr__title">
微信扫码支付
</h3>
<p class="wx-qr__amount">
¥{{ formatMoney(amount) }}
</p>
<div
class="wx-qr__box"
:class="{ 'is-expired': expired }"
@click="onBoxClick"
>
<!-- eslint-disable-next-line vue/no-v-html -- 内容由本地 QR 库生成,非用户输入 -->
<div v-if="!expired && qrSvg" class="wx-qr__code" v-html="qrSvg" />
<div v-else class="wx-qr__mask">
<span class="wx-qr__mask-icon">⟳</span>
<span class="wx-qr__mask-text">
{{ expired ? '二维码已过期' : '二维码生成中' }}<br>点击刷新
</span>
</div>
</div>
<p class="wx-qr__status">
<template v-if="!expired">
请使用手机微信 <strong>[扫一扫]</strong> 扫描上方二维码,
二维码 <strong class="wx-qr__count">{{ countdownText }}</strong> 后失效
</template>
<template v-else>
二维码已过期,请点击二维码或下方按钮刷新
</template>
</p>
<div class="wx-qr__actions">
<button class="wx-qr__btn" @click="refresh">
刷新二维码
</button>
<button class="wx-qr__btn wx-qr__btn--cta" @click="onPaid">
我已完成支付
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style lang="less" scoped>
.wx-qr {
position: fixed;
inset: 0;
z-index: 1000;
.flex(row, center);
background: rgba(0, 0, 0, 0.45);
&__panel {
width: 360px;
max-width: calc(100vw - 32px);
padding: @spacing-lg;
border-radius: @radius-lg;
background: @checkout-card;
box-shadow: @shadow-lg;
.flex(column, flex-start, center);
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__amount {
margin-top: @spacing-xs;
font-size: @font-size-xl;
font-weight: 600;
color: @checkout-amount;
}
&__box {
.flex(row, center, center);
width: 200px;
height: 200px;
margin-top: @spacing-base;
border: 1px solid @checkout-line;
border-radius: @radius-base;
background: @checkout-card;
&.is-expired {
background: @bg-color;
cursor: pointer;
}
}
&__code {
width: 100%;
height: 100%;
padding: @spacing-sm;
// v-html 注入的 SVG 拿不到 scoped 属性,必须 :deep
:deep(svg) {
display: block;
width: 100%;
height: 100%;
}
}
&__mask {
.flex(column, center);
gap: @spacing-xs;
color: @text-color-secondary;
}
&__mask-icon {
font-size: 36px;
line-height: 1;
}
&__mask-text {
font-size: @font-size-sm;
text-align: center;
line-height: 1.6;
}
&__status {
margin-top: @spacing-base;
font-size: @font-size-sm;
color: @text-color-secondary;
text-align: center;
line-height: 1.6;
strong {
color: @heading-color;
}
}
&__count {
color: @checkout-amount;
font-weight: 600;
}
&__actions {
.flex(row, center);
gap: @spacing-sm;
width: 100%;
margin-top: @spacing-lg;
}
&__btn {
flex: 1;
height: 40px;
border: 1px solid @border-color-dark;
border-radius: @radius-round;
background: @checkout-card;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
border-color: @checkout-cta;
color: @checkout-cta;
}
&--cta {
border-color: @checkout-cta;
background: @checkout-cta;
color: @white;
font-weight: 600;
&:hover {
background: @checkout-cta-hover;
color: @white;
}
}
}
}
</style>

View File

@@ -6,3 +6,6 @@ export { default as BillSummaryBar } from './cpns/BillSummaryBar.vue'
export { default as PayMethodDialog } from './cpns/PayMethodDialog.vue'
export { default as InvoiceTitleCard } from './cpns/InvoiceTitleCard.vue'
export { default as InvoiceTitleDialog } from './cpns/InvoiceTitleDialog.vue'
export { default as CheckoutBillCard } from './cpns/CheckoutBillCard.vue'
export { default as PayMethodList } from './cpns/PayMethodList.vue'
export { default as WechatQrDialog } from './cpns/WechatQrDialog.vue'

View File

@@ -14,13 +14,19 @@ const { result } = storeToRefs(financeStore)
const billId = computed(() => String(route.params.id))
/** 需求 id(收银台带过来):有它就走「需求 + 期次」的财务口径查支付结果 */
const requirementId = computed(() => {
const raw = route.query.requirementId
return String(Array.isArray(raw) ? raw[0] : (raw ?? '')).trim()
})
const methodLabel = computed(() => {
if (!result.value) return '—'
return result.value.method === 'wechat' ? '微信支付' : '支付宝支付'
})
onMounted(() => {
void financeStore.fetchResult(billId.value)
void financeStore.fetchResult(billId.value, requirementId.value)
})
</script>

View File

@@ -11,7 +11,7 @@ defineProps<Props>()
const items = [
{ key: 'total', label: '累积项目' },
{ key: 'inProgress', label: '进行中' },
{ key: 'pending', label: '已提交' },
{ key: 'pending', label: '待您处理' },
] as const
</script>

View File

@@ -2,6 +2,7 @@
import { reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { AppButton, FileList, UploadArea } from '@/components'
import { useOssStore } from '@/stores/modules/oss'
import type { FileInfo } from '@/types/api'
import type { Qualification, QualificationInput, QualificationType } from '@/types/business/profile'
@@ -11,6 +12,8 @@ interface Props {
const props = defineProps<Props>()
const ossStore = useOssStore()
const visible = defineModel<boolean>('visible', { default: false })
const emit = defineEmits<{ save: [payload: QualificationInput] }>()
@@ -56,11 +59,8 @@ function onUpload(file: File): void {
}
function onPreview(file: FileInfo): void {
if (file.url) {
window.open(file.url, '_blank')
return
}
ElMessage.info('附件预览待接口接入')
// 同源预览:资质附件多为本地未上传文件(无 ossId),弹窗会提示无法在线预览
ossStore.openPreview(file)
}
function onDeleteFile(file: FileInfo): void {

View File

@@ -4,9 +4,11 @@ import { ElMessage } from 'element-plus'
import type { FileInfo } from '@/types/api'
import type { Feedback } from '@/types/business/profile'
import { useProfileStore } from '@/stores/modules/profile'
import { useOssStore } from '@/stores/modules/oss'
import FeedbackForm from './cpns/FeedbackForm.vue'
const profileStore = useProfileStore()
const ossStore = useOssStore()
const resetSignal = ref(0)
@@ -30,11 +32,8 @@ function onReset(): void {
}
function onPreview(file: FileInfo): void {
if (file.url) {
window.open(file.url, '_blank')
return
}
ElMessage.info('附件预览待接口接入')
// 同源预览:本地未上传文件无 ossId,弹窗会提示无法在线预览
ossStore.openPreview(file)
}
</script>

View File

@@ -6,12 +6,14 @@ import { ElMessage } from 'element-plus'
import { AppButton, FileList } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useProjectStore } from '@/stores/modules/project'
import { useOssStore } from '@/stores/modules/oss'
import type { FileInfo } from '@/types/api'
import type { AfterSalesAttachment } from '@/types/business/project'
const route = useRoute()
const router = useRouter()
const projectStore = useProjectStore()
const ossStore = useOssStore()
const { current } = storeToRefs(projectStore)
const projectId = computed(() => String(route.params.id))
@@ -117,11 +119,8 @@ function removeAttachment(file: FileInfo): void {
}
function previewAttachment(file: FileInfo): void {
if (!file.url) {
ElMessage.info('该附件还没有可访问的地址')
return
}
window.open(file.url, '_blank', 'noopener')
// 同源预览:有 ossId 走后端 inline 接口,绕开 OSS 桶「强制下载」
ossStore.openPreview(file)
}
function downloadAttachment(file: FileInfo): void {

View File

@@ -2,6 +2,7 @@
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { FileList } from '@/components'
import { useOssStore } from '@/stores/modules/oss'
import type { FileInfo } from '@/types/api'
import type { AfterSales, ProjectStatus } from '@/types/business/project'
@@ -19,6 +20,8 @@ const emit = defineEmits<{
cancel: []
}>()
const ossStore = useOssStore()
/**
* 售后页签的三态(设计稿便签原文):
* 1. 项目**未完成** → 不展示售后信息;
@@ -37,11 +40,8 @@ const hasTicket = computed(() => props.afterSales?.status === '处理中')
* 删除没有接口 —— 售后附件只在「申请 / 完成售后」时整体写入,不支持单独删。
*/
function previewFile(file: FileInfo): void {
if (!file.url) {
ElMessage.info('该附件没有可访问的地址')
return
}
window.open(file.url, '_blank', 'noopener')
// 同源预览:有 ossId 走后端 inline 接口,绕开 OSS 桶「强制下载」
ossStore.openPreview(file)
}
function downloadFile(file: FileInfo): void {

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import type { FileInfo } from '@/types/api'
import type { ProjectBill } from '@/types/business/project'
import { fileLabelOf, fileToneOf } from '@/utils/fileMeta'
import { formatFileSize, formatMoney } from '@/utils/format'
interface Props {
@@ -18,54 +18,82 @@ const emit = defineEmits<{
download: [file: FileInfo]
}>()
const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
'已支付': 'success',
'待支付': 'warning',
'未到期': 'info',
/**
* 是否可跳收银台支付。
*
* <p>用「待支付 + id 是纯数字」判断,而不是过去的「来源是账单」——本栈的支付口径就是
* **计划 id**(收银台 `?ids=` 与 `vm_finance_payment.bill_id` 同一口径),计划卡本就该能付;
* 数字校验只是防止空 id 拼出 `?ids=`。</p>
*/
function canPay(bill: ProjectBill): boolean {
return bill.status === '待支付' && /^\d+$/.test(bill.id)
}
/**
* 三态配色类(设计稿口径,挂到 `.project-finance__plan` 上,由后代选择器分发):
* 已支付 = 绿、待支付 = 橙、未到期 = 灰,**徽标实底 + 金额文字同色**。
*/
const TONE_CLASS: Record<ProjectBill['status'], string> = {
'已支付': 'is-paid',
'待支付': 'is-pending',
'未到期': 'is-idle',
}
/** 文件行副标题(设计稿:「2.4MB · 上传于 2026-8-17 17:00」) */
function fileMeta(file: FileInfo): string {
const size = file.size ? formatFileSize(file.size) : ''
const time = file.uploadTime ? `上传于 ${file.uploadTime}` : ''
return [size, time].filter(Boolean).join(' · ')
}
</script>
<template>
<section class="project-finance">
<!-- 付款计划 -->
<!-- 付款计划(数据源 vm_bill_plan):卡内三态 = 已支付 / 待支付 / 未到期 -->
<h2 class="project-finance__title">
付款计划
</h2>
<div class="project-finance__plans">
<article
v-for="bill in paymentPlans"
:key="bill.id"
:key="bill.id || bill.planName"
class="project-finance__plan"
:class="TONE_CLASS[bill.status]"
>
<div class="project-finance__plan-head">
<span class="project-finance__plan-name">{{ bill.planName }}</span>
<StatusTag :label="bill.status" :variant="BILL_VARIANTS[bill.status]" />
</div>
<div class="project-finance__plan-meta">
<div class="project-finance__field">
<span class="project-finance__label text-secondary">关联项目</span>
<span class="project-finance__value">{{ bill.projectName }}</span>
</div>
<div class="project-finance__field">
<span class="project-finance__label text-secondary">账单号</span>
<span class="project-finance__value">{{ bill.billNo }}</span>
</div>
</div>
<div class="project-finance__amount">
¥{{ formatMoney(bill.amount) }}
</div>
<div class="project-finance__plan-foot">
<span v-if="bill.status === '已支付' && bill.tradeNo" class="project-finance__trade text-secondary">
交易流水号:{{ bill.tradeNo }}
<span class="project-finance__plan-name">
{{ bill.planName }}<template v-if="bill.periodRatio"> 比例{{ bill.periodRatio }}%</template>
</span>
<button
v-if="bill.status === '待支付'"
class="project-finance__pay"
@click="emit('pay', bill.id)"
>
立即支付
</button>
<span class="project-finance__badge">{{ bill.status }}</span>
</div>
<div class="project-finance__plan-body">
<div class="project-finance__fields">
<!-- 交易流水号只在已支付时出现(设计稿:已支付那张卡多一行流水号) -->
<p v-if="bill.status === '已支付' && bill.tradeNo" class="project-finance__field">
交易流水号:{{ bill.tradeNo }}
</p>
<p class="project-finance__field">
账单号:{{ bill.billNo || '—' }}
</p>
<p class="project-finance__field">
关联项目:{{ bill.projectName || '—' }}
</p>
</div>
<div class="project-finance__amount">
¥{{ formatMoney(bill.amount) }}
</div>
</div>
<!-- 支付口径 = 计划 id(与收银台 ?ids= / vm_finance_payment.bill_id 一致) -->
<button
v-if="canPay(bill)"
class="project-finance__pay"
type="button"
@click="emit('pay', bill.id)"
>
立即支付
</button>
</article>
<p v-if="paymentPlans.length === 0" class="project-finance__empty text-secondary">
暂无付款计划
@@ -82,17 +110,38 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
:key="`invoice-${index}`"
class="project-finance__file"
>
<span
class="project-finance__file-badge"
:style="{ color: fileToneOf(file), backgroundColor: `${fileToneOf(file)}1a` }"
>
{{ fileLabelOf(file) }}
</span>
<div class="project-finance__file-info">
<span class="project-finance__file-name" :title="file.name">{{ file.name }}</span>
<span class="project-finance__file-meta text-secondary">
{{ file.size ? formatFileSize(file.size) + ' · ' : '' }}{{ file.uploadTime }}
</span>
<span class="project-finance__file-meta">{{ fileMeta(file) }}</span>
</div>
<div class="project-finance__file-actions">
<button class="project-finance__link" @click="emit('preview', file)">
<button class="project-finance__act" type="button" @click="emit('preview', file)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8s-2.4 4.5-6.5 4.5S1.5 8 1.5 8Z"
stroke="currentColor"
stroke-width="1.2"
/>
<circle cx="8" cy="8" r="1.8" stroke="currentColor" stroke-width="1.2" />
</svg>
预览
</button>
<button class="project-finance__link" @click="emit('download', file)">
<button class="project-finance__act" type="button" @click="emit('download', file)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M8 2v8m0 0 3-3m-3 3L5 7M3 13h10"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
下载
</button>
</div>
@@ -106,18 +155,39 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
<h2 class="project-finance__title">
合同文件
</h2>
<div v-if="contractFile" class="project-finance__contract">
<div v-if="contractFile" class="project-finance__file">
<span
class="project-finance__file-badge"
:style="{ color: fileToneOf(contractFile), backgroundColor: `${fileToneOf(contractFile)}1a` }"
>
{{ fileLabelOf(contractFile) }}
</span>
<div class="project-finance__file-info">
<span class="project-finance__file-name" :title="contractFile.name">{{ contractFile.name }}</span>
<span class="project-finance__file-meta text-secondary">
{{ contractFile.size ? formatFileSize(contractFile.size) + ' · ' : '' }}{{ contractFile.uploadTime }}
</span>
<span class="project-finance__file-meta">{{ fileMeta(contractFile) }}</span>
</div>
<div class="project-finance__file-actions">
<button class="project-finance__link" @click="emit('preview', contractFile)">
<button class="project-finance__act" type="button" @click="emit('preview', contractFile)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8s-2.4 4.5-6.5 4.5S1.5 8 1.5 8Z"
stroke="currentColor"
stroke-width="1.2"
/>
<circle cx="8" cy="8" r="1.8" stroke="currentColor" stroke-width="1.2" />
</svg>
预览
</button>
<button class="project-finance__link" @click="emit('download', contractFile)">
<button class="project-finance__act" type="button" @click="emit('download', contractFile)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M8 2v8m0 0 3-3m-3 3L5 7M3 13h10"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
下载
</button>
</div>
@@ -137,107 +207,166 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
background: @white;
box-shadow: @shadow-base;
// 区块标题:与「项目进度/项目文件/售后信息」同款(16px/600 + 左侧蓝色竖条)
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
.section-title();
}
// 只读回落态(后端未重启、响应无 bills)的说明行
&__note {
font-size: @font-size-sm;
}
// ---------- 付款计划 ----------
// 设计稿:卡片**竖排单列、定宽左对齐**(不是横排多列网格)
&__plans {
.flex(row, flex-start);
flex-wrap: wrap;
gap: @spacing-base;
.flex(column, flex-start, flex-start);
gap: @spacing-sm;
}
&__plan {
.flex(column, flex-start, stretch);
gap: @spacing-sm;
width: 320px;
padding: @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
width: @finance-card-width;
padding: 12px;
border-radius: @finance-card-radius;
background: @white;
// 设计稿用阴影立卡片、**不描边**;阴影比页面默认更淡,只做「浮起」暗示
box-shadow: @finance-card-shadow;
// 三态配色(徽标实底白字 + 金额同色)
&.is-paid {
.project-finance__badge {
background: @finance-paid;
}
.project-finance__amount {
color: @finance-paid;
}
}
&.is-pending {
.project-finance__badge {
background: @finance-pending;
}
.project-finance__amount {
color: @finance-pending-amount;
}
}
&.is-idle {
.project-finance__badge {
background: @finance-idle;
}
.project-finance__amount {
color: @finance-idle;
}
}
}
&__plan-head {
.flex(row, space-between, center);
.flex(row, space-between, flex-start);
gap: @spacing-sm;
}
&__plan-name {
font-size: @font-size-base;
font-weight: 600;
font-weight: 700;
letter-spacing: 0.8px;
color: @heading-color;
.ellipsis();
}
&__plan-meta {
// 状态徽标:实底白字的小圆角块(设计 48×22 / 圆角 4 / 10 设计px 字号)
&__badge {
.flex(row, center, center);
flex-shrink: 0;
padding: 2px @spacing-sm;
border-radius: @radius-sm;
font-size: 11px;
line-height: 16px;
color: @white;
}
&__plan-body {
.flex(row, space-between, flex-start);
gap: @spacing-base;
}
&__fields {
.flex(column, flex-start, stretch);
gap: @spacing-xs;
gap: 4px;
min-width: 0;
}
&__field {
.flex(row, flex-start);
gap: @spacing-sm;
font-size: @font-size-sm;
}
&__label {
flex-shrink: 0;
}
&__value {
color: @text-color;
line-height: 17px;
color: @finance-meta-color;
.ellipsis();
}
// 金额:设计稿在卡片**右侧**、字号最大且随状态变色(不是左下角恒定深色)
&__amount {
flex-shrink: 0;
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
&__plan-foot {
.flex(row, space-between, center);
min-height: 28px;
}
&__trade {
font-size: @font-size-sm;
.ellipsis();
font-weight: 700;
line-height: 28px;
white-space: nowrap;
}
// 「立即支付」:设计稿是卡内**满宽**金色胶囊(不是右下角的小蓝按钮)
&__pay {
height: 28px;
padding: 0 @spacing-base;
height: 22px;
border: none;
border-radius: @radius-sm;
background: @primary-color;
border-radius: @radius-round;
background: @finance-pending;
color: @white;
font-size: @font-size-sm;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: @primary-hover;
background: @finance-cta-hover;
}
}
// ---------- 发票 / 合同文件 ----------
&__files {
.flex(column, flex-start, stretch);
gap: @spacing-sm;
margin: 0;
padding: 0;
list-style: none;
}
&__file,
&__contract {
.flex(row, space-between);
gap: @spacing-base;
// 设计稿:整行通铺浅灰底、无描边无圆角(区别于卡片)
&__file {
.flex(row, space-between, center);
gap: @spacing-sm;
padding: @spacing-sm @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
background: @finance-row-bg;
}
// 文件类型标识:按后缀着色的小方块(与需求提报页附件行同一套色板)
&__file-badge {
.flex(row, center, center);
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: @radius-sm;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.2px;
}
&__file-info {
.flex(column, flex-start, stretch);
flex: 1;
gap: 2px;
min-width: 0;
}
@@ -250,21 +379,25 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
&__file-meta {
font-size: @font-size-sm;
color: @text-color-secondary;
}
&__file-actions {
.flex(row, center);
gap: @spacing-sm;
.flex(row, flex-end, center);
gap: @spacing-base;
flex-shrink: 0;
}
&__link {
&__act {
.flex(row, center, center);
gap: 4px;
font-size: @font-size-sm;
color: @primary-color;
transition: opacity 0.2s;
color: @text-color;
cursor: pointer;
transition: color 0.2s;
&:hover {
opacity: 0.75;
color: @primary-color;
}
}

View File

@@ -74,9 +74,26 @@ function onDownloadFile(): void {
ElMessage.info('文件下载待接口接入')
}
function onPay(): void {
// TODO: 跳财务中心 `/finance/bills`
ElMessage.info('请前往财务中心完成支付')
/**
* 「立即支付」→ 收银台。
*
* <p>带两个参数:**需求 id**(本页路由 `:id`;收银台据此调现成的
* `GET /web/requirementPool/get/finance/{需求id}` 取付款计划当订单明细)与**期次 id**
* (`vm_bill_plan.id`,用于精确指定付哪一期——本栈支付口径就是它)。</p>
*
* <p>兜底:期次 id 为空或非数字时不跳(历史兜底数据 / 后端未重启时可能出现),
* 只提示——宁可不跳,也不要把无效 id 丢给路由,让用户落在空收银台上。</p>
*/
function onPay(billId: string): void {
const id = String(billId ?? '').trim()
if (!/^\d+$/.test(id)) {
ElMessage.warning('该期账单尚未生成,暂时无法支付')
return
}
void router.push({
path: '/finance/checkout',
query: { requirementId: projectId.value, ids: id },
})
}
function onFinancePreview(file: FileInfo): void {