feat 搭建架构骨架

This commit is contained in:
Ironsp
2026-09-24 09:09:47 +08:00
parent dd9f6b77a4
commit 7a2efa3fea
180 changed files with 18967 additions and 9 deletions

View File

@@ -0,0 +1,226 @@
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { AppButton, StatusTag } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useFinanceStore } from '@/stores/modules/finance'
import type { BillStatus } from '@/types/business/finance'
import { formatMoney } from '@/utils/format'
const route = useRoute()
const router = useRouter()
const financeStore = useFinanceStore()
const { currentBill } = storeToRefs(financeStore)
const billId = computed(() => String(route.params.id))
const STATUS_VARIANTS: Record<BillStatus, 'warning' | 'success' | 'info'> = {
待支付: 'warning',
已支付: 'success',
未到期: 'info',
}
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`)
}
watch(billId, () => {
void load()
})
onMounted(() => {
void load()
})
</script>
<template>
<div class="bill-detail">
<nav class="bill-detail__breadcrumb text-secondary">
<RouterLink to="/finance" class="link">
财务中心
</RouterLink>
<span> &gt; </span>
<RouterLink to="/finance/bills" class="link">
账单明细
</RouterLink>
<span> &gt; </span>
<span>账单详情</span>
</nav>
<template v-if="currentBill">
<section class="bill-detail__card card">
<div class="bill-detail__card-head">
<h3 class="bill-detail__card-title">
订单信息
</h3>
<StatusTag :label="currentBill.status" :variant="STATUS_VARIANTS[currentBill.status]" />
</div>
<div class="bill-detail__fields">
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">账单名称</span>
<span class="bill-detail__value">{{ currentBill.planName }}</span>
</div>
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">账单号</span>
<span class="bill-detail__value">{{ currentBill.billNo }}</span>
</div>
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">项目名称</span>
<span class="bill-detail__value">{{ currentBill.projectName }}</span>
</div>
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">订单金额</span>
<span class="bill-detail__value bill-detail__value--amount">¥{{ formatMoney(currentBill.amount) }}</span>
</div>
</div>
<!-- 待支付:立即支付 -->
<div v-if="currentBill.status === '待支付'" class="bill-detail__pay">
<AppButton type="primary" @click="onPay">
立即支付
</AppButton>
</div>
<!-- 已支付:支付信息 -->
<template v-else-if="currentBill.status === '已支付'">
<div class="bill-detail__pay-info">
<h4 class="bill-detail__sub-title">
支付信息
</h4>
<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>
</div>
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">支付方式</span>
<span class="bill-detail__value">微信支付</span>
</div>
<div class="bill-detail__field">
<span class="bill-detail__label text-secondary">支付时间</span>
<span class="bill-detail__value">{{ financeStore.result?.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>
</div>
</div>
</div>
</template>
<!-- 未到期:提示 -->
<div v-else class="bill-detail__tip text-secondary">
账单尚未到期,到期后可前往账单明细完成支付。
</div>
</section>
</template>
<p v-else class="bill-detail__empty text-secondary">
账单不存在或已下线
</p>
<div class="bill-detail__back">
<AppButton @click="router.push('/finance/bills')">
返回
</AppButton>
</div>
</div>
</template>
<style lang="less" scoped>
.bill-detail {
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
margin-bottom: @spacing-base;
font-size: @font-size-base;
}
&__card {
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__card-head {
.flex(row, space-between, center);
margin-bottom: @spacing-base;
}
&__card-title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__fields {
.flex(column, flex-start);
gap: @spacing-sm;
}
&__field {
.flex(row, flex-start, center);
gap: @spacing-base;
font-size: @font-size-base;
}
&__label {
width: 80px;
flex-shrink: 0;
}
&__value {
color: @heading-color;
&--amount {
font-weight: 600;
color: @danger-color;
}
}
&__pay {
margin-top: @spacing-lg;
}
&__pay-info {
margin-top: @spacing-lg;
padding-top: @spacing-base;
border-top: 1px solid @border-color;
}
&__sub-title {
margin-bottom: @spacing-sm;
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__tip {
margin-top: @spacing-lg;
padding: @spacing-base;
border-radius: @radius-base;
background: @bg-color;
font-size: @font-size-base;
}
&__empty {
padding: @spacing-xl 0;
text-align: center;
font-size: @font-size-base;
}
&__back {
margin-top: @spacing-lg;
}
}
</style>

158
src/views/finance/bills.vue Normal file
View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { FilterPanel, PaginationBar } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useFinanceStore } from '@/stores/modules/finance'
import type { BillStatus } from '@/types/business/finance'
import BillSummaryBar from './cpns/BillSummaryBar.vue'
import BillTable from './cpns/BillTable.vue'
const router = useRouter()
const financeStore = useFinanceStore()
const { query, list, total } = storeToRefs(financeStore)
const keyword = ref('')
const selectedIds = ref<string[]>([])
const STATUS_TABS: Array<{ label: string; value: BillStatus | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '待支付', value: '待支付' },
{ label: '已支付', value: '已支付' },
{ label: '未到期', value: '未到期' },
]
function onTab(value: BillStatus | 'all'): void {
if (query.value.status === value) return
financeStore.setQuery({ ...query.value, status: value, keyword: keyword.value })
selectedIds.value = []
void financeStore.search()
}
function onSearch(): void {
financeStore.setQuery({ ...query.value, keyword: keyword.value })
void financeStore.search()
}
function onReset(): void {
keyword.value = ''
selectedIds.value = []
void financeStore.resetQuery()
}
function onPageChange(page: number): void {
void financeStore.changePage(page)
}
function onView(id: string): void {
void router.push(`/finance/bills/${id}`)
}
function onPay(id: string): void {
void router.push(`/finance/bills/${id}/pay`)
}
function onBatchPay(ids: string[]): void {
const first = ids[0]
if (!first) return
void router.push(`/finance/bills/${first}/pay`)
}
onMounted(() => {
keyword.value = query.value.keyword
void financeStore.fetchList()
})
</script>
<template>
<div class="bills">
<nav class="bills__breadcrumb text-secondary">
<RouterLink to="/finance" class="link">
财务中心
</RouterLink>
<span> &gt; </span>
<span>账单明细</span>
</nav>
<div class="bills__tabs">
<button
v-for="tab in STATUS_TABS"
:key="tab.value"
class="bills__tab"
:class="{ 'is-active': query.status === tab.value }"
@click="onTab(tab.value)"
>
{{ tab.label }}
</button>
</div>
<FilterPanel
v-model:keyword="keyword"
keyword-placeholder="搜索账单名称 / 账单号 / 项目名称"
@search="onSearch"
@reset="onReset"
/>
<BillTable
:bills="list"
:selected-ids="selectedIds"
@update:selection="selectedIds = $event"
@view="onView"
@pay="onPay"
/>
<BillSummaryBar
:bills="list"
:selected-ids="selectedIds"
@batch-pay="onBatchPay"
/>
<PaginationBar
:page="query.page"
:page-size="query.pageSize"
:total="total"
@page-change="onPageChange"
/>
</div>
</template>
<style lang="less" scoped>
.bills {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
font-size: @font-size-base;
}
&__tabs {
.flex(row, flex-start);
gap: @spacing-sm;
}
&__tab {
height: 34px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-color;
color: @white;
}
}
}
</style>

View File

@@ -0,0 +1,88 @@
<script setup lang="ts">
import { computed } from 'vue'
import { AppButton } from '@/components'
import type { Bill } from '@/types/business/finance'
import { formatMoney } from '@/utils/format'
interface Props {
bills: Bill[]
selectedIds: string[]
}
const props = defineProps<Props>()
const emit = defineEmits<{ batchPay: [ids: string[]] }>()
const totalCount = computed(() => props.bills.length)
const totalAmount = computed(() => props.bills.reduce((sum, bill) => sum + bill.amount, 0))
const pendingCount = computed(() => props.bills.filter((bill) => bill.status === '待支付').length)
const selectedBills = computed(() => props.bills.filter((bill) => props.selectedIds.includes(bill.id)))
const selectedAmount = computed(() =>
selectedBills.value.reduce((sum, bill) => sum + bill.amount, 0),
)
const canPay = computed(() => selectedBills.value.length > 0)
function onBatchPay(): void {
if (!canPay.value) return
emit('batchPay', selectedBills.value.map((bill) => bill.id))
}
</script>
<template>
<div class="bill-summary">
<div class="bill-summary__stats">
<span class="bill-summary__stat">总笔数:<strong>{{ totalCount }}</strong></span>
<span class="bill-summary__stat">总金额:<strong>¥{{ formatMoney(totalAmount) }}</strong></span>
<span class="bill-summary__stat">待支付:<strong>{{ pendingCount }}</strong></span>
</div>
<div class="bill-summary__right">
<span class="bill-summary__selected">
已选择{{ selectedBills.length }}笔订单 · 合计:¥{{ formatMoney(selectedAmount) }}
</span>
<AppButton
type="primary"
:disabled="!canPay"
@click="onBatchPay"
>
立即支付
</AppButton>
</div>
</div>
</template>
<style lang="less" scoped>
.bill-summary {
.flex(row, space-between, center);
padding: @spacing-base @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__stats {
.flex(row, flex-start);
gap: @spacing-lg;
font-size: @font-size-base;
color: @text-color;
strong {
color: @heading-color;
}
}
&__right {
.flex(row, flex-end, center);
gap: @spacing-base;
}
&__selected {
font-size: @font-size-base;
color: @text-color;
}
}
</style>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { StatusTag } from '@/components'
import type { Bill, BillStatus } from '@/types/business/finance'
import { formatMoney } from '@/utils/format'
interface Props {
bills: Bill[]
selectedIds: string[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
view: [id: string]
pay: [id: string]
'update:selection': [ids: string[]]
}>()
/** 本地勾选集合(由 selectedIds prop 同步) */
const selection = ref<Set<string>>(new Set(props.selectedIds))
watch(
() => props.selectedIds,
(ids) => {
selection.value = new Set(ids)
},
)
function toggle(id: string): void {
if (selection.value.has(id)) selection.value.delete(id)
else selection.value.add(id)
emit('update:selection', [...selection.value])
}
function toggleAll(): void {
const allSelected = props.bills.length > 0 && props.bills.every((b) => selection.value.has(b.id))
if (allSelected) {
for (const bill of props.bills) selection.value.delete(bill.id)
}
else {
for (const bill of props.bills) selection.value.add(bill.id)
}
emit('update:selection', [...selection.value])
}
const allSelected = () => props.bills.length > 0 && props.bills.every((b) => selection.value.has(b.id))
const STATUS_VARIANTS: Record<BillStatus, 'warning' | 'success' | 'info'> = {
待支付: 'warning',
已支付: 'success',
未到期: 'info',
}
</script>
<template>
<div class="bill-table card">
<table class="bill-table__table">
<thead>
<tr>
<th class="bill-table__check">
<input
type="checkbox"
:checked="allSelected()"
@change="toggleAll"
>
</th>
<th>序号</th>
<th>账单名称</th>
<th>账单号</th>
<th>交易流水号</th>
<th>项目名称</th>
<th>订单金额</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(bill, index) in bills" :key="bill.id">
<td class="bill-table__check">
<input
type="checkbox"
:checked="selection.has(bill.id)"
@change="toggle(bill.id)"
>
</td>
<td>{{ index + 1 }}</td>
<td>{{ bill.planName }}</td>
<td>{{ bill.billNo }}</td>
<td>{{ bill.tradeNo ?? '—' }}</td>
<td>{{ bill.projectName }}</td>
<td class="bill-table__amount">¥{{ formatMoney(bill.amount) }}</td>
<td>
<StatusTag :label="bill.status" :variant="STATUS_VARIANTS[bill.status]" />
</td>
<td class="bill-table__ops">
<button
v-if="bill.status === '待支付'"
class="bill-table__link"
@click="emit('pay', bill.id)"
>
去支付
</button>
<button class="bill-table__link" @click="emit('view', bill.id)">
详情
</button>
</td>
</tr>
<tr v-if="bills.length === 0">
<td colspan="9" class="bill-table__empty text-secondary">
暂无账单数据
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style lang="less" scoped>
.bill-table {
padding: @spacing-base;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
overflow-x: auto;
&__table {
width: 100%;
border-collapse: collapse;
font-size: @font-size-base;
color: @text-color;
th,
td {
padding: @spacing-sm @spacing-sm;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid @border-color;
}
th {
background: @bg-color-light;
color: @text-color-secondary;
font-weight: 500;
}
tbody tr:hover {
background: @bg-color-light;
}
}
&__check {
width: 40px;
text-align: center;
}
&__amount {
color: @heading-color;
font-weight: 600;
}
&__ops {
.flex(row, flex-start);
gap: @spacing-sm;
}
&__link {
color: @primary-color;
font-size: @font-size-base;
transition: color 0.2s;
&:hover {
color: @primary-hover;
}
}
&__empty {
text-align: center;
padding: @spacing-xl 0;
}
}
</style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed } from 'vue'
interface DistributionItem {
name: string
value: number
color: string
}
interface Props {
distribution: DistributionItem[]
}
const props = defineProps<Props>()
const total = computed(() => props.distribution.reduce((sum, item) => sum + item.value, 0))
/** CSS conic-gradient(无图表库):按 value 占比切分色环 */
const gradient = computed(() => {
if (total.value <= 0) return 'conic-gradient(#e4e7ec 0% 100%)'
let acc = 0
const stops = props.distribution.map((item) => {
const start = acc
acc += (item.value / total.value) * 100
return `${item.color} ${start}% ${acc}%`
})
return `conic-gradient(${stops.join(', ')})`
})
function percentOf(value: number): number {
if (total.value <= 0) return 0
return Math.round((value / total.value) * 100)
}
</script>
<template>
<div class="finance-chart card">
<h3 class="finance-chart__title">
业务类型分布
</h3>
<div class="finance-chart__body">
<div class="finance-chart__pie" :style="{ background: gradient }" />
<ul class="finance-chart__legend">
<li
v-for="item in distribution"
:key="item.name"
class="finance-chart__legend-item"
>
<span
class="finance-chart__dot"
:style="{ background: item.color }"
/>
<span class="finance-chart__name">{{ item.name }}</span>
<span class="finance-chart__value">{{ percentOf(item.value) }}%</span>
</li>
</ul>
</div>
</div>
</template>
<style lang="less" scoped>
.finance-chart {
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__title {
margin-bottom: @spacing-base;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__body {
.flex(row, center, center);
gap: @spacing-xl;
}
&__pie {
width: 180px;
height: 180px;
border-radius: @radius-round;
flex-shrink: 0;
}
&__legend {
.flex(column, flex-start);
gap: @spacing-base;
}
&__legend-item {
.flex(row, flex-start, center);
gap: @spacing-sm;
font-size: @font-size-base;
color: @text-color;
}
&__dot {
width: 10px;
height: 10px;
border-radius: @radius-round;
flex-shrink: 0;
}
&__name {
min-width: 72px;
}
&__value {
color: @heading-color;
font-weight: 600;
}
}
</style>

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import type { FinanceStats } from '@/types/business/finance'
import { formatMoney } from '@/utils/format'
interface Props {
stats: FinanceStats | null
}
defineProps<Props>()
const emit = defineEmits<{
periodChange: [period: string]
export: []
}>()
</script>
<template>
<div class="finance-overview card">
<div class="finance-overview__main">
<p class="finance-overview__label text-secondary">
累计消费总额
</p>
<p class="finance-overview__amount">
¥{{ formatMoney(stats?.totalExpense ?? 0) }}
</p>
<div class="finance-overview__meta">
<div class="finance-overview__periods">
<button
class="finance-overview__period"
@click="emit('periodChange', 'quarter')"
>
本季度
</button>
<button
class="finance-overview__period"
@click="emit('periodChange', 'year')"
>
本年
</button>
</div>
<span class="finance-overview__month text-secondary">{{ stats?.month }}</span>
<StatusTag
v-if="(stats?.pendingCount ?? 0) > 0"
:label="`待支付 ${stats?.pendingCount ?? 0}`"
variant="warning"
/>
</div>
</div>
<div class="finance-overview__actions">
<RouterLink to="/finance/invoices" class="finance-overview__action">
发票申请
</RouterLink>
<RouterLink to="/finance/bills" class="finance-overview__action">
账单明细
</RouterLink>
<button class="finance-overview__action" @click="emit('export')">
报表导出
</button>
</div>
</div>
</template>
<style lang="less" scoped>
.finance-overview {
.flex(row, space-between, center);
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__main {
.flex(column, flex-start);
gap: @spacing-sm;
}
&__label {
font-size: @font-size-base;
}
&__amount {
font-size: 36px;
font-weight: 600;
color: @heading-color;
line-height: 1.2;
}
&__meta {
.flex(row, flex-start, center);
gap: @spacing-base;
font-size: @font-size-base;
}
&__periods {
.flex(row, flex-start);
gap: @spacing-sm;
}
&__period {
height: 28px;
padding: 0 @spacing-sm;
border: 1px solid @border-color;
border-radius: @radius-sm;
background: @white;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
}
&__month {
font-size: @font-size-sm;
}
&__actions {
.flex(row, flex-start);
gap: @spacing-base;
}
&__action {
font-size: @font-size-base;
color: @primary-color;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: @primary-hover;
}
}
}
</style>

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import type { InvoiceTitle } from '@/types/business/finance'
interface Props {
invoiceTitle: InvoiceTitle
}
defineProps<Props>()
const emit = defineEmits<{
setDefault: [id: string]
edit: [item: InvoiceTitle]
delete: [id: string]
}>()
</script>
<template>
<div class="invoice-title-card">
<div class="invoice-title-card__head">
<span class="invoice-title-card__label text-secondary">发票抬头</span>
<StatusTag
:label="invoiceTitle.type"
:variant="invoiceTitle.type === '企业单位' ? 'primary' : 'info'"
/>
<StatusTag v-if="invoiceTitle.isDefault" label="默认" variant="success" />
</div>
<p class="invoice-title-card__title">
{{ invoiceTitle.title }}
</p>
<div class="invoice-title-card__tax">
<span class="invoice-title-card__label text-secondary">税号</span>
<span class="invoice-title-card__tax-no">{{ invoiceTitle.taxNo }}</span>
</div>
<div class="invoice-title-card__actions">
<button
class="invoice-title-card__btn"
@click="emit('setDefault', invoiceTitle.id)"
>
{{ invoiceTitle.isDefault ? '取消默认' : '设为默认' }}
</button>
<button class="invoice-title-card__btn" @click="emit('edit', invoiceTitle)">
编辑
</button>
<button class="invoice-title-card__btn is-danger" @click="emit('delete', invoiceTitle.id)">
删除
</button>
</div>
</div>
</template>
<style lang="less" scoped>
.invoice-title-card {
padding: @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
.flex(column, flex-start);
gap: @spacing-sm;
&__head {
.flex(row, flex-start, center);
gap: @spacing-sm;
}
&__label {
font-size: @font-size-sm;
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
word-break: break-all;
}
&__tax {
.flex(row, flex-start, center);
gap: @spacing-sm;
}
&__tax-no {
font-size: @font-size-base;
color: @text-color;
word-break: break-all;
}
&__actions {
.flex(row, flex-start);
gap: @spacing-sm;
margin-top: @spacing-xs;
}
&__btn {
height: 28px;
padding: 0 @spacing-sm;
border: 1px solid @border-color-dark;
border-radius: @radius-sm;
background: @white;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
&:hover:not(:disabled) {
border-color: @primary-color;
color: @primary-color;
}
&.is-danger:hover:not(:disabled) {
border-color: @danger-color;
color: @danger-color;
}
}
}
</style>

View File

@@ -0,0 +1,192 @@
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { InvoiceTitle, InvoiceTitlePayload } from '@/types/business/finance'
interface Props {
invoiceTitle?: InvoiceTitle
}
const props = defineProps<Props>()
const visible = defineModel<boolean>('visible', { default: false })
const emit = defineEmits<{ save: [payload: InvoiceTitlePayload] }>()
const form = reactive<InvoiceTitlePayload>({
type: '企业单位',
title: '',
taxNo: '',
})
watch(visible, (open) => {
if (!open) return
form.type = props.invoiceTitle?.type ?? '企业单位'
form.title = props.invoiceTitle?.title ?? ''
form.taxNo = props.invoiceTitle?.taxNo ?? ''
})
function onSave(): void {
if (!form.title.trim() || !form.taxNo.trim()) {
ElMessage.warning('请填写发票抬头与税号')
return
}
emit('save', { type: form.type, title: form.title.trim(), taxNo: form.taxNo.trim() })
visible.value = false
}
function onCancel(): void {
visible.value = false
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="title-dialog" @click.self="onCancel">
<div class="title-dialog__panel">
<h3 class="title-dialog__title">
{{ invoiceTitle ? '编辑抬头' : '新增抬头' }}
</h3>
<div class="title-dialog__field">
<span class="title-dialog__label">抬头类型</span>
<label class="title-dialog__radio">
<input v-model="form.type" type="radio" value="企业单位">
企业单位
</label>
<label class="title-dialog__radio">
<input v-model="form.type" type="radio" value="非企业单位">
非企业单位
</label>
</div>
<div class="title-dialog__field">
<span class="title-dialog__label">发票抬头</span>
<input
v-model="form.title"
class="title-dialog__input"
placeholder="请输入发票抬头"
>
</div>
<div class="title-dialog__field">
<span class="title-dialog__label">税号</span>
<input
v-model="form.taxNo"
class="title-dialog__input"
placeholder="请输入税号"
>
</div>
<div class="title-dialog__footer">
<button class="title-dialog__btn" @click="onCancel">
取消
</button>
<button class="title-dialog__btn title-dialog__btn--confirm" @click="onSave">
保存
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style lang="less" scoped>
.title-dialog {
position: fixed;
inset: 0;
z-index: 1000;
.flex(row, center);
background: rgba(0, 0, 0, 0.45);
&__panel {
width: 440px;
max-width: calc(100vw - 32px);
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-lg;
}
&__title {
margin-bottom: @spacing-base;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__field {
.flex(row, flex-start, center);
gap: @spacing-sm;
margin-bottom: @spacing-base;
}
&__label {
width: 72px;
flex-shrink: 0;
font-size: @font-size-base;
color: @text-color;
}
&__radio {
.flex(row, flex-start, center);
gap: @spacing-xs;
font-size: @font-size-base;
color: @text-color;
cursor: pointer;
input {
accent-color: @primary-color;
}
}
&__input {
flex: 1;
height: 34px;
padding: 0 @spacing-sm;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
outline: none;
transition: border-color 0.2s;
&:focus {
border-color: @primary-color;
}
}
&__footer {
.flex(row, flex-end);
gap: @spacing-sm;
margin-top: @spacing-lg;
}
&__btn {
height: 34px;
padding: 0 @spacing-base;
border: 1px solid @border-color-dark;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&--confirm {
border-color: @primary-color;
background: @primary-color;
color: @white;
&:hover {
background: @primary-hover;
color: @white;
}
}
}
}
</style>

View File

@@ -0,0 +1,179 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { PayMethod } from '@/types/business/finance'
interface Props {
method: PayMethod
}
const props = defineProps<Props>()
const visible = defineModel<boolean>('visible', { default: false })
const emit = defineEmits<{ confirm: [method: PayMethod] }>()
const selected = ref<PayMethod>(props.method)
watch(
() => props.method,
(value) => {
selected.value = value
},
)
watch(visible, (open) => {
if (open) selected.value = props.method
})
function onConfirm(): void {
emit('confirm', selected.value)
visible.value = false
}
function onCancel(): void {
visible.value = false
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="pay-method-dialog" @click.self="onCancel">
<div class="pay-method-dialog__panel">
<h3 class="pay-method-dialog__title">
切换支付方式
</h3>
<label
class="pay-method-dialog__option"
:class="{ 'is-active': selected === 'wechat' }"
>
<input
v-model="selected"
type="radio"
value="wechat"
class="pay-method-dialog__radio"
>
<span class="pay-method-dialog__name">微信支付</span>
<span class="pay-method-dialog__tip text-secondary">推荐使用</span>
</label>
<label
class="pay-method-dialog__option"
:class="{ 'is-active': selected === 'alipay' }"
>
<input
v-model="selected"
type="radio"
value="alipay"
class="pay-method-dialog__radio"
>
<span class="pay-method-dialog__name">支付宝支付</span>
<span class="pay-method-dialog__tip text-secondary">其他用户选择</span>
</label>
<div class="pay-method-dialog__footer">
<button class="pay-method-dialog__btn" @click="onCancel">
取消
</button>
<button class="pay-method-dialog__btn pay-method-dialog__btn--confirm" @click="onConfirm">
确认
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style lang="less" scoped>
.pay-method-dialog {
position: fixed;
inset: 0;
z-index: 1000;
.flex(row, center);
background: rgba(0, 0, 0, 0.45);
&__panel {
width: 420px;
max-width: calc(100vw - 32px);
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-lg;
}
&__title {
margin-bottom: @spacing-base;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__option {
.flex(row, flex-start, center);
gap: @spacing-sm;
padding: @spacing-base;
margin-bottom: @spacing-sm;
border: 1px solid @border-color;
border-radius: @radius-base;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-light;
}
}
&__radio {
accent-color: @primary-color;
}
&__name {
font-size: @font-size-base;
color: @heading-color;
font-weight: 500;
}
&__tip {
margin-left: auto;
font-size: @font-size-sm;
}
&__footer {
.flex(row, flex-end);
gap: @spacing-sm;
margin-top: @spacing-base;
}
&__btn {
height: 34px;
padding: 0 @spacing-base;
border: 1px solid @border-color-dark;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&--confirm {
border-color: @primary-color;
background: @primary-color;
color: @white;
&:hover {
background: @primary-hover;
color: @white;
}
}
}
}
</style>

View File

@@ -0,0 +1,8 @@
export { default } from './index.vue'
export { default as FinanceOverview } from './cpns/FinanceOverview.vue'
export { default as FinanceChart } from './cpns/FinanceChart.vue'
export { default as BillTable } from './cpns/BillTable.vue'
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'

110
src/views/finance/index.vue Normal file
View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { RouterLink } from 'vue-router'
import { ElMessage } from 'element-plus'
// 页面容器:只做组件组合与事件中转,数据只经 store
import { useFinanceStore } from '@/stores/modules/finance'
import FinanceChart from './cpns/FinanceChart.vue'
import FinanceOverview from './cpns/FinanceOverview.vue'
const financeStore = useFinanceStore()
const { stats } = storeToRefs(financeStore)
function onPeriodChange(period: string): void {
void financeStore.fetchStats(period)
}
async function onExport(): Promise<void> {
await financeStore.export()
ElMessage.success('报表导出成功(演示)')
}
onMounted(() => {
void financeStore.fetchStats()
})
</script>
<template>
<div class="finance">
<header class="finance__head">
<h1 class="finance__title">
财务中心
</h1>
<p class="finance__slogan text-secondary">
账单支付、发票申请与报表导出一站式管理。
</p>
</header>
<FinanceOverview
:stats="stats"
@period-change="onPeriodChange"
@export="onExport"
/>
<FinanceChart :distribution="stats?.distribution ?? []" />
<div class="finance__entries">
<RouterLink to="/finance/invoices" class="finance__entry">
<span class="finance__entry-icon">🧾</span>
<span class="finance__entry-name">发票申请</span>
</RouterLink>
<RouterLink to="/finance/bills" class="finance__entry">
<span class="finance__entry-icon">📋</span>
<span class="finance__entry-name">账单明细</span>
</RouterLink>
<button class="finance__entry" @click="onExport">
<span class="finance__entry-icon">📊</span>
<span class="finance__entry-name">报表导出</span>
</button>
</div>
</div>
</template>
<style lang="less" scoped>
.finance {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-xl;
padding-bottom: @spacing-xxl;
&__title {
font-size: @font-size-xxl;
font-weight: 600;
color: @heading-color;
}
&__slogan {
margin-top: @spacing-xs;
font-size: @font-size-base;
}
&__entries {
.flex(row, flex-start);
gap: @spacing-base;
}
&__entry {
.flex(row, center, center);
gap: @spacing-sm;
width: 200px;
height: 72px;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-lg;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
}
&__entry-icon {
font-size: @font-size-xl;
}
}
</style>

View File

@@ -0,0 +1,207 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { ElMessage } from 'element-plus'
import { AppButton, ConfirmDialog } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useFinanceStore } from '@/stores/modules/finance'
import type { InvoiceTitle, InvoiceTitlePayload } from '@/types/business/finance'
import InvoiceTitleCard from './cpns/InvoiceTitleCard.vue'
import InvoiceTitleDialog from './cpns/InvoiceTitleDialog.vue'
const financeStore = useFinanceStore()
const { titles } = storeToRefs(financeStore)
type TypeFilter = 'all' | InvoiceTitle['type']
const typeFilter = ref<TypeFilter>('all')
const TYPE_TABS: Array<{ label: string; value: TypeFilter }> = [
{ label: '全部', value: 'all' },
{ label: '企业单位', value: '企业单位' },
{ label: '非企业单位', value: '非企业单位' },
]
const filteredTitles = computed(() =>
typeFilter.value === 'all'
? titles.value
: titles.value.filter((item) => item.type === typeFilter.value),
)
const dialogVisible = ref(false)
const editing = ref<InvoiceTitle | undefined>(undefined)
const confirmVisible = ref(false)
const pendingDeleteId = ref('')
function onCreate(): void {
editing.value = undefined
dialogVisible.value = true
}
function onEdit(item: InvoiceTitle): void {
editing.value = item
dialogVisible.value = true
}
async function onSave(payload: InvoiceTitlePayload): Promise<void> {
if (editing.value) {
await financeStore.updateTitle(editing.value.id, payload)
ElMessage.success('抬头已更新')
}
else {
await financeStore.createTitle(payload)
ElMessage.success('抬头已新增')
}
}
function onDelete(id: string): void {
pendingDeleteId.value = id
confirmVisible.value = true
}
async function onConfirmDelete(): Promise<void> {
await financeStore.deleteTitle(pendingDeleteId.value)
pendingDeleteId.value = ''
ElMessage.success('抬头已删除')
}
async function onSetDefault(id: string): Promise<void> {
const item = titles.value.find((title) => title.id === id)
if (!item) return
if (item.isDefault) {
ElMessage.info('演示环境暂不支持取消默认,可设其他抬头为默认')
return
}
await financeStore.setDefaultTitle(id)
ElMessage.success('已设为默认抬头')
}
onMounted(() => {
void financeStore.fetchTitles()
})
</script>
<template>
<div class="invoice-titles">
<nav class="invoice-titles__breadcrumb text-secondary">
<RouterLink to="/finance" class="link">
财务中心
</RouterLink>
<span> &gt; </span>
<span>发票抬头</span>
</nav>
<header class="invoice-titles__head">
<h2 class="invoice-titles__title">
发票管理
</h2>
<div class="invoice-titles__filters">
<button
v-for="tab in TYPE_TABS"
:key="tab.value"
class="invoice-titles__filter"
:class="{ 'is-active': typeFilter === tab.value }"
@click="typeFilter = tab.value"
>
{{ tab.label }}
</button>
<AppButton type="primary" @click="onCreate">
+新增抬头
</AppButton>
</div>
</header>
<div class="invoice-titles__grid">
<InvoiceTitleCard
v-for="item in filteredTitles"
:key="item.id"
:invoice-title="item"
@set-default="onSetDefault"
@edit="onEdit"
@delete="onDelete"
/>
<p v-if="filteredTitles.length === 0" class="invoice-titles__empty text-secondary">
暂无发票抬头,点击右上角新增
</p>
</div>
<InvoiceTitleDialog
v-model:visible="dialogVisible"
:invoice-title="editing"
@save="onSave"
/>
<ConfirmDialog
v-model:visible="confirmVisible"
title="删除抬头"
message="删除后无法恢复,是否继续?"
confirm-text="删除"
danger
@confirm="onConfirmDelete"
/>
</div>
</template>
<style lang="less" scoped>
.invoice-titles {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
font-size: @font-size-base;
}
&__head {
.flex(row, space-between, center);
flex-wrap: wrap;
gap: @spacing-base;
}
&__title {
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
&__filters {
.flex(row, flex-end, center);
gap: @spacing-sm;
}
&__filter {
height: 34px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-color;
color: @white;
}
}
&__grid {
.flex(row, flex-start);
flex-wrap: wrap;
gap: @spacing-base;
}
&__empty {
padding: @spacing-xl 0;
font-size: @font-size-base;
}
}
</style>

View File

@@ -0,0 +1,364 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { RouterLink } from 'vue-router'
import { ElMessage } from 'element-plus'
import { FilterPanel, PaginationBar, StatusTag } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useFinanceStore } from '@/stores/modules/finance'
import type { Invoice, InvoiceStatus } from '@/types/business/finance'
import { formatMoney } from '@/utils/format'
const financeStore = useFinanceStore()
const { titles, invoices, invoiceQuery, invoiceTotal } = storeToRefs(financeStore)
const keyword = ref('')
const selectedIds = ref<string[]>([])
const defaultTitle = computed(() => titles.value.find((item) => item.isDefault) ?? titles.value[0] ?? null)
const STATUS_TABS: Array<{ label: string; value: InvoiceStatus | 'all' }> = [
{ label: '全部', value: 'all' },
{ label: '已开票', value: '已开票' },
{ label: '开票中', value: '开票中' },
{ label: '未开票', value: '未开票' },
]
const STATUS_VARIANTS: Record<InvoiceStatus, 'success' | 'warning' | 'info'> = {
已开票: 'success',
开票中: 'warning',
未开票: 'info',
}
function onTab(value: InvoiceStatus | 'all'): void {
if (invoiceQuery.value.status === value) return
financeStore.setInvoiceQuery({ ...invoiceQuery.value, status: value, keyword: keyword.value })
selectedIds.value = []
void financeStore.searchInvoices()
}
function onSearch(): void {
financeStore.setInvoiceQuery({ ...invoiceQuery.value, keyword: keyword.value })
void financeStore.searchInvoices()
}
function onReset(): void {
keyword.value = ''
selectedIds.value = []
financeStore.setInvoiceQuery({ keyword: '', status: 'all', page: 1, pageSize: invoiceQuery.value.pageSize })
void financeStore.fetchInvoices()
}
function onPageChange(page: number): void {
void financeStore.changeInvoicePage(page)
}
async function onApply(invoice: Invoice): Promise<void> {
await financeStore.applyInvoice(invoice.id)
ElMessage.success('开票申请已提交')
}
function onPreview(): void {
ElMessage.info('附件待接口接入')
}
function onDownload(): void {
ElMessage.info('附件待接口接入')
}
function onSendEmail(): void {
ElMessage.success('发票已发送至绑定邮箱(演示)')
}
function onToggleSelect(id: string): void {
if (selectedIds.value.includes(id)) {
selectedIds.value = selectedIds.value.filter((item) => item !== id)
}
else {
selectedIds.value = [...selectedIds.value, id]
}
}
onMounted(() => {
keyword.value = invoiceQuery.value.keyword
void Promise.all([financeStore.fetchTitles(), financeStore.fetchInvoices()])
})
</script>
<template>
<div class="invoices">
<nav class="invoices__breadcrumb text-secondary">
<RouterLink to="/finance" class="link">
财务中心
</RouterLink>
<span> &gt; </span>
<span>发票申请</span>
</nav>
<section v-if="defaultTitle" class="invoices__title-card card">
<div class="invoices__title-main">
<div class="invoices__title-head">
<span class="invoices__title-label text-secondary">默认发票抬头</span>
<StatusTag :label="defaultTitle.type" variant="primary" />
<StatusTag label="默认" variant="success" />
</div>
<p class="invoices__title-name">
{{ defaultTitle.title }}
</p>
<p class="invoices__title-tax text-secondary">
税号:{{ defaultTitle.taxNo }}
</p>
</div>
<RouterLink to="/finance/invoice-titles" class="invoices__title-manage">
管理抬头 &gt;
</RouterLink>
</section>
<div class="invoices__tabs">
<button
v-for="tab in STATUS_TABS"
:key="tab.value"
class="invoices__tab"
:class="{ 'is-active': invoiceQuery.status === tab.value }"
@click="onTab(tab.value)"
>
{{ tab.label }}
</button>
</div>
<FilterPanel
v-model:keyword="keyword"
keyword-placeholder="搜索发票抬头 / 账单号 / 项目名称"
@search="onSearch"
@reset="onReset"
/>
<div class="invoices__table card">
<table class="invoices__table-el">
<thead>
<tr>
<th class="invoices__check">
多选
</th>
<th>序号</th>
<th>账单名称</th>
<th>账单号</th>
<th>交易流水号</th>
<th>项目名称</th>
<th>订单金额</th>
<th>状态</th>
<th>申请时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(invoice, index) in invoices" :key="invoice.id">
<td class="invoices__check">
<input
type="checkbox"
:checked="selectedIds.includes(invoice.id)"
@change="onToggleSelect(invoice.id)"
>
</td>
<td>{{ index + 1 }}</td>
<td>{{ invoice.planName }}</td>
<td>{{ invoice.billNo }}</td>
<td>{{ invoice.tradeNo ?? '—' }}</td>
<td>{{ invoice.projectName }}</td>
<td class="invoices__amount">¥{{ formatMoney(invoice.amount) }}</td>
<td>
<StatusTag :label="invoice.status" :variant="STATUS_VARIANTS[invoice.status]" />
</td>
<td>{{ invoice.applyTime }}</td>
<td class="invoices__ops">
<template v-if="invoice.status === '已开票'">
<button class="invoices__link" @click="onPreview">
预览
</button>
<button class="invoices__link" @click="onDownload">
下载
</button>
<button class="invoices__link" @click="onSendEmail">
发送邮箱
</button>
</template>
<span v-else-if="invoice.status === '开票中'" class="invoices__dash">—</span>
<button v-else class="invoices__link" @click="onApply(invoice)">
申请开票
</button>
</td>
</tr>
<tr v-if="invoices.length === 0">
<td colspan="10" class="invoices__empty text-secondary">
暂无发票记录
</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar
:page="invoiceQuery.page"
:page-size="invoiceQuery.pageSize"
:total="invoiceTotal"
@page-change="onPageChange"
/>
<p class="invoices__tip text-secondary">
开票将使用默认抬头,如需调整请前往 <RouterLink to="/finance/invoice-titles" class="link">管理抬头</RouterLink>。
</p>
</div>
</template>
<style lang="less" scoped>
.invoices {
.flex(column, flex-start);
gap: @spacing-base;
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
font-size: @font-size-base;
}
&__title-card {
.flex(row, space-between, center);
padding: @spacing-base @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__title-main {
.flex(column, flex-start);
gap: @spacing-xs;
}
&__title-head {
.flex(row, flex-start, center);
gap: @spacing-sm;
}
&__title-label {
font-size: @font-size-sm;
}
&__title-name {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__title-tax {
font-size: @font-size-base;
}
&__title-manage {
color: @primary-color;
font-size: @font-size-base;
white-space: nowrap;
&:hover {
color: @primary-hover;
}
}
&__tabs {
.flex(row, flex-start);
gap: @spacing-sm;
}
&__tab {
height: 34px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-color;
color: @white;
}
}
&__table {
padding: @spacing-base;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
overflow-x: auto;
}
&__table-el {
width: 100%;
border-collapse: collapse;
font-size: @font-size-base;
color: @text-color;
th,
td {
padding: @spacing-sm;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid @border-color;
}
th {
background: @bg-color-light;
color: @text-color-secondary;
font-weight: 500;
}
tbody tr:hover {
background: @bg-color-light;
}
}
&__check {
width: 48px;
text-align: center;
}
&__amount {
color: @heading-color;
font-weight: 600;
}
&__ops {
.flex(row, flex-start);
gap: @spacing-sm;
}
&__link {
color: @primary-color;
font-size: @font-size-base;
transition: color 0.2s;
&:hover {
color: @primary-hover;
}
}
&__dash {
color: @text-color-secondary;
}
&__empty {
text-align: center;
padding: @spacing-xl 0;
}
&__tip {
font-size: @font-size-sm;
}
}
</style>

View File

@@ -0,0 +1,232 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { AppButton } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useFinanceStore } from '@/stores/modules/finance'
import { formatMoney } from '@/utils/format'
const route = useRoute()
const router = useRouter()
const financeStore = useFinanceStore()
const { result } = storeToRefs(financeStore)
const billId = computed(() => String(route.params.id))
const methodLabel = computed(() => {
if (!result.value) return '—'
return result.value.method === 'wechat' ? '微信支付' : '支付宝支付'
})
onMounted(() => {
void financeStore.fetchResult(billId.value)
})
</script>
<template>
<div class="pay-result">
<nav class="pay-result__breadcrumb text-secondary">
<RouterLink to="/finance" class="link">
财务中心
</RouterLink>
<span> &gt; </span>
<RouterLink to="/finance/bills" class="link">
账单明细
</RouterLink>
<span> &gt; </span>
<span>支付结果</span>
</nav>
<template v-if="result">
<section class="pay-result__card card">
<div class="pay-result__head">
<span class="pay-result__check">✓</span>
<div class="pay-result__head-text">
<h2 class="pay-result__title">
支付成功
</h2>
<p class="pay-result__amount">
付款金额 ¥{{ formatMoney(result.amount) }}
</p>
<p class="pay-result__count text-secondary">
共{{ result.bills.length }}个订单
</p>
</div>
</div>
<div class="pay-result__grid">
<div class="pay-result__grid-item">
<span class="pay-result__grid-label text-secondary">支付方式</span>
<span class="pay-result__grid-value">{{ methodLabel }}</span>
</div>
<div class="pay-result__grid-item">
<span class="pay-result__grid-label text-secondary">支付时间</span>
<span class="pay-result__grid-value">{{ result.payTime }}</span>
</div>
<div class="pay-result__grid-item">
<span class="pay-result__grid-label text-secondary">交易流水号</span>
<span class="pay-result__grid-value">{{ result.tradeNo }}</span>
</div>
</div>
<div class="pay-result__bills">
<h4 class="pay-result__sub-title">
已支付订单
</h4>
<ul class="pay-result__bill-list">
<li
v-for="bill in result.bills"
:key="bill.id"
class="pay-result__bill"
>
<span class="pay-result__bill-name">{{ bill.planName }}</span>
<span class="pay-result__bill-no text-secondary">{{ bill.billNo }}</span>
<span class="pay-result__bill-amount">¥{{ formatMoney(bill.amount) }}</span>
</li>
</ul>
</div>
<div class="pay-result__footer">
<AppButton type="primary" @click="router.push('/finance/bills')">
返回
</AppButton>
</div>
</section>
</template>
<p v-else class="pay-result__empty text-secondary">
暂无支付结果
</p>
</div>
</template>
<style lang="less" scoped>
.pay-result {
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
margin-bottom: @spacing-base;
font-size: @font-size-base;
}
&__card {
padding: @spacing-xl @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__head {
.flex(row, flex-start, center);
gap: @spacing-base;
padding-bottom: @spacing-lg;
border-bottom: 1px solid @border-color;
}
&__check {
.flex(row, center);
width: 56px;
height: 56px;
border-radius: @radius-round;
background: fade(@success-color, 12%);
color: @success-color;
font-size: 32px;
font-weight: 600;
flex-shrink: 0;
}
&__title {
font-size: @font-size-xl;
font-weight: 600;
color: @heading-color;
}
&__amount {
margin-top: @spacing-xs;
font-size: @font-size-lg;
color: @danger-color;
font-weight: 600;
}
&__count {
margin-top: @spacing-xs;
font-size: @font-size-base;
}
&__grid {
.flex(row, flex-start, flex-start);
gap: @spacing-xxl;
margin-top: @spacing-lg;
}
&__grid-item {
.flex(column, flex-start);
gap: @spacing-xs;
}
&__grid-label {
font-size: @font-size-sm;
}
&__grid-value {
font-size: @font-size-base;
color: @heading-color;
word-break: break-all;
}
&__bills {
margin-top: @spacing-lg;
padding-top: @spacing-base;
border-top: 1px solid @border-color;
}
&__sub-title {
margin-bottom: @spacing-sm;
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__bill-list {
.flex(column, flex-start);
gap: @spacing-sm;
list-style: none;
padding: 0;
}
&__bill {
.flex(row, flex-start, center);
gap: @spacing-base;
padding: @spacing-sm;
border-radius: @radius-sm;
background: @bg-color-light;
font-size: @font-size-base;
color: @text-color;
}
&__bill-name {
color: @heading-color;
}
&__bill-no {
flex: 1;
}
&__bill-amount {
font-weight: 600;
color: @heading-color;
}
&__footer {
margin-top: @spacing-lg;
}
&__empty {
padding: @spacing-xl 0;
text-align: center;
font-size: @font-size-base;
}
}
</style>

291
src/views/finance/pay.vue Normal file
View File

@@ -0,0 +1,291 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { AppButton, QrCodePanel } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useFinanceStore } from '@/stores/modules/finance'
import type { PayMethod } from '@/types/business/finance'
import { formatMoney } from '@/utils/format'
import PayMethodDialog from './cpns/PayMethodDialog.vue'
const route = useRoute()
const router = useRouter()
const financeStore = useFinanceStore()
const { payment, currentBill } = storeToRefs(financeStore)
const billId = computed(() => String(route.params.id))
const dialogVisible = ref(false)
const method = computed<PayMethod>(() => payment.value?.method ?? 'wechat')
const methodLabel = computed(() => (method.value === 'wechat' ? '微信支付' : '支付宝支付'))
const steps = computed<string[]>(() =>
method.value === 'wechat'
? [
'打开手机微信',
'扫描左侧二维码',
'输入支付密码完成支付,本页将自动跳转至支付结果页',
]
: [
'打开手机支付宝',
'对准屏幕上方的二维码进行扫描,核对金额后确认支付',
'输入支付密码完成支付',
],
)
function onRefresh(): void {
void financeStore.switchPaymentMethod(billId.value, method.value)
ElMessage.success('二维码已刷新')
}
function onConfirmMethod(next: PayMethod): void {
void financeStore.switchPaymentMethod(billId.value, next)
ElMessage.success('二维码已刷新')
}
async function onPaid(): Promise<void> {
await financeStore.createPayment(billId.value, method.value)
void router.push(`/finance/bills/${billId.value}/pay/result`)
}
onMounted(() => {
void Promise.all([
financeStore.createPayment(billId.value, 'wechat'),
financeStore.fetchDetail(billId.value),
])
})
</script>
<template>
<div class="pay">
<nav class="pay__breadcrumb text-secondary">
<RouterLink to="/finance" class="link">
财务中心
</RouterLink>
<span> &gt; </span>
<RouterLink to="/finance/bills" class="link">
账单明细
</RouterLink>
<span> &gt; </span>
<span>扫码支付</span>
</nav>
<div class="pay__body">
<section class="pay__left card">
<QrCodePanel
:expires-in="payment?.qrExpiresInSeconds ?? 300"
@refresh="onRefresh"
/>
<p class="pay__safe text-secondary">
安全提示:本页面已通过安全验证,请勿向他人泄露付款码
</p>
<AppButton @click="dialogVisible = true">
切换支付方式
</AppButton>
</section>
<section class="pay__right card">
<div class="pay__amount-block">
<p class="pay__amount-label text-secondary">
支付金额
</p>
<p class="pay__amount">¥{{ formatMoney(payment?.amount ?? currentBill?.amount ?? 0) }}</p>
<p class="pay__method">
{{ methodLabel }} · 请使用手机微信 <strong>[扫一扫]</strong> 扫描下方二维码完成支付
</p>
</div>
<div class="pay__detail">
<h4 class="pay__sub-title">
订单明细
</h4>
<ul class="pay__detail-list">
<li class="pay__detail-item">
<span class="pay__detail-label text-secondary">账单号</span>
<span>{{ currentBill?.billNo ?? '—' }}</span>
</li>
<li class="pay__detail-item">
<span class="pay__detail-label text-secondary">关联项目</span>
<span>{{ currentBill?.projectName ?? '—' }}</span>
</li>
<li class="pay__detail-item">
<span class="pay__detail-label text-secondary">期数</span>
<span>{{ currentBill?.planName ?? '—' }}</span>
</li>
<li class="pay__detail-item">
<span class="pay__detail-label text-secondary">金额</span>
<span class="pay__detail-amount">¥{{ formatMoney(payment?.amount ?? currentBill?.amount ?? 0) }}</span>
</li>
</ul>
</div>
<div class="pay__steps">
<h4 class="pay__sub-title">
扫码支付操作步骤
</h4>
<ol class="pay__step-list">
<li v-for="(step, index) in steps" :key="step" class="pay__step">
<span class="pay__step-index">{{ index + 1 }}</span>
<span>{{ step }}</span>
</li>
</ol>
</div>
<div class="pay__footer">
<AppButton type="primary" size="lg" @click="onPaid">
完成支付
</AppButton>
<AppButton @click="router.push('/finance/bills')">
返回
</AppButton>
</div>
</section>
</div>
<PayMethodDialog
v-model:visible="dialogVisible"
:method="method"
@confirm="onConfirmMethod"
/>
</div>
</template>
<style lang="less" scoped>
.pay {
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
margin-bottom: @spacing-base;
font-size: @font-size-base;
}
&__body {
.flex(row, flex-start);
gap: @spacing-base;
align-items: flex-start;
}
&__left {
.flex(column, center, center);
gap: @spacing-base;
width: 320px;
flex-shrink: 0;
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__safe {
font-size: @font-size-sm;
text-align: center;
line-height: 1.6;
}
&__right {
flex: 1;
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__amount-block {
padding-bottom: @spacing-base;
border-bottom: 1px solid @border-color;
}
&__amount-label {
font-size: @font-size-base;
}
&__amount {
margin: @spacing-xs 0;
font-size: 32px;
font-weight: 600;
color: @danger-color;
line-height: 1.2;
}
&__method {
font-size: @font-size-base;
color: @text-color;
strong {
color: @primary-color;
}
}
&__detail,
&__steps {
margin-top: @spacing-base;
}
&__sub-title {
margin-bottom: @spacing-sm;
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__detail-list {
.flex(column, flex-start);
gap: @spacing-sm;
}
&__detail-item {
.flex(row, flex-start, center);
gap: @spacing-base;
font-size: @font-size-base;
color: @heading-color;
}
&__detail-label {
width: 72px;
flex-shrink: 0;
}
&__detail-amount {
font-weight: 600;
color: @danger-color;
}
&__step-list {
.flex(column, flex-start);
gap: @spacing-sm;
padding-left: 0;
list-style: none;
}
&__step {
.flex(row, flex-start, center);
gap: @spacing-sm;
font-size: @font-size-base;
color: @text-color;
}
&__step-index {
.flex(row, center);
width: 22px;
height: 22px;
border-radius: @radius-round;
background: @primary-light;
color: @primary-color;
font-size: @font-size-sm;
flex-shrink: 0;
}
&__footer {
.flex(row, flex-start, center);
gap: @spacing-base;
margin-top: @spacing-lg;
padding-top: @spacing-base;
border-top: 1px solid @border-color;
}
}
</style>