项目中心-支付模块
This commit is contained in:
@@ -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
8
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
18
src/api/oss.ts
Normal 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
|
||||
}
|
||||
247
src/components/FilePreviewDialog.vue
Normal file
247
src/components/FilePreviewDialog.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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
114
src/stores/modules/oss.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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
2
src/types/api.d.ts
vendored
@@ -28,4 +28,6 @@ export interface FileInfo {
|
||||
size: number
|
||||
uploadTime: string
|
||||
url: string
|
||||
/** OSS 主键(同源预览用);本地未上传文件为 undefined */
|
||||
ossId?: string | number
|
||||
}
|
||||
|
||||
114
src/types/business/finance.d.ts
vendored
114
src/types/business/finance.d.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
/** 账单查询条件 */
|
||||
|
||||
74
src/types/business/project.d.ts
vendored
74
src/types/business/project.d.ts
vendored
@@ -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
136
src/utils/billVo.ts
Normal 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
94
src/utils/fileMeta.ts
Normal 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
52
src/utils/filePreview.ts
Normal 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
68
src/utils/payMeta.ts
Normal 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)}`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
账单尚未到期,到期后可前往账单明细完成支付。
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
406
src/views/finance/checkout.vue
Normal file
406
src/views/finance/checkout.vue
Normal 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> > </span>
|
||||
<RouterLink to="/finance/bills" class="link">
|
||||
账单明细
|
||||
</RouterLink>
|
||||
<span> > </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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
137
src/views/finance/cpns/CheckoutBillCard.vue
Normal file
137
src/views/finance/cpns/CheckoutBillCard.vue
Normal 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>
|
||||
225
src/views/finance/cpns/PayMethodList.vue
Normal file
225
src/views/finance/cpns/PayMethodList.vue
Normal 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>
|
||||
274
src/views/finance/cpns/WechatQrDialog.vue
Normal file
274
src/views/finance/cpns/WechatQrDialog.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ defineProps<Props>()
|
||||
const items = [
|
||||
{ key: 'total', label: '累积项目' },
|
||||
{ key: 'inProgress', label: '进行中' },
|
||||
{ key: 'pending', label: '已提交' },
|
||||
{ key: 'pending', label: '待您处理' },
|
||||
] as const
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user