From ce8210fb3f826f69ac399759db7c508ff9564180 Mon Sep 17 00:00:00 2001 From: wangzzpeng <1032675359@qq.com> Date: Wed, 30 Sep 2026 08:04:16 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E4=B8=AD=E5=BF=83-=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 8 + src/api/finance.ts | 182 +++++++- src/api/oss.ts | 18 + src/components/FilePreviewDialog.vue | 247 +++++++++++ src/components/index.ts | 1 + src/layout/index.vue | 4 +- src/router/routes.ts | 2 + src/stores/modules/finance.ts | 63 ++- src/stores/modules/oss.ts | 114 +++++ src/styles/variables.less | 28 ++ src/types/api.d.ts | 2 + src/types/business/finance.d.ts | 114 ++++- src/types/business/project.d.ts | 74 +++- src/utils/billVo.ts | 136 ++++++ src/utils/fileMeta.ts | 94 ++++ src/utils/filePreview.ts | 52 +++ src/utils/payMeta.ts | 68 +++ src/utils/projectVo.ts | 103 ++++- src/views/finance/bill-detail.vue | 24 +- src/views/finance/bills.vue | 8 +- src/views/finance/checkout.vue | 406 ++++++++++++++++++ src/views/finance/cpns/BillTable.vue | 3 +- src/views/finance/cpns/CheckoutBillCard.vue | 137 ++++++ src/views/finance/cpns/PayMethodList.vue | 225 ++++++++++ src/views/finance/cpns/WechatQrDialog.vue | 274 ++++++++++++ src/views/finance/index.ts | 3 + src/views/finance/pay-result.vue | 8 +- src/views/home/cpns/HomeStats.vue | 2 +- .../profile/cpns/QualificationDialog.vue | 10 +- src/views/profile/feedback.vue | 9 +- src/views/project/after-sales.vue | 9 +- src/views/project/cpns/ProjectAfterSales.vue | 10 +- src/views/project/cpns/ProjectFinance.vue | 331 +++++++++----- src/views/project/detail.vue | 23 +- 35 files changed, 2618 insertions(+), 175 deletions(-) create mode 100644 src/api/oss.ts create mode 100644 src/components/FilePreviewDialog.vue create mode 100644 src/stores/modules/oss.ts create mode 100644 src/utils/billVo.ts create mode 100644 src/utils/fileMeta.ts create mode 100644 src/utils/filePreview.ts create mode 100644 src/utils/payMeta.ts create mode 100644 src/views/finance/checkout.vue create mode 100644 src/views/finance/cpns/CheckoutBillCard.vue create mode 100644 src/views/finance/cpns/PayMethodList.vue create mode 100644 src/views/finance/cpns/WechatQrDialog.vue diff --git a/package.json b/package.json index 3d775b5..36a6f02 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8de4b33..54e5ad1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/api/finance.ts b/src/api/finance.ts index 2c15a84..29b1570 100644 --- a/src/api/finance.ts +++ b/src/api/finance.ts @@ -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 调用 +// 说明:账单 / 支付相关接口已接后端真数据;仅「发票」与「统计」仍是 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 { 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 { + const rows = await request({ 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> { - const filtered = MOCK_BILLS.filter((item) => { +/** + * 账单列表(筛选 + 分页)。 + * + *

⚠️ `GET /web/bill/list` 只按登录用户返回**全量**账单,后端没有 pageNum / keyword / + * status 查询参数;单个客户的账单量级是「几条 ~ 几十条」,故筛选与分页放在前端做。 + * 若日后账单量变大,应给该端点补后端分页参数,这里再切成一次请求。

+ */ +export async function getBillList(query: BillQuery): Promise> { + 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 { - const found = MOCK_BILLS.find((item) => item.id === id) ?? null - return Promise.resolve(found ? { ...found } : null) +export async function getBillDetail(id: string): Promise { + const rows = await request({ 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 { 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 { return createPayment(billId, method) } -/** 查询支付结果(无支付记录且未支付返回 null;演示数据 payTime/tradeNo 固定) */ -export function getPayResult(billId: string): Promise { +/** + * 查询支付结果。 + * + *

真账单:直接读 `vm_bill` —— 账单被回调置为 PAID 时,`pay_time` / + * `transaction_no` / `pay_channel` 已由后端一并回写,这里拼出结果页所需结构; + * 未支付则返回 null。

+ * + *

假账单(id 形如 `bill-001`):走下面的遗留演示逻辑。真账单 id 一定是数字, + * 故只有已下线的 pay.vue 会命中这条分支。

+ */ +export async function getPayResult(billId: string): Promise { + 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 { const existing = MOCK_RESULTS.get(billId) if (existing) return Promise.resolve(structuredClone(existing)) @@ -314,3 +369,98 @@ export function setDefaultTitle(id: string): Promise { export function exportReport(): Promise { return Promise.resolve() } + +// ═══════════════════════════════════════════════════════════════════════ +// 收银台(确认支付)——以下为**真实后端接口**,不是 mock +// 后端类:vermis-modules/…/controller/pc/pay/WebScanPayController,类级 @SaIgnore(匿名可调) +// ═══════════════════════════════════════════════════════════════════════ + +/** + * 取收银台「订单明细」——**复用项目详情「合同财务」那个现成接口**,不另开端点。 + * + *

`GET /web/requirementPool/get/finance/{requirementId}`(`ProjectRequirementUserController`) + * 的 `{id}` 是**需求 id**;后端把「需求 + 项目」拼成一条联合结构(`loadProject` 用 + * `vm_rd_project.requirement_id` 反查项目),再把该项目的付款计划随 `billPlans` 一并下发。

+ * + *

本栈支付口径是 `vm_bill_plan.id`(`vm_finance_payment.bill_id` 实测存的就是计划 id), + * 故订单明细直接取这份 `billPlans`,只保留**本次要付的期**: + * URL 带 `ids`(期次 id)时按期次过滤,不带则取状态为「待支付」的期。

+ * + * @param requirementId 需求 id(项目详情路由 `:id`) + * @param ids 期次 id(`vm_bill_plan.id`)列表,可选,用于精确指定付哪一期 + */ +export async function getCheckoutBills(requirementId: string, ids: string[] = []): Promise { + 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 === '待支付') +} + +/** + * 支付结果(收银台「我已完成支付」→ 结果页)。 + * + *

口径同 {@link getCheckoutBills}:按**需求 id + 期次 id** 从 `billPlans` 里取出该期, + * 支付单状态为 `SUCCESS` 才算已支付——计划本身的 status 可能还没被回调推到 DONE。

+ */ +export async function getPlanPayResult(requirementId: string, planId: string): Promise { + 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 { + return request({ + url: '/pay/web/wechat/native', + method: 'post', + data: { orderNo, amount, billIds }, + }) +} + +/** + * 支付宝电脑网站支付下单:`POST /pay/web/alipay/page`,`@SaIgnore`。 + * + *

入参对应后端 `IFinancePaymentBo`。其 `outTradeNo` / `status` 上的 `@NotBlank` + * 只声明在 AddGroup / EditGroup 分组上,而控制器用的是默认分组 ⇒ 只传 + * `outTradeNo` + `amount` 即可(`payChannel` / `status` 由后端自己补),不会校验失败。

+ * + * @returns `formBody` —— 后端走 `pageExecute(request, "GET")`,实测内容是 + * **支付宝收银台跳转地址**(`https://…/gateway.do?…`)而不是 HTML 表单;前端两种形态都兜住。 + */ +export function createAlipayPageOrder( + outTradeNo: string, + amount: number, + billIds: string[] = [], +): Promise { + return request({ + url: '/pay/web/alipay/page', + method: 'post', + data: { outTradeNo, amount, billIds }, + }) +} diff --git a/src/api/oss.ts b/src/api/oss.ts new file mode 100644 index 0000000..1d49d5f --- /dev/null +++ b/src/api/oss.ts @@ -0,0 +1,18 @@ +import service from '@/utils/request' + +/** + * 同源预览:后端从 OSS 读取字节后以 `inline` 方式返回,绕开阿里云 OSS 桶「强制下载」配置。 + * + *

⚠️ 该端点返回的是裸字节(非 `R.ok()` 包装的 `ApiResponse`),故不走统一的 + * `request` 解包逻辑,直接用底层 axios 实例拿 `blob`:请求拦截器已自动带上 + * `Authorization` 与 `clientid` 头,后端据此放行(端点需登录)。

+ * + *

用 blob 而非 `window.open(url)` 的关键:blob URL 自身不带 `Content-Disposition`, + * 天然绕开 OSS 的强制下载;且全程同源,不受 OSS 跨域 / 强制下载限制。

+ */ +export async function fetchOssPreviewBlob(ossId: string | number): Promise { + const resp = await service.get(`/web/workOrderDraft/preview/${ossId}`, { + responseType: 'blob', + }) + return resp.data as Blob +} diff --git a/src/components/FilePreviewDialog.vue b/src/components/FilePreviewDialog.vue new file mode 100644 index 0000000..e64367b --- /dev/null +++ b/src/components/FilePreviewDialog.vue @@ -0,0 +1,247 @@ + + +