feat 合同中心

This commit is contained in:
Ironsp
2026-10-09 17:17:43 +08:00
parent fd1eca95a6
commit 123d259f92
18 changed files with 799 additions and 649 deletions

View File

@@ -34,6 +34,8 @@ defineProps<Props>()
} }
&__slogan { &__slogan {
// 设计稿 #20:slogan 水平居中(当前唯一使用方为财务中心页)
text-align: center;
padding-bottom: 0; padding-bottom: 0;
margin-top: 9px; margin-top: 9px;
font-size: 13px; font-size: 13px;

View File

@@ -275,3 +275,12 @@
@finance-entry-text: #4b5563; @finance-entry-text: #4b5563;
@finance-axis-text: #9ca3af; @finance-axis-text: #9ca3af;
@finance-ink: #1f2937; @finance-ink: #1f2937;
// 财务中心首页快捷入口图标(设计稿 #20,蓝湖 fallback 无切图,色值取自封面采样)
@finance-entry-bill-bg: #e9f1fe;
@finance-entry-bill-icon: #1f67ef;
@finance-entry-pending-bg: #fff4e5;
@finance-entry-invoice-bg: #f1ecfe;
@finance-entry-invoice-icon: #7c5ce0;
@finance-entry-export-bg: #e6f6ec;
@finance-entry-badge-bg: #f53f3f;
@finance-chart-switch-active: #024fff;

View File

@@ -118,9 +118,9 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
&__section { &__section {
position: absolute; position: absolute;
left: 43px; left: 54px;
top: 0; top: 0;
height: 0; height: 70px;
&--1 { top: 36px; } &--1 { top: 36px; }
&--2 { top: 649px; } &--2 { top: 649px; }
@@ -130,11 +130,13 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
&__section-title { &__section-title {
position: absolute; position: absolute;
left: 60px; left: 60px;
top: 23px;
margin: 0; margin: 0;
font-size: 28px; font-size: 28px;
font-weight: 500; font-weight: 500;
line-height: 41px; line-height: 41px;
color: @contract-cell-text; color: @contract-cell-text;
white-space: nowrap; // 父级 h3 内子元素全部脱流,标题会逐字换行,强制不换行
} }
&__step { &__step {
@@ -164,8 +166,8 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
&__field--5:has(&__label) { top: 749px; } &__field--5:has(&__label) { top: 749px; }
&__field--l { left: 64px; } &__field--l { left: 54px; }
&__field--r { left: 940px; } &__field--r { left: 930px; }
&__label { &__label {
font-size: 20px; font-size: 20px;

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ElTable, ElTableColumn } from 'element-plus'
import { StatusTag } from '@/components' import { StatusTag } from '@/components'
import type { Contract, SignStatus } from '@/types/business/contract' import type { Contract, SignStatus } from '@/types/business/contract'
import { formatMoney } from '@/utils/format' import { formatMoney } from '@/utils/format'
@@ -23,125 +24,96 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
<template> <template>
<div class="contract-table"> <div class="contract-table">
<table class="contract-table__grid"> <el-table
<thead> :data="contracts"
<tr> border
<th>合同编号</th> style="width: 100%"
<th>合同名称</th> >
<th>合同类型</th> <el-table-column prop="contractNo" label="合同编号" show-overflow-tooltip />
<th>合同金额</th> <el-table-column prop="contractName" label="合同名称" show-overflow-tooltip />
<th>关联项目</th> <el-table-column prop="contractType" label="合同类型" show-overflow-tooltip />
<th>项目编号</th> <el-table-column label="合同金额" show-overflow-tooltip>
<th>签署状态</th> <template #default="{ row }">
<th>生效时间</th> ¥{{ formatMoney((row as Contract).contractAmount) }}
<th>到期时间</th> </template>
<th>操作</th> </el-table-column>
</tr> <el-table-column prop="relatedProjectName" label="关联项目" show-overflow-tooltip />
</thead> <el-table-column prop="projectNo" label="项目编号" show-overflow-tooltip />
<tbody> <el-table-column label="签署状态" width="140">
<tr v-for="item in contracts" :key="item.id"> <template #default="{ row }">
<td>{{ item.contractNo }}</td> <StatusTag :label="(row as Contract).signStatus" :variant="SIGN_VARIANTS[(row as Contract).signStatus]" />
<td class="contract-table__name" :title="item.contractName"> </template>
{{ item.contractName }} </el-table-column>
</td> <el-table-column prop="effectiveDate" label="生效时间" show-overflow-tooltip />
<td>{{ item.contractType }}</td> <el-table-column prop="expiryDate" label="到期时间" show-overflow-tooltip />
<td>¥{{ formatMoney(item.contractAmount) }}</td> <el-table-column label="操作" width="180">
<td class="contract-table__name" :title="item.relatedProjectName"> <template #default="{ row }">
{{ item.relatedProjectName }} <div class="contract-table__actions">
</td>
<td>{{ item.projectNo }}</td>
<td>
<StatusTag :label="item.signStatus" :variant="SIGN_VARIANTS[item.signStatus]" />
</td>
<td>{{ item.effectiveDate }}</td>
<td>{{ item.expiryDate }}</td>
<td class="contract-table__actions">
<button <button
v-if="item.signStatus === '待签署'" v-if="(row as Contract).signStatus === '待签署'"
type="button"
class="contract-table__btn contract-table__btn--primary" class="contract-table__btn contract-table__btn--primary"
@click="emit('sign', item.id)" @click="emit('sign', (row as Contract).id)"
> >
去签署 去签署
</button> </button>
<button class="contract-table__btn" @click="emit('view', item.id)"> <button
type="button"
class="contract-table__btn"
@click="emit('view', (row as Contract).id)"
>
详情 详情
</button> </button>
</td> </div>
</tr> </template>
<tr v-if="contracts.length === 0"> </el-table-column>
<td colspan="10" class="contract-table__empty text-secondary"> <template #empty>
暂无符合条件的合同 <span class="contract-table__empty">暂无符合条件的合同</span>
</td> </template>
</tr> </el-table>
</tbody>
</table>
</div> </div>
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
.contract-table { .contract-table {
overflow-x: auto; :deep(.el-table) {
.scrollbar();
&__grid {
width: 100%;
border: 1px solid @contract-line;
border-collapse: collapse;
table-layout: fixed;
font-size: 20px; font-size: 20px;
--el-table-border-color: @contract-line;
--el-table-header-bg-color: @contract-table-head-bg;
--el-table-header-text-color: @contract-head-text;
--el-table-text-color: @contract-cell-text;
--el-table-row-hover-bg-color: @bg-color-light;
background: @white;
th, .el-table__cell {
td { padding: 0;
}
.cell {
padding: 0 10px; padding: 0 10px;
border-bottom: 1px solid @contract-line;
vertical-align: middle;
white-space: nowrap;
font-size: 20px; font-size: 20px;
}
td.contract-table__name {
white-space: normal;
word-break: break-all;
}
th {
height: 64px;
padding-top: 0;
padding-bottom: 0;
background: @contract-table-head-bg;
color: @contract-head-text;
font-weight: 400;
text-align: center;
}
td {
height: 96px;
color: @contract-cell-text;
line-height: 29px; line-height: 29px;
} }
tbody tr:last-child td { th.el-table__cell {
border-bottom: none; height: 64px;
font-weight: 400;
background: @contract-table-head-bg;
.cell {
color: @contract-head-text;
text-align: center;
}
} }
tbody tr:hover td { td.el-table__cell {
height: 96px;
color: @contract-cell-text;
}
.el-table__row:hover > td.el-table__cell {
background: @bg-color-light; background: @bg-color-light;
} }
th:nth-child(1) { width: 11%; }
th:nth-child(2) { width: 9%; }
th:nth-child(3) { width: 10%; }
th:nth-child(4) { width: 9%; }
th:nth-child(5) { width: 10%; }
th:nth-child(6) { width: 11%; }
th:nth-child(7) { width: 7%; }
th:nth-child(8) { width: 11%; }
th:nth-child(9) { width: 11%; }
th:nth-child(10) { width: 11%; }
}
&__name {
color: @contract-cell-text;
} }
&__actions { &__actions {
@@ -152,6 +124,9 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
&__btn { &__btn {
font-size: 13px; font-size: 13px;
color: @contract-accent; color: @contract-accent;
background: transparent;
border: none;
cursor: pointer;
transition: opacity 0.2s; transition: opacity 0.2s;
&:hover { &:hover {
@@ -161,7 +136,6 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
&--primary { &--primary {
height: 28px; height: 28px;
padding: 0 @spacing-sm; padding: 0 @spacing-sm;
border: none;
border-radius: @radius-sm; border-radius: @radius-sm;
background: @contract-accent; background: @contract-accent;
color: @white; color: @white;
@@ -173,6 +147,12 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
} }
} }
&__empty {
display: block;
padding: @spacing-xl 0;
color: @text-color-secondary;
}
:deep(.status-tag) { :deep(.status-tag) {
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
@@ -180,10 +160,5 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
color: inherit; color: inherit;
font-size: inherit; font-size: inherit;
} }
&__empty {
padding: @spacing-xl 0;
text-align: center;
}
} }
</style> </style>

View File

@@ -1,15 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, watch } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router' import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { AppButton } from '@/components'
import type { FileInfo } from '@/types/api' import type { FileInfo } from '@/types/api'
import { useContractStore } from '@/stores/modules/contract' import { useContractStore } from '@/stores/modules/contract'
import ContractInfoCard from './cpns/ContractInfoCard.vue' import ContractInfoCard from './cpns/ContractInfoCard.vue'
const route = useRoute() const route = useRoute()
const router = useRouter()
const contractStore = useContractStore() const contractStore = useContractStore()
const { current } = storeToRefs(contractStore) const { current } = storeToRefs(contractStore)
@@ -19,10 +17,6 @@ function load(): void {
void contractStore.fetchDetail(contractId.value) void contractStore.fetchDetail(contractId.value)
} }
function onBack(): void {
void router.push('/contract')
}
function onViewProject(): void { function onViewProject(): void {
// TODO: 关联项目 id 由接口返回后跳转项目详情 // TODO: 关联项目 id 由接口返回后跳转项目详情
ElMessage.info('项目详情跳转待接口接入') ElMessage.info('项目详情跳转待接口接入')
@@ -65,12 +59,6 @@ onMounted(load)
<p v-else class="contract-detail__empty text-secondary"> <p v-else class="contract-detail__empty text-secondary">
合同不存在或已下线 合同不存在或已下线
</p> </p>
<div class="contract-detail__footer">
<AppButton @click="onBack">
返回
</AppButton>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -91,28 +79,6 @@ onMounted(load)
color: @contract-muted; color: @contract-muted;
} }
&__footer {
.flex(row, flex-end);
width: 100%;
margin-top: 30px;
:deep(.app-button) {
height: 40px;
padding: 0 32px;
border: none;
border-radius: @radius-base;
background: @primary-strong;
color: @white;
font-size: 16px;
}
:deep(.app-button:hover) {
border-color: transparent;
background: @primary-color;
color: @white;
}
}
&__empty { &__empty {
padding: @spacing-xxl 0; padding: @spacing-xxl 0;
text-align: center; text-align: center;

View File

@@ -2,6 +2,7 @@
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, watch } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import { useFinanceStore } from '@/stores/modules/finance' import { useFinanceStore } from '@/stores/modules/finance'
import type { Bill, BillStatus } from '@/types/business/finance' import type { Bill, BillStatus } from '@/types/business/finance'
import { formatMoney } from '@/utils/format' import { formatMoney } from '@/utils/format'
@@ -122,20 +123,20 @@ onMounted(() => {
</div> </div>
</div> </div>
<button <el-button
v-if="card.status === '待支付'" v-if="card.status === '待支付'"
class="bill-detail__cta" class="bill-detail__cta"
@click="onPay(currentBill.id)" @click="onPay(currentBill.id)"
> >
立即支付 立即支付
</button> </el-button>
<button <el-button
v-else v-else
class="bill-detail__cta" class="bill-detail__cta"
@click="router.push('/finance/bills')" @click="router.push('/finance/bills')"
> >
返回 返回
</button> </el-button>
</section> </section>
</div> </div>
@@ -350,12 +351,12 @@ onMounted(() => {
width: 90px; width: 90px;
height: 32px; height: 32px;
border: none; border: none;
padding: 0;
border-radius: 4px; border-radius: 4px;
background: @primary-strong; background: @primary-strong;
color: @white; color: @white;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
padding-bottom: 6px;
box-sizing: border-box; box-sizing: border-box;
transform: translateX(-50%); transform: translateX(-50%);
transition: background 0.2s; transition: background 0.2s;

View File

@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { PaginationBar } from '@/components' import { PaginationBar } from '@/components'
import { ElRadioGroup, ElRadioButton, ElInput, ElButton } from 'element-plus'
import { useFinanceStore } from '@/stores/modules/finance' import { useFinanceStore } from '@/stores/modules/finance'
import type { BillStatus } from '@/types/business/finance' import type { BillStatus } from '@/types/business/finance'
import BillSummaryBar from './cpns/BillSummaryBar.vue' import BillSummaryBar from './cpns/BillSummaryBar.vue'
@@ -35,6 +36,10 @@ function onTab(value: BillStatus | 'all'): void {
void financeStore.search() void financeStore.search()
} }
function onTabChange(val: string | number | boolean | undefined): void {
onTab(val as BillStatus | 'all')
}
function onSearch(): void { function onSearch(): void {
financeStore.setQuery({ ...query.value, keyword: keyword.value }) financeStore.setQuery({ ...query.value, keyword: keyword.value })
void financeStore.search() void financeStore.search()
@@ -87,31 +92,34 @@ onMounted(() => {
</div> </div>
<div class="bills__toolbar"> <div class="bills__toolbar">
<div class="bills__tabs"> <el-radio-group
<button class="bills__tabs"
:model-value="query.status"
@change="onTabChange"
>
<el-radio-button
v-for="tab in STATUS_TABS" v-for="tab in STATUS_TABS"
:key="tab.value" :key="tab.value"
class="bills__tab" :value="tab.value"
:class="{ 'is-active': query.status === tab.value }"
@click="onTab(tab.value)"
> >
{{ tab.label }} {{ tab.label }}
</button> </el-radio-button>
</div> </el-radio-group>
<input <el-input
v-model="keyword" v-model="keyword"
class="bills__search" class="bills__search"
placeholder="关键字搜索" placeholder="关键字搜索"
clearable
@keyup.enter="onSearch" @keyup.enter="onSearch"
> />
<button class="bills__btn bills__btn--reset" @click="onReset"> <el-button class="bills__btn bills__btn--reset" @click="onReset">
重置 重置
</button> </el-button>
<button class="bills__btn bills__btn--primary" @click="onSearch"> <el-button class="bills__btn bills__btn--primary" @click="onSearch">
搜索 搜索
</button> </el-button>
</div> </div>
<div class="bills__board"> <div class="bills__board">
@@ -220,44 +228,32 @@ onMounted(() => {
} }
&__tabs { &__tabs {
.flex(row, flex-start, center);
gap: 0;
width: 279px;
height: 34px;
border: 1px solid @border-color;
border-radius: 4px;
overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
box-sizing: border-box;
padding: 0 11.3px 0 9.2px;
}
&__tab { :deep(.el-radio-button__inner) {
flex: 1 1 0; height: 34px;
height: 32px; padding: 0 11.3px;
border: none; border: 1px solid @border-color;
border-right: 1px solid @border-color; border-radius: 4px;
border-radius: 0; background: @white;
background: @white; color: @finance-entry-text;
color: @finance-entry-text; font-size: 12px;
font-size: 12px; font-weight: 400;
font-weight: 400; line-height: 32px;
transition: all 0.2s; box-shadow: none;
&:last-child {
border-right: none;
} }
&:hover { :deep(.el-radio-button + .el-radio-button) {
color: @primary-strong; margin-left: -1px;
} }
&.is-active { :deep(.el-radio-button.is-active .el-radio-button__inner) {
border-right-color: @primary-strong; border-color: @primary-strong;
background: @primary-strong; background: @primary-strong;
color: @white; color: @white;
font-size: 11px; font-size: 11px;
font-weight: 500; font-weight: 500;
box-shadow: -1px 0 0 0 @primary-strong;
} }
} }
@@ -265,18 +261,24 @@ onMounted(() => {
width: 1300px; width: 1300px;
height: 37px; height: 37px;
margin-left: 33px; margin-left: 33px;
padding: 0 16px;
border: none;
border-radius: 0;
background: @finance-field-bg;
color: @finance-entry-text;
font-size: 13px;
outline: none;
box-sizing: border-box;
flex-shrink: 0; flex-shrink: 0;
&::placeholder { :deep(.el-input__wrapper) {
color: @contract-placeholder; width: 1300px;
height: 37px;
padding: 0 16px;
background: @finance-field-bg;
box-shadow: none;
border-radius: 0;
}
:deep(.el-input__inner) {
color: @finance-entry-text;
font-size: 13px;
&::placeholder {
color: @contract-placeholder;
}
} }
} }
@@ -284,7 +286,6 @@ onMounted(() => {
height: 32px; height: 32px;
border: none; border: none;
border-radius: 0; border-radius: 0;
background: transparent;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
flex-shrink: 0; flex-shrink: 0;
@@ -293,9 +294,11 @@ onMounted(() => {
width: 62.2px; width: 62.2px;
margin-left: 15.4px; margin-left: 15.4px;
color: @finance-entry-text; color: @finance-entry-text;
background: transparent;
&:hover { &:hover {
color: @primary-strong; color: @primary-strong;
background: transparent;
} }
} }

View File

@@ -2,7 +2,7 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage, ElButton } from 'element-plus'
import { useFinanceStore } from '@/stores/modules/finance' import { useFinanceStore } from '@/stores/modules/finance'
import type { PayMethod } from '@/types/business/finance' import type { PayMethod } from '@/types/business/finance'
import { createMerchantOrderNo, PAY_METHODS } from '@/utils/payMeta' import { createMerchantOrderNo, PAY_METHODS } from '@/utils/payMeta'
@@ -172,13 +172,13 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<button <el-button
class="checkout__cta" class="checkout__cta"
:disabled="!canPay" :disabled="!canPay"
@click="onConfirm" @click="onConfirm"
> >
{{ submitting ? '处理中…' : '确认支付' }} {{ submitting ? '处理中…' : '确认支付' }}
</button> </el-button>
<p class="checkout__summary-tip"> <p class="checkout__summary-tip">
支付完成后订单状态将自动更新 支付完成后订单状态将自动更新
@@ -191,9 +191,9 @@ onBeforeUnmount(() => {
<p class="checkout__empty-text"> <p class="checkout__empty-text">
未找到待支付账单,请返回账单明细重新选择 未找到待支付账单,请返回账单明细重新选择
</p> </p>
<button class="checkout__empty-btn" @click="goBack"> <el-button class="checkout__empty-btn" @click="goBack">
返回账单明细 返回账单明细
</button> </el-button>
</section> </section>
<WechatQrDialog <WechatQrDialog
@@ -283,6 +283,8 @@ onBeforeUnmount(() => {
&__cta { &__cta {
width: 100%; width: 100%;
height: 44px; height: 44px;
border: none;
padding: 0;
border-radius: @radius-round; border-radius: @radius-round;
background: @checkout-cta; background: @checkout-cta;
color: @white; color: @white;
@@ -329,6 +331,12 @@ onBeforeUnmount(() => {
font-size: @font-size-base; font-size: @font-size-base;
} }
:deep(.checkout__empty-btn.el-button) {
border: 1px solid @checkout-cta;
background: @checkout-card;
color: @checkout-cta;
}
@media (max-width: 992px) { @media (max-width: 992px) {
&__layout { &__layout {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { ElCheckbox, ElButton } from 'element-plus'
import type { Bill } from '@/types/business/finance' import type { Bill } from '@/types/business/finance'
import { formatMoney } from '@/utils/format' import { formatMoney } from '@/utils/format'
@@ -30,12 +31,11 @@ function onBatchPay(): void {
<div class="bill-summary"> <div class="bill-summary">
<div class="bill-summary__inner"> <div class="bill-summary__inner">
<div class="bill-summary__left"> <div class="bill-summary__left">
<input <el-checkbox
class="bill-summary__check" class="bill-summary__check"
type="checkbox" :model-value="canPay"
:checked="canPay"
@change="onBatchPay" @change="onBatchPay"
> />
<span class="bill-summary__text">已选择</span> <span class="bill-summary__text">已选择</span>
<span class="bill-summary__count">{{ selectedBills.length }}</span> <span class="bill-summary__count">{{ selectedBills.length }}</span>
<span class="bill-summary__text">笔订单</span> <span class="bill-summary__text">笔订单</span>
@@ -44,9 +44,9 @@ function onBatchPay(): void {
<div class="bill-summary__right"> <div class="bill-summary__right">
<span class="bill-summary__text">合计:</span> <span class="bill-summary__text">合计:</span>
<span class="bill-summary__amount">¥ {{ formatMoney(selectedAmount, 0) }}</span> <span class="bill-summary__amount">¥ {{ formatMoney(selectedAmount, 0) }}</span>
<button class="bill-summary__cta" :disabled="!canPay" @click="onBatchPay"> <el-button class="bill-summary__cta" :disabled="!canPay" @click="onBatchPay">
立即支付 立即支付
</button> </el-button>
</div> </div>
</div> </div>
</div> </div>
@@ -77,11 +77,15 @@ function onBatchPay(): void {
} }
&__check { &__check {
width: 15px;
height: 15px; height: 15px;
margin: 0 12.25px 0 0; margin: 0 12.25px 0 0;
accent-color: @primary-strong;
flex-shrink: 0; flex-shrink: 0;
:deep(.el-checkbox__inner) {
width: 15px;
height: 15px;
border-color: @border-color;
}
} }
&__text { &__text {
@@ -118,6 +122,7 @@ function onBatchPay(): void {
width: 84px; width: 84px;
height: 40px; height: 40px;
border: none; border: none;
padding: 0;
border-radius: 4px; border-radius: 4px;
background: @finance-summary-cta; background: @finance-summary-cta;
color: @white; color: @white;

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { ref, watch } from 'vue'
import { ElTable, ElTableColumn } from 'element-plus'
import type { TableInstance } from 'element-plus'
import type { Bill } from '@/types/business/finance' import type { Bill } from '@/types/business/finance'
import { formatMoney } from '@/utils/format' import { formatMoney } from '@/utils/format'
@@ -16,223 +18,158 @@ const emit = defineEmits<{
'update:selection': [ids: string[]] 'update:selection': [ids: string[]]
}>() }>()
const selection = ref<Set<string>>(new Set(props.selectedIds)) const tableRef = ref<TableInstance>()
function onSelectionChange(rows: Bill[]): void {
emit('update:selection', rows.map((row) => row.id))
}
// 外部清空选中(如重置查询)时同步清空 el-table 选择态
watch( watch(
() => props.selectedIds, () => props.selectedIds,
(ids) => { (ids) => {
selection.value = new Set(ids) if (ids.length === 0 && tableRef.value) tableRef.value.clearSelection()
}, },
) )
const allSelected = computed(
() => props.bills.length > 0 && props.bills.every((bill) => selection.value.has(bill.id)),
)
const someSelected = computed(
() => props.bills.some((bill) => selection.value.has(bill.id)) && !allSelected.value,
)
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 {
if (allSelected.value) {
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 COLUMNS = [
{ key: 'index', label: '序号', className: 'bill-table__col--index' },
{ key: 'planName', label: '账单名称', className: 'bill-table__col--name' },
{ key: 'billNo', label: '账单号', className: 'bill-table__col--no' },
{ key: 'tradeNo', label: '交易流水号', className: 'bill-table__col--trade' },
{ key: 'projectName', label: '项目名称', className: 'bill-table__col--project' },
{ key: 'amount', label: '订单金额', className: 'bill-table__col--amount' },
{ key: 'status', label: '状态', className: 'bill-table__col--status' },
{ key: 'ops', label: '操作', className: 'bill-table__col--ops' },
] as const
</script> </script>
<template> <template>
<table class="bill-table"> <el-table
<colgroup> ref="tableRef"
<col class="bill-table__col--check"> class="bill-table"
<col :data="bills"
v-for="column in COLUMNS" row-key="id"
:key="column.key" @selection-change="onSelectionChange"
:class="column.className" >
> <el-table-column
</colgroup> type="selection"
<thead> width="51"
<tr> />
<th class="bill-table__cell bill-table__cell--check"> <el-table-column
<input type="index"
type="checkbox" label="序号"
:checked="allSelected" width="235"
:indeterminate="someSelected" />
@change="toggleAll" <el-table-column
prop="planName"
label="账单名称"
width="310"
show-overflow-tooltip
/>
<el-table-column
prop="billNo"
label="账单号"
width="148"
show-overflow-tooltip
/>
<el-table-column
prop="tradeNo"
label="交易流水号"
width="394"
show-overflow-tooltip
/>
<el-table-column
prop="projectName"
label="项目名称"
width="339"
show-overflow-tooltip
/>
<el-table-column
label="订单金额"
width="97"
>
<template #default="{ row }">
{{ formatMoney((row as Bill).amount, 0) }}
</template>
</el-table-column>
<el-table-column
label="状态"
width="95"
>
<template #default="{ row }">
{{ (row as Bill).status }}
</template>
</el-table-column>
<el-table-column
label="操作"
width="96"
fixed="right"
>
<template #default="{ row }">
<span class="bill-table__ops">
<el-button
v-if="(row as Bill).status === '待支付'"
link
type="primary"
class="bill-table__link"
@click="emit('pay', (row as Bill).id)"
> >
</th> 去支付
<th </el-button>
v-for="column in COLUMNS" <el-button
:key="column.key" link
class="bill-table__cell" type="primary"
> class="bill-table__link"
{{ column.label }} @click="emit('view', (row as Bill).id)"
</th>
</tr>
</thead>
<tbody>
<tr v-for="(bill, index) in bills" :key="bill.id">
<td class="bill-table__cell bill-table__cell--check">
<input
type="checkbox"
:checked="selection.has(bill.id)"
@change="toggle(bill.id)"
> >
</td> 详情
<td class="bill-table__cell"> </el-button>
{{ index + 1 }} </span>
</td> </template>
<td class="bill-table__cell"> </el-table-column>
{{ bill.planName }}
</td> <template #empty>
<td class="bill-table__cell"> <span class="bill-table__empty">暂无账单数据</span>
{{ bill.billNo }} </template>
</td> </el-table>
<td class="bill-table__cell">
{{ bill.tradeNo ?? '—' }}
</td>
<td class="bill-table__cell">
{{ bill.projectName }}
</td>
<td class="bill-table__cell">
{{ formatMoney(bill.amount, 0) }}
</td>
<td class="bill-table__cell">
{{ bill.status }}
</td>
<td class="bill-table__cell bill-table__cell--ops">
<span 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>
</span>
</td>
</tr>
<tr v-if="bills.length === 0">
<td colspan="9" class="bill-table__empty">
暂无账单数据
</td>
</tr>
</tbody>
</table>
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
.bill-table { .bill-table {
width: 1765px; width: 1765px;
margin: 6.5px 0 0 14.5px; margin: 6.5px 0 0 14.5px;
border-collapse: collapse;
table-layout: fixed;
font-size: 12px; font-size: 12px;
color: @finance-entry-text; color: @finance-entry-text;
&__col--check { width: 51px; } :deep(.el-table__header-wrapper th.el-table__cell) {
&__col--index { width: 235.4px; }
&__col--name { width: 310.2px; }
&__col--no { width: 148px; }
&__col--trade { width: 394.1px; }
&__col--project { width: 339.4px; }
&__col--amount { width: 97px; }
&__col--status { width: 95px; }
&__col--ops { width: 95.5px; }
&__cell {
height: 43.77px;
padding: 0;
border-bottom: 1px solid @project-divider;
text-align: left;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-sizing: border-box;
&:not(&--check):not(&--ops) {
padding-left: 10px;
}
&--check {
padding-left: 43.5px;
}
&--ops {
padding-right: 22px;
text-align: right;
}
}
@col-pad-1: 43.5px;
@col-pad-2: 149.9px;
@col-pad-3: 8px;
@col-pad-4: 17px;
@col-pad-5: 5px;
@col-pad-6: 14.6px;
@col-pad-7: 4.6px;
@col-pad-8: 6px;
thead &__cell,
tbody &__cell {
&:nth-child(1) { padding-left: @col-pad-1; }
&:nth-child(2) { padding-left: @col-pad-2; }
&:nth-child(3) { padding-left: @col-pad-3; }
&:nth-child(4) { padding-left: @col-pad-4; }
&:nth-child(5) { padding-left: @col-pad-5; }
&:nth-child(6) { padding-left: @col-pad-6; }
&:nth-child(7) { padding-left: @col-pad-7; }
&:nth-child(8) { padding-left: @col-pad-8; }
&:nth-child(9) { padding-left: 0; padding-right: 10px; }
}
thead &__cell {
height: 41.77px; height: 41.77px;
padding: 0;
background: @finance-table-head-bg; background: @finance-table-head-bg;
border-right: 1px solid @project-divider; border-right: 1px solid @project-divider;
border-bottom: 1px solid @project-divider;
color: @finance-entry-text; color: @finance-entry-text;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
text-align: left;
&:first-child {
border-left: 1px solid @project-divider;
}
} }
tbody tr:hover { :deep(.el-table__header-wrapper th.el-table__cell:first-child) {
border-left: 1px solid @project-divider;
}
:deep(.el-table__body-wrapper td.el-table__cell) {
height: 43.77px;
padding: 0;
border-bottom: 1px solid @project-divider;
box-sizing: border-box;
}
:deep(.el-table__body tr:hover > td.el-table__cell) {
background: @bg-color-light; background: @bg-color-light;
} }
&__cell--check input { :deep(.el-table .cell) {
width: 14px; padding-left: 10px;
height: 14px; padding-right: 10px;
margin: 0; line-height: normal;
accent-color: @primary-strong; white-space: nowrap;
vertical-align: middle; overflow: hidden;
text-overflow: ellipsis;
}
// 首列(多选)左缩进对齐设计稿
:deep(.el-table-column--selection .cell) {
padding-left: 43.5px;
} }
&__ops { &__ops {
@@ -240,31 +177,18 @@ const COLUMNS = [
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 18px;
white-space: nowrap; white-space: nowrap;
} }
&__link { &__link {
padding: 0;
border: none;
background: transparent;
color: @primary-strong;
font-size: 12px; font-size: 12px;
font-weight: 400; font-weight: 400;
transition: color 0.2s; padding: 0;
+ .bill-table__link {
margin-left: 18.36px;
}
&:hover {
color: @primary-color;
}
} }
&__empty { &__empty {
height: 43.77px;
color: @project-muted; color: @project-muted;
text-align: center;
} }
} }
</style> </style>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { ElRadioGroup, ElRadioButton } from 'element-plus'
interface DistributionItem { interface DistributionItem {
name: string name: string
@@ -26,13 +27,18 @@ const gradient = computed(() => {
return `conic-gradient(${stops.join(', ')})` return `conic-gradient(${stops.join(', ')})`
}) })
function percentOf(value: number): number {
if (total.value <= 0) return 0
return Math.round((value / total.value) * 100)
}
const AXIS = [250, 200, 150, 100, 50] const AXIS = [250, 200, 150, 100, 50]
const AXIS_MAX = AXIS[0]
const MONTHS = ['1月', '2月', '3月', '4月', '5月'] const MONTHS = ['1月', '2月', '3月', '4月', '5月']
// 设计稿 #20 右上角切换:月度账单(激活)/ 上半
const period = ref<'month' | 'half'>('month')
// 柱高按 Y 轴量程绝对值换算(设计稿 #20:Y 轴 250 为满量程),非占比
function heightPercentOf(value: number): number {
if (value <= 0) return 0
return Math.min((value / AXIS_MAX) * 100, 100)
}
</script> </script>
<template> <template>
@@ -41,6 +47,15 @@ const MONTHS = ['1月', '2月', '3月', '4月', '5月']
业务类型分布 业务类型分布
</h3> </h3>
<el-radio-group v-model="period" class="finance-chart__switch">
<el-radio-button value="month">
月度账单
</el-radio-button>
<el-radio-button value="half">
上半
</el-radio-button>
</el-radio-group>
<div class="finance-chart__body"> <div class="finance-chart__body">
<ul class="finance-chart__axis" aria-hidden="true"> <ul class="finance-chart__axis" aria-hidden="true">
<li v-for="v in AXIS" :key="v" class="finance-chart__axis-item"> <li v-for="v in AXIS" :key="v" class="finance-chart__axis-item">
@@ -55,10 +70,10 @@ const MONTHS = ['1月', '2月', '3月', '4月', '5月']
<div class="finance-chart__bars"> <div class="finance-chart__bars">
<span <span
v-for="item in distribution" v-for="(m, i) in MONTHS"
:key="item.name" :key="m"
class="finance-chart__bar" class="finance-chart__bar"
:style="{ height: `${Math.max(percentOf(item.value), 6)}%` }" :style="{ height: `${heightPercentOf(distribution[i]?.value ?? 0)}%` }"
/> />
</div> </div>
@@ -78,6 +93,10 @@ const MONTHS = ['1月', '2月', '3月', '4月', '5月']
:key="item.name" :key="item.name"
class="finance-chart__legend-item" class="finance-chart__legend-item"
> >
<i
class="finance-chart__dot"
:style="{ background: item.color }"
/>
<span class="finance-chart__name">{{ item.name }}</span> <span class="finance-chart__name">{{ item.name }}</span>
</li> </li>
</ul> </ul>
@@ -116,6 +135,34 @@ const MONTHS = ['1月', '2月', '3月', '4月', '5月']
} }
} }
&__switch {
position: absolute;
top: 25px;
right: 23px;
:deep(.el-radio-button__inner) {
border: none;
border-radius: 0;
padding: 0;
background: transparent;
box-shadow: none;
font-size: 11px;
font-weight: 500;
line-height: 15px;
color: @finance-muted-text;
transition: color 0.2s;
}
:deep(.el-radio-button.is-active .el-radio-button__inner) {
background: transparent;
color: @finance-chart-switch-active;
}
:deep(.el-radio-button + .el-radio-button) {
margin-left: 14px;
}
}
&__body { &__body {
.flex(row, flex-start, flex-start); .flex(row, flex-start, flex-start);
gap: 0; gap: 0;
@@ -126,7 +173,8 @@ const MONTHS = ['1月', '2月', '3月', '4月', '5月']
.flex(column, space-between, flex-start); .flex(column, space-between, flex-start);
width: 34px; width: 34px;
height: 234px; height: 234px;
margin: 0; // 设计稿 #20:Y 轴刻度文字左缘 x=103(卡左 70 + padding 23 + 10)
margin: 0 0 0 10px;
padding: 0; padding: 0;
list-style: none; list-style: none;
box-sizing: border-box; box-sizing: border-box;
@@ -164,7 +212,7 @@ const MONTHS = ['1月', '2月', '3月', '4月', '5月']
.flex(row, space-around, flex-end); .flex(row, space-around, flex-end);
position: absolute; position: absolute;
inset: 0; inset: 0;
padding: 0 40px; // 无横向 padding:柱子与下方月份标签同为 space-around,中心才能对齐(设计稿 #20 柱月对齐)
} }
&__bar { &__bar {
@@ -200,7 +248,8 @@ const MONTHS = ['1月', '2月', '3月', '4月', '5月']
.flex(row, flex-start, center); .flex(row, flex-start, center);
gap: 27px; gap: 27px;
margin: 41px 0 0; margin: 41px 0 0;
padding: 0 0 0 662px; // 设计稿 #20:图例文字左缘 x=809(卡左 70 + padding 23 + 704 + 圆点 6 + gap 6)
padding: 0 0 0 704px;
list-style: none; list-style: none;
} }
@@ -212,6 +261,13 @@ const MONTHS = ['1月', '2月', '3月', '4月', '5月']
color: @finance-muted-text; color: @finance-muted-text;
} }
&__dot {
flex-shrink: 0;
width: 6px;
height: 6px;
border-radius: 50%;
}
&__name { &__name {
min-width: 0; min-width: 0;
} }

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ElButton } from 'element-plus'
import type { InvoiceTitle } from '@/types/business/finance' import type { InvoiceTitle } from '@/types/business/finance'
interface Props { interface Props {
@@ -20,18 +21,30 @@ const emit = defineEmits<{
<span v-if="invoiceTitle.isDefault" class="invoice-title-card__badge">默认抬头</span> <span v-if="invoiceTitle.isDefault" class="invoice-title-card__badge">默认抬头</span>
<span v-else class="invoice-title-card__badge-placeholder" /> <span v-else class="invoice-title-card__badge-placeholder" />
<div class="invoice-title-card__actions"> <div class="invoice-title-card__actions">
<button <el-button
link
type="primary"
class="invoice-title-card__btn" class="invoice-title-card__btn"
@click="emit('setDefault', invoiceTitle.id)" @click="emit('setDefault', invoiceTitle.id)"
> >
{{ invoiceTitle.isDefault ? '取消默认' : '设为默认' }} {{ invoiceTitle.isDefault ? '取消默认' : '设为默认' }}
</button> </el-button>
<button class="invoice-title-card__btn" @click="emit('delete', invoiceTitle.id)"> <el-button
link
type="primary"
class="invoice-title-card__btn"
@click="emit('delete', invoiceTitle.id)"
>
删除 删除
</button> </el-button>
<button class="invoice-title-card__btn" @click="emit('edit', invoiceTitle)"> <el-button
link
type="primary"
class="invoice-title-card__btn"
@click="emit('edit', invoiceTitle)"
>
编辑 编辑
</button> </el-button>
</div> </div>
</div> </div>
@@ -104,6 +117,7 @@ const emit = defineEmits<{
&:hover { &:hover {
color: @primary-strong; color: @primary-strong;
background: transparent;
} }
} }

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, watch } from 'vue' import { reactive, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElInput, ElRadio, ElButton } from 'element-plus'
import type { InvoiceTitle, InvoiceTitlePayload } from '@/types/business/finance' import type { InvoiceTitle, InvoiceTitlePayload } from '@/types/business/finance'
interface Props { interface Props {
@@ -50,41 +50,39 @@ function onCancel(): void {
<div class="title-dialog__field"> <div class="title-dialog__field">
<span class="title-dialog__label">抬头类型</span> <span class="title-dialog__label">抬头类型</span>
<label class="title-dialog__radio"> <el-radio v-model="form.type" value="企业单位">
<input v-model="form.type" type="radio" value="企业单位">
企业单位 企业单位
</label> </el-radio>
<label class="title-dialog__radio"> <el-radio v-model="form.type" value="非企业单位">
<input v-model="form.type" type="radio" value="非企业单位">
非企业单位 非企业单位
</label> </el-radio>
</div> </div>
<div class="title-dialog__field"> <div class="title-dialog__field">
<span class="title-dialog__label">发票抬头</span> <span class="title-dialog__label">发票抬头</span>
<input <el-input
v-model="form.title" v-model="form.title"
class="title-dialog__input" class="title-dialog__input"
placeholder="请输入发票抬头" placeholder="请输入发票抬头"
> />
</div> </div>
<div class="title-dialog__field"> <div class="title-dialog__field">
<span class="title-dialog__label">税号</span> <span class="title-dialog__label">税号</span>
<input <el-input
v-model="form.taxNo" v-model="form.taxNo"
class="title-dialog__input" class="title-dialog__input"
placeholder="请输入税号" placeholder="请输入税号"
> />
</div> </div>
<div class="title-dialog__footer"> <div class="title-dialog__footer">
<button class="title-dialog__btn" @click="onCancel"> <el-button class="title-dialog__btn" @click="onCancel">
取消 取消
</button> </el-button>
<button class="title-dialog__btn title-dialog__btn--confirm" @click="onSave"> <el-button class="title-dialog__btn title-dialog__btn--confirm" @click="onSave">
保存 保存
</button> </el-button>
</div> </div>
</div> </div>
</div> </div>
@@ -119,6 +117,11 @@ function onCancel(): void {
.flex(row, flex-start, center); .flex(row, flex-start, center);
gap: @spacing-sm; gap: @spacing-sm;
margin-bottom: @spacing-base; margin-bottom: @spacing-base;
:deep(.el-radio__label) {
color: @text-color;
font-size: @font-size-base;
}
} }
&__label { &__label {
@@ -128,31 +131,19 @@ function onCancel(): void {
color: @text-color; 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 { &__input {
flex: 1; 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 { :deep(.el-input__wrapper) {
border-color: @primary-color; height: 34px;
background: @bg-color-light;
box-shadow: none;
border-radius: @radius-base;
}
:deep(.el-input__inner) {
color: @text-color;
font-size: @font-size-base;
} }
} }

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { ElRadio, ElButton } from 'element-plus'
import type { PayMethod } from '@/types/business/finance' import type { PayMethod } from '@/types/business/finance'
interface Props { interface Props {
@@ -47,13 +48,13 @@ function onCancel(): void {
class="pay-method-dialog__option" class="pay-method-dialog__option"
:class="{ 'is-active': selected === 'wechat' }" :class="{ 'is-active': selected === 'wechat' }"
> >
<input <el-radio
v-model="selected" v-model="selected"
type="radio"
value="wechat" value="wechat"
class="pay-method-dialog__radio" class="pay-method-dialog__radio"
> >
<span class="pay-method-dialog__name">微信支付</span> 微信支付
</el-radio>
<span class="pay-method-dialog__tip text-secondary">推荐使用</span> <span class="pay-method-dialog__tip text-secondary">推荐使用</span>
</label> </label>
@@ -61,23 +62,23 @@ function onCancel(): void {
class="pay-method-dialog__option" class="pay-method-dialog__option"
:class="{ 'is-active': selected === 'alipay' }" :class="{ 'is-active': selected === 'alipay' }"
> >
<input <el-radio
v-model="selected" v-model="selected"
type="radio"
value="alipay" value="alipay"
class="pay-method-dialog__radio" class="pay-method-dialog__radio"
> >
<span class="pay-method-dialog__name">支付宝支付</span> 支付宝支付
</el-radio>
<span class="pay-method-dialog__tip text-secondary">其他用户选择</span> <span class="pay-method-dialog__tip text-secondary">其他用户选择</span>
</label> </label>
<div class="pay-method-dialog__footer"> <div class="pay-method-dialog__footer">
<button class="pay-method-dialog__btn" @click="onCancel"> <el-button class="pay-method-dialog__btn" @click="onCancel">
取消 取消
</button> </el-button>
<button class="pay-method-dialog__btn pay-method-dialog__btn--confirm" @click="onConfirm"> <el-button class="pay-method-dialog__btn pay-method-dialog__btn--confirm" @click="onConfirm">
确认 确认
</button> </el-button>
</div> </div>
</div> </div>
</div> </div>
@@ -129,13 +130,11 @@ function onCancel(): void {
} }
&__radio { &__radio {
accent-color: @primary-color; .el-radio__label {
} color: @heading-color;
font-size: @font-size-base;
&__name { font-weight: 500;
font-size: @font-size-base; }
color: @heading-color;
font-weight: 500;
} }
&__tip { &__tip {
@@ -158,7 +157,6 @@ function onCancel(): void {
color: @text-color; color: @text-color;
font-size: @font-size-base; font-size: @font-size-base;
transition: all 0.2s; transition: all 0.2s;
&:hover { &:hover {
border-color: @primary-color; border-color: @primary-color;
color: @primary-color; color: @primary-color;

View File

@@ -13,10 +13,10 @@ const financeStore = useFinanceStore()
const { stats } = storeToRefs(financeStore) const { stats } = storeToRefs(financeStore)
const ENTRIES = [ const ENTRIES = [
{ label: '账单明细', to: '/finance/bills' }, { key: 'bill', label: '账单明细', to: '/finance/bills' },
{ label: '待支付', to: '/finance/bills' }, { key: 'pending', label: '待支付', to: '/finance/bills' },
{ label: '发票申请', to: '/finance/invoices' }, { key: 'invoice', label: '发票申请', to: '/finance/invoices' },
{ label: '报表导出', to: '' }, { key: 'export', label: '报表导出', to: '' },
] as const ] as const
function onPeriodChange(period: string): void { function onPeriodChange(period: string): void {
@@ -49,20 +49,108 @@ onMounted(() => {
/> />
<div class="finance__entries"> <div class="finance__entries">
<template v-for="entry in ENTRIES" :key="entry.label"> <template v-for="entry in ENTRIES" :key="entry.key">
<RouterLink <RouterLink
v-if="entry.to" v-if="entry.to"
:to="entry.to" :to="entry.to"
class="finance__entry" class="finance__entry"
> >
{{ entry.label }} <span
class="finance__entry-icon"
:class="`finance__entry-icon--${entry.key}`"
>
<svg
v-if="entry.key === 'bill'"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect
x="4"
y="2.5"
width="12"
height="15"
rx="1.5"
/>
<path d="M7 7h6M7 10h6M7 13h4" />
</svg>
<svg
v-else-if="entry.key === 'pending'"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect
x="2.5"
y="4.5"
width="15"
height="11"
rx="1.5"
/>
<path d="M2.5 8.5h15" />
</svg>
<svg
v-else-if="entry.key === 'invoice'"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect
x="4"
y="2.5"
width="12"
height="15"
rx="1.5"
/>
<path d="M7.5 7h5M7.5 10.5h5M7.5 14h2.5" />
</svg>
<svg
v-else
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 13V4M6.5 7.5 10 4l3.5 3.5M4 15.5h12" />
</svg>
<i
v-if="entry.key === 'pending' && (stats?.pendingCount ?? 0) > 0"
class="finance__entry-badge"
>
{{ stats?.pendingCount }}
</i>
</span>
<span class="finance__entry-label">{{ entry.label }}</span>
</RouterLink> </RouterLink>
<button <button
v-else v-else
class="finance__entry" class="finance__entry"
@click="onExport" @click="onExport"
> >
{{ entry.label }} <span class="finance__entry-icon finance__entry-icon--export">
<svg
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 13V4M6.5 7.5 10 4l3.5 3.5M4 15.5h12" />
</svg>
</span>
<span class="finance__entry-label">{{ entry.label }}</span>
</button> </button>
</template> </template>
</div> </div>
@@ -87,7 +175,8 @@ onMounted(() => {
.flex(row, space-between, center); .flex(row, space-between, center);
flex: 0 0 672px; flex: 0 0 672px;
height: 135px; height: 135px;
padding: 0 34px; // 设计稿 #20:首入口中心 x=1272、尾中心 x=1756,两侧对称 padding 66px 命中
padding: 0 66px;
border: 1px solid @finance-card-line; border: 1px solid @finance-card-line;
border-radius: @radius-base; border-radius: @radius-base;
background: @white; background: @white;
@@ -95,8 +184,10 @@ onMounted(() => {
} }
&__entry { &__entry {
.flex(row, center, center); .flex(column, center, center);
height: 100%; gap: 8px;
// 等宽保证 space-between 下四入口中心等距 161.5px(设计稿 #20)
width: 56px;
border: none; border: none;
background: transparent; background: transparent;
color: @finance-entry-text; color: @finance-entry-text;
@@ -110,5 +201,56 @@ onMounted(() => {
color: @primary-strong; color: @primary-strong;
} }
} }
&__entry-icon {
position: relative;
.flex(row, center, center);
width: 40px;
height: 40px;
border-radius: 10px;
svg {
width: 20px;
height: 20px;
}
&--bill {
background: @finance-entry-bill-bg;
color: @finance-entry-bill-icon;
}
&--pending {
background: @finance-entry-pending-bg;
color: @finance-pending;
}
&--invoice {
background: @finance-entry-invoice-bg;
color: @finance-entry-invoice-icon;
}
&--export {
background: @finance-entry-export-bg;
color: @finance-paid;
}
}
&__entry-badge {
position: absolute;
top: -7px;
// 设计稿 #20:badge 叠在图标右上内侧(右缘距图标右约 7px)
right: 7px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: @finance-entry-badge-bg;
color: @white;
font-size: 9px;
font-style: normal;
line-height: 16px;
text-align: center;
box-sizing: border-box;
}
} }
</style> </style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { ElMessage } from 'element-plus' import { ElMessage, ElButton, ElPagination } from 'element-plus'
import { ConfirmDialog } from '@/components' import { ConfirmDialog } from '@/components'
import { useFinanceStore } from '@/stores/modules/finance' import { useFinanceStore } from '@/stores/modules/finance'
import type { InvoiceTitle, InvoiceTitlePayload } from '@/types/business/finance' import type { InvoiceTitle, InvoiceTitlePayload } from '@/types/business/finance'
@@ -24,7 +24,6 @@ const pageCount = computed(() => Math.max(1, Math.ceil(titles.value.length / PAG
const pagedTitles = computed(() => const pagedTitles = computed(() =>
titles.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE), titles.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE),
) )
const pages = computed(() => Array.from({ length: Math.max(5, pageCount.value) }, (_, i) => i + 1))
function go(next: number): void { function go(next: number): void {
if (next < 1 || next > pageCount.value) return if (next < 1 || next > pageCount.value) return
@@ -89,9 +88,9 @@ onMounted(() => {
<h2 class="invoice-titles__title"> <h2 class="invoice-titles__title">
发票管理 发票管理
</h2> </h2>
<button class="invoice-titles__create" @click="onCreate"> <el-button class="invoice-titles__create" @click="onCreate">
+ 新增抬头 + 新增抬头
</button> </el-button>
</header> </header>
<div class="invoice-titles__list"> <div class="invoice-titles__list">
@@ -108,23 +107,14 @@ onMounted(() => {
</p> </p>
</div> </div>
<nav class="invoice-titles__pager" aria-label="分页"> <el-pagination
<button class="invoice-titles__page" :disabled="page <= 1" @click="go(page - 1)"> class="invoice-titles__pager"
‹ layout="prev, pager, next"
</button> :page-size="PAGE_SIZE"
<button :total="titles.length"
v-for="item in pages" :current-page="page"
:key="item" @current-change="go"
class="invoice-titles__page" />
:class="{ 'is-active': item === page }"
@click="go(item)"
>
{{ item }}
</button>
<button class="invoice-titles__page" :disabled="page >= pageCount" @click="go(page + 1)">
›
</button>
</nav>
</div> </div>
<InvoiceTitleDialog <InvoiceTitleDialog
@@ -197,6 +187,7 @@ onMounted(() => {
height: 34px; height: 34px;
margin-right: -5px; margin-right: -5px;
border: none; border: none;
padding: 0;
border-radius: 4px; border-radius: 4px;
background: @primary-strong; background: @primary-strong;
color: @white; color: @white;
@@ -223,33 +214,34 @@ onMounted(() => {
} }
&__pager { &__pager {
.flex(row, flex-end, center);
gap: 3.7px;
margin-top: 8px; margin-top: 8px;
padding-right: 2.4px; padding-right: 2.4px;
} justify-content: flex-end;
&__page { :deep(.el-pagination button),
min-width: 21.45px; :deep(.el-pagination .el-pager li) {
height: 16px; min-width: 21.45px;
padding: 0 3px; height: 16px;
border: none; padding: 0 3px;
border-radius: 0; border: none;
background: transparent; border-radius: 0;
color: @finance-muted-text; background: transparent;
font-size: 12px; color: @finance-muted-text;
font-weight: 400; font-size: 12px;
transition: color 0.2s; font-weight: 400;
transition: color 0.2s;
}
&:hover:not(:disabled) { :deep(.el-pagination .el-pager li.is-active) {
color: @primary-strong;
font-weight: 500;
}
:deep(.el-pagination button:hover:not(:disabled)) {
color: @primary-strong; color: @primary-strong;
} }
&.is-active { :deep(.el-pagination button:disabled) {
color: @primary-strong;
}
&:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.5; opacity: 0.5;
} }

View File

@@ -2,7 +2,7 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage, ElButton, ElRadioGroup, ElRadioButton, ElTable, ElTableColumn } from 'element-plus'
import { FilterPanel, PaginationBar, StatusTag } from '@/components' import { FilterPanel, PaginationBar, StatusTag } from '@/components'
import { useFinanceStore } from '@/stores/modules/finance' import { useFinanceStore } from '@/stores/modules/finance'
import type { Invoice, InvoiceStatus } from '@/types/business/finance' import type { Invoice, InvoiceStatus } from '@/types/business/finance'
@@ -36,6 +36,10 @@ function onTab(value: InvoiceStatus | 'all'): void {
void financeStore.searchInvoices() void financeStore.searchInvoices()
} }
function onTabChange(val: string | number | boolean | undefined): void {
onTab(val as InvoiceStatus | 'all')
}
function onSearch(): void { function onSearch(): void {
financeStore.setInvoiceQuery({ ...invoiceQuery.value, keyword: keyword.value }) financeStore.setInvoiceQuery({ ...invoiceQuery.value, keyword: keyword.value })
void financeStore.searchInvoices() void financeStore.searchInvoices()
@@ -69,13 +73,8 @@ function onSendEmail(): void {
ElMessage.success('发票已发送至绑定邮箱(演示)') ElMessage.success('发票已发送至绑定邮箱(演示)')
} }
function onToggleSelect(id: string): void { function onSelectionChange(rows: Invoice[]): void {
if (selectedIds.value.includes(id)) { selectedIds.value = rows.map((row) => row.id)
selectedIds.value = selectedIds.value.filter((item) => item !== id)
}
else {
selectedIds.value = [...selectedIds.value, id]
}
} }
onMounted(() => { onMounted(() => {
@@ -113,17 +112,19 @@ onMounted(() => {
</RouterLink> </RouterLink>
</section> </section>
<div class="invoices__tabs"> <el-radio-group
<button class="invoices__tabs"
:model-value="invoiceQuery.status"
@change="onTabChange"
>
<el-radio-button
v-for="tab in STATUS_TABS" v-for="tab in STATUS_TABS"
:key="tab.value" :key="tab.value"
class="invoices__tab" :value="tab.value"
:class="{ 'is-active': invoiceQuery.status === tab.value }"
@click="onTab(tab.value)"
> >
{{ tab.label }} {{ tab.label }}
</button> </el-radio-button>
</div> </el-radio-group>
<FilterPanel <FilterPanel
v-model:keyword="keyword" v-model:keyword="keyword"
@@ -133,67 +134,121 @@ onMounted(() => {
/> />
<div class="invoices__table card"> <div class="invoices__table card">
<table class="invoices__table-el"> <el-table
<thead> class="invoices__table-el"
<tr> :data="invoices"
<th class="invoices__check"> row-key="id"
多选 @selection-change="onSelectionChange"
</th> >
<th>序号</th> <el-table-column
<th>账单名称</th> type="selection"
<th>账单号</th> width="48"
<th>交易流水号</th> />
<th>项目名称</th> <el-table-column
<th>订单金额</th> type="index"
<th>状态</th> label="序号"
<th>申请时间</th> width="60"
<th>操作</th> />
</tr> <el-table-column
</thead> prop="planName"
<tbody> label="账单名称"
<tr v-for="(invoice, index) in invoices" :key="invoice.id"> min-width="180"
<td class="invoices__check"> show-overflow-tooltip
<input />
type="checkbox" <el-table-column
:checked="selectedIds.includes(invoice.id)" prop="billNo"
@change="onToggleSelect(invoice.id)" label="账单号"
> width="150"
</td> show-overflow-tooltip
<td>{{ index + 1 }}</td> />
<td>{{ invoice.planName }}</td> <el-table-column
<td>{{ invoice.billNo }}</td> prop="tradeNo"
<td>{{ invoice.tradeNo ?? '—' }}</td> label="交易流水号"
<td>{{ invoice.projectName }}</td> min-width="180"
<td class="invoices__amount">¥{{ formatMoney(invoice.amount) }}</td> show-overflow-tooltip
<td> />
<StatusTag :label="invoice.status" :variant="STATUS_VARIANTS[invoice.status]" /> <el-table-column
</td> prop="projectName"
<td>{{ invoice.applyTime }}</td> label="项目名称"
<td class="invoices__ops"> min-width="160"
<template v-if="invoice.status === '已开票'"> show-overflow-tooltip
<button class="invoices__link" @click="onPreview"> />
<el-table-column
label="订单金额"
width="110"
>
<template #default="{ row }">
<span class="invoices__amount">¥{{ formatMoney((row as Invoice).amount) }}</span>
</template>
</el-table-column>
<el-table-column
label="状态"
width="90"
>
<template #default="{ row }">
<StatusTag
:label="(row as Invoice).status"
:variant="STATUS_VARIANTS[(row as Invoice).status]"
/>
</template>
</el-table-column>
<el-table-column
prop="applyTime"
label="申请时间"
width="140"
show-overflow-tooltip
/>
<el-table-column
label="操作"
width="180"
fixed="right"
>
<template #default="{ row }">
<span class="invoices__ops">
<template v-if="(row as Invoice).status === '已开票'">
<el-button
link
type="primary"
class="invoices__link"
@click="onPreview"
>
预览 预览
</button> </el-button>
<button class="invoices__link" @click="onDownload"> <el-button
link
type="primary"
class="invoices__link"
@click="onDownload"
>
下载 下载
</button> </el-button>
<button class="invoices__link" @click="onSendEmail"> <el-button
link
type="primary"
class="invoices__link"
@click="onSendEmail"
>
发送邮箱 发送邮箱
</button> </el-button>
</template> </template>
<span v-else-if="invoice.status === '开票中'" class="invoices__dash">—</span> <span v-else-if="(row as Invoice).status === '开票中'" class="invoices__dash">—</span>
<button v-else class="invoices__link" @click="onApply(invoice)"> <el-button
v-else
link
type="primary"
class="invoices__link"
@click="onApply(row as Invoice)"
>
申请开票 申请开票
</button> </el-button>
</td> </span>
</tr> </template>
<tr v-if="invoices.length === 0"> </el-table-column>
<td colspan="10" class="invoices__empty text-secondary">
暂无发票记录 <template #empty>
</td> <span class="invoices__empty text-secondary">暂无发票记录</span>
</tr> </template>
</tbody> </el-table>
</table>
</div> </div>
<PaginationBar <PaginationBar
@@ -271,29 +326,26 @@ onMounted(() => {
} }
&__tabs { &__tabs {
.flex(row, flex-start); :deep(.el-radio-button__inner) {
gap: @spacing-sm; height: 34px;
} padding: 0 @spacing-base;
border: 1px solid @border-color;
&__tab { border-radius: @radius-base;
height: 34px; background: @white;
padding: 0 @spacing-base; color: @text-color;
border: 1px solid @border-color; font-size: @font-size-base;
border-radius: @radius-base; box-shadow: none;
background: @white;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
} }
&.is-active { :deep(.el-radio-button + .el-radio-button) {
margin-left: @spacing-sm;
}
:deep(.el-radio-button.is-active .el-radio-button__inner) {
border-color: @primary-color; border-color: @primary-color;
background: @primary-color; background: @primary-color;
color: @white; color: @white;
box-shadow: -1px 0 0 0 @primary-color;
} }
} }
@@ -307,32 +359,35 @@ onMounted(() => {
&__table-el { &__table-el {
width: 100%; width: 100%;
border-collapse: collapse;
font-size: @font-size-base; font-size: @font-size-base;
color: @text-color; color: @text-color;
th, :deep(th.el-table__cell) {
td { background: @bg-color-light;
padding: @spacing-sm;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid @border-color; border-bottom: 1px solid @border-color;
}
th {
background: @bg-color-light;
color: @text-color-secondary; color: @text-color-secondary;
font-size: @font-size-base;
font-weight: 500; font-weight: 500;
text-align: left;
} }
tbody tr:hover { :deep(td.el-table__cell) {
padding: @spacing-sm 0;
border-bottom: 1px solid @border-color;
font-size: @font-size-base;
}
:deep(.el-table__body tr:hover > td.el-table__cell) {
background: @bg-color-light; background: @bg-color-light;
} }
}
&__check { :deep(.el-table .cell) {
width: 48px; padding-left: @spacing-sm;
text-align: center; padding-right: @spacing-sm;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
} }
&__amount { &__amount {
@@ -341,17 +396,23 @@ onMounted(() => {
} }
&__ops { &__ops {
.flex(row, flex-start); display: inline-flex;
flex-direction: row;
align-items: center;
gap: @spacing-sm; gap: @spacing-sm;
} }
&__link { &__link {
padding: 0;
border: none;
background: transparent;
color: @primary-color; color: @primary-color;
font-size: @font-size-base; font-size: @font-size-base;
transition: color 0.2s; transition: color 0.2s;
&:hover { &:hover {
color: @primary-hover; color: @primary-hover;
background: transparent;
} }
} }

View File

@@ -2,6 +2,7 @@
import { computed, onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElButton } from 'element-plus'
import { useFinanceStore } from '@/stores/modules/finance' import { useFinanceStore } from '@/stores/modules/finance'
import { formatMoney } from '@/utils/format' import { formatMoney } from '@/utils/format'
@@ -110,13 +111,12 @@ onMounted(() => {
</ul> </ul>
<div class="pay-result__footer"> <div class="pay-result__footer">
<button <el-button
class="pay-result__back" class="pay-result__back"
type="button" @click="router.push('/finance/bills')"
@click="router.push('/finance/bills')" >
> 返回
返回 </el-button>
</button>
</div> </div>
</section> </section>
</template> </template>
@@ -311,6 +311,7 @@ onMounted(() => {
width: 84px; width: 84px;
height: 32px; height: 32px;
border: none; border: none;
padding: 0;
border-radius: 6px; border-radius: 6px;
background: @primary-strong; background: @primary-strong;
color: @white; color: @white;