feat 搭建架构骨架
This commit is contained in:
226
src/views/finance/bill-detail.vue
Normal file
226
src/views/finance/bill-detail.vue
Normal 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> > </span>
|
||||
<RouterLink to="/finance/bills" class="link">
|
||||
账单明细
|
||||
</RouterLink>
|
||||
<span> > </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
158
src/views/finance/bills.vue
Normal 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> > </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>
|
||||
88
src/views/finance/cpns/BillSummaryBar.vue
Normal file
88
src/views/finance/cpns/BillSummaryBar.vue
Normal 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>
|
||||
182
src/views/finance/cpns/BillTable.vue
Normal file
182
src/views/finance/cpns/BillTable.vue
Normal 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>
|
||||
115
src/views/finance/cpns/FinanceChart.vue
Normal file
115
src/views/finance/cpns/FinanceChart.vue
Normal 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>
|
||||
136
src/views/finance/cpns/FinanceOverview.vue
Normal file
136
src/views/finance/cpns/FinanceOverview.vue
Normal 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>
|
||||
116
src/views/finance/cpns/InvoiceTitleCard.vue
Normal file
116
src/views/finance/cpns/InvoiceTitleCard.vue
Normal 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>
|
||||
192
src/views/finance/cpns/InvoiceTitleDialog.vue
Normal file
192
src/views/finance/cpns/InvoiceTitleDialog.vue
Normal 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>
|
||||
179
src/views/finance/cpns/PayMethodDialog.vue
Normal file
179
src/views/finance/cpns/PayMethodDialog.vue
Normal 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>
|
||||
8
src/views/finance/index.ts
Normal file
8
src/views/finance/index.ts
Normal 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
110
src/views/finance/index.vue
Normal 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>
|
||||
207
src/views/finance/invoice-titles.vue
Normal file
207
src/views/finance/invoice-titles.vue
Normal 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> > </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>
|
||||
364
src/views/finance/invoices.vue
Normal file
364
src/views/finance/invoices.vue
Normal 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> > </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">
|
||||
管理抬头 >
|
||||
</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>
|
||||
232
src/views/finance/pay-result.vue
Normal file
232
src/views/finance/pay-result.vue
Normal 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> > </span>
|
||||
<RouterLink to="/finance/bills" class="link">
|
||||
账单明细
|
||||
</RouterLink>
|
||||
<span> > </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
291
src/views/finance/pay.vue
Normal 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> > </span>
|
||||
<RouterLink to="/finance/bills" class="link">
|
||||
账单明细
|
||||
</RouterLink>
|
||||
<span> > </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>
|
||||
Reference in New Issue
Block a user