feat: 优化后台界面与报表样式

This commit is contained in:
wchino
2026-06-11 15:32:22 +08:00
parent 064ba727e0
commit 9b10f21663
37 changed files with 5746 additions and 610 deletions

View File

@@ -14,7 +14,12 @@
</tr>
</thead>
<tbody>
<tr v-for="bank in visibleBanks" :key="bank.id" :class="bank.rowClass" :style="{ backgroundColor: bank.labelColor }">
<tr
v-for="bank in visibleBanks"
:key="bank.id"
:class="[bank.rowClass, { 'bank-colored': bank.labelColor }]"
:style="bankRowStyle(bank)"
>
<td>
<strong>{{ bank.name }}</strong>
<small>{{ bank.account }}</small>
@@ -22,11 +27,50 @@
<td class="balance">AUD {{ money(bank.balance) }}</td>
<td>
<div class="bank-operate">
<el-button size="small" :icon="Coin" circle />
<el-button size="small" :icon="Switch" @click="openTransfer(bank)">{{ t('dashboard.Transfer') }}</el-button>
<div class="breakdown">
<span><i class="dot income"></i> ({{ bank.depositCount }}) {{ money(bank.deposit) }}</span>
<span><i class="dot outcome"></i> ({{ bank.withdrawCount }}) {{ money(bank.withdraw) }}</span>
<input :id="calculatorToggleId(bank)" class="calculator-toggle" type="checkbox" />
<div class="bank-operate-main">
<label
class="calculator-button"
:for="calculatorToggleId(bank)"
:aria-label="t('dashboard.Fast Calculation')"
:title="t('dashboard.Fast Calculation')"
>
<Icon name="fa fa-calculator" size="13" color="currentColor" />
</label>
<el-button size="small" :icon="Switch" @click="openTransfer(bank)">
{{ t('dashboard.Transfer') }}
</el-button>
<div class="breakdown">
<div class="breakdown-row breakdown-in">
<span class="breakdown-label"></span>
<span class="breakdown-count">({{ bank.depositCount }})</span>
<strong>{{ money(bank.deposit) }}</strong>
</div>
<div class="breakdown-row breakdown-out">
<span class="breakdown-label"></span>
<span class="breakdown-count">({{ bank.withdrawCount }})</span>
<strong>{{ money(bank.withdraw) }}</strong>
</div>
</div>
</div>
<div class="bank-calculator">
<div class="calculator-formula">
<el-input
v-model="calculatorFormulas[calculatorKey(bank)]"
class="calculator-input"
size="small"
:placeholder="t('dashboard.Calculation placeholder')"
clearable
/>
<span class="calculator-equals">=</span>
<strong class="calculator-result" :class="{ 'is-invalid': isCalculatorInvalid(bank) }">
{{ calculatorResultText(bank) }}
</strong>
</div>
<div class="calculator-hint">
{{ t('dashboard.Fast Calculation Support') }}
<span>+ × ÷</span>
</div>
</div>
</div>
</td>
@@ -240,7 +284,7 @@
</template>
<script setup lang="ts">
import { Coin, Switch } from '@element-plus/icons-vue'
import { Switch } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { bankTransact, delTransact, editTransact, index as getDashboard, logHistory, newTransact } from '/@/api/backend/dashboard'
@@ -357,6 +401,11 @@ interface HistoryRow {
bankAfter: string
}
interface CalculationResult {
valid: boolean
value: number
}
const today = () => {
const date = new Date()
const pad = (value: number) => value.toString().padStart(2, '0')
@@ -374,6 +423,249 @@ const toNumber = (value: unknown) => {
return Number.isFinite(number) ? number : 0
}
const normalizeCalculationFormula = (value: string) =>
value
.replace(/,/g, '')
.replace(/乘以/g, '*')
.replace(/除以/g, '/')
.replace(/加/g, '+')
.replace(/减/g, '-')
.replace(/[]/g, '+')
.replace(/[-–—]/g, '-')
.replace(/[×xX]/g, '*')
.replace(/[÷/]/g, '/')
.replace(/\s+/g, '')
.replace(/^=/, '')
const evaluateExpression = (expression: string) => {
let index = 0
const parseNumber = () => {
const matched = expression.slice(index).match(/^(?:\d+(?:\.\d*)?|\.\d+)/)
if (!matched) return null
index += matched[0].length
return Number(matched[0])
}
const parseFactor = (): number | null => {
const char = expression[index]
if (char === '+') {
index += 1
return parseFactor()
}
if (char === '-') {
index += 1
const value = parseFactor()
return value === null ? null : -value
}
if (char === '(') {
index += 1
const value = parseExpression()
if (value === null || expression[index] !== ')') return null
index += 1
return value
}
return parseNumber()
}
const parseTerm = (): number | null => {
let value = parseFactor()
if (value === null) return null
while (expression[index] === '*' || expression[index] === '/') {
const operator = expression[index]
index += 1
const right = parseFactor()
if (right === null) return null
value = operator === '*' ? value * right : right === 0 ? Number.NaN : value / right
}
return value
}
function parseExpression(): number | null {
let value = parseTerm()
if (value === null) return null
while (expression[index] === '+' || expression[index] === '-') {
const operator = expression[index]
index += 1
const right = parseTerm()
if (right === null) return null
value = operator === '+' ? value + right : value - right
}
return value
}
const result = parseExpression()
return result !== null && index === expression.length && Number.isFinite(result) ? result : null
}
const calculateBalancePreview = (balance: number, formula: string): CalculationResult => {
const normalized = normalizeCalculationFormula(formula)
if (!normalized) {
return { valid: true, value: balance }
}
if (!/^[\d+\-*/().]+$/.test(normalized)) {
return { valid: false, value: balance }
}
const expression = /^[+\-*/]/.test(normalized) ? `${balance}${normalized}` : `${balance}+${normalized}`
const value = evaluateExpression(expression)
return value === null ? { valid: false, value: balance } : { valid: true, value }
}
const clampRgbChannel = (value: number) => Math.min(255, Math.max(0, Math.round(value)))
const parseColorChannel = (value: string) => {
const number = Number(value)
return Number.isFinite(number) ? clampRgbChannel(number) : 0
}
const parseAlphaChannel = (value?: string) => {
if (value === undefined) return 1
const number = Number(value)
return Number.isFinite(number) ? Math.min(1, Math.max(0, number)) : 1
}
const parseColorToRgb = (value: string) => {
const color = value.trim()
const hex = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i)
if (hex) {
const raw = hex[1]
const full =
raw.length === 3
? raw
.split('')
.map((char) => char + char)
.join('')
: raw
return {
r: Number.parseInt(full.slice(0, 2), 16),
g: Number.parseInt(full.slice(2, 4), 16),
b: Number.parseInt(full.slice(4, 6), 16),
a: full.length === 8 ? Number.parseInt(full.slice(6, 8), 16) / 255 : 1,
}
}
const rgb = color.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i)
if (!rgb) return null
return {
r: parseColorChannel(rgb[1]),
g: parseColorChannel(rgb[2]),
b: parseColorChannel(rgb[3]),
a: parseAlphaChannel(rgb[4]),
}
}
const rgbToCss = (rgb: { r: number; g: number; b: number }) => `rgb(${clampRgbChannel(rgb.r)}, ${clampRgbChannel(rgb.g)}, ${clampRgbChannel(rgb.b)})`
const mixRgb = (source: { r: number; g: number; b: number }, target: { r: number; g: number; b: number }, targetWeight: number) => ({
r: source.r * (1 - targetWeight) + target.r * targetWeight,
g: source.g * (1 - targetWeight) + target.g * targetWeight,
b: source.b * (1 - targetWeight) + target.b * targetWeight,
})
const polishedBankRowColor = (backgroundColor: string) => {
const rgb = parseColorToRgb(backgroundColor)
if (!rgb) return backgroundColor
const blended = {
r: rgb.r * rgb.a + 255 * (1 - rgb.a),
g: rgb.g * rgb.a + 255 * (1 - rgb.a),
b: rgb.b * rgb.a + 255 * (1 - rgb.a),
}
const max = Math.max(blended.r, blended.g, blended.b)
const min = Math.min(blended.r, blended.g, blended.b)
if (max - min < 18) {
return rgbToCss(mixRgb(blended, { r: 241, g: 245, b: 249 }, 0.72))
}
if (blended.g >= blended.r && blended.g >= blended.b) {
return '#d9f3e5'
}
if (blended.r >= blended.g && blended.r >= blended.b) {
return '#f8dde2'
}
return '#dbeafe'
}
const srgbToLinear = (channel: number) => {
const value = channel / 255
return value <= 0.03928 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4)
}
const relativeLuminance = (rgb: { r: number; g: number; b: number }) =>
0.2126 * srgbToLinear(rgb.r) + 0.7152 * srgbToLinear(rgb.g) + 0.0722 * srgbToLinear(rgb.b)
const contrastRatio = (first: number, second: number) => {
const lighter = Math.max(first, second)
const darker = Math.min(first, second)
return (lighter + 0.05) / (darker + 0.05)
}
const readableTextColor = (backgroundColor: string) => {
const rgb = parseColorToRgb(backgroundColor)
if (!rgb) return '#172033'
const blended = {
r: rgb.r * rgb.a + 255 * (1 - rgb.a),
g: rgb.g * rgb.a + 255 * (1 - rgb.a),
b: rgb.b * rgb.a + 255 * (1 - rgb.a),
}
const luminance = relativeLuminance(blended)
const slateContrast = contrastRatio(luminance, relativeLuminance({ r: 23, g: 32, b: 51 }))
const whiteContrast = contrastRatio(luminance, 1)
const darkContrast = contrastRatio(luminance, 0)
if (slateContrast >= 4.5 || slateContrast >= whiteContrast) {
return '#172033'
}
return whiteContrast >= darkContrast ? '#ffffff' : '#000000'
}
const bankRowStyle = (bank: Bank): Record<string, string> => {
if (!bank.labelColor) return {}
const backgroundColor = polishedBankRowColor(bank.labelColor)
const textColor = readableTextColor(backgroundColor)
const isLightText = textColor === '#ffffff'
return {
backgroundColor,
'--bank-row-text-color': textColor,
'--bank-row-muted-color': isLightText ? 'rgba(255, 255, 255, 0.82)' : 'rgba(0, 0, 0, 0.72)',
'--bank-row-border-color': isLightText ? 'rgba(255, 255, 255, 0.24)' : 'rgba(0, 0, 0, 0.16)',
'--bank-row-control-bg': isLightText ? 'rgba(255, 255, 255, 0.16)' : 'rgba(255, 255, 255, 0.72)',
'--bank-row-control-hover-bg': isLightText ? 'rgba(255, 255, 255, 0.26)' : 'rgba(255, 255, 255, 0.88)',
}
}
const formatDateTime = (value: unknown) => {
if (typeof value === 'string' && value.trim() && !Number.isFinite(Number(value))) {
return value
@@ -435,6 +727,7 @@ const transactionPage = reactive({
pageSize: 10,
})
const showAllBanks = ref(false)
const calculatorFormulas = reactive<Record<string, string>>({})
const visibleBanks = computed(() => (showAllBanks.value ? banks.value : banks.value.slice(0, 4)))
const totalDeposit = computed(() => transactionTotals.totalDeposit)
const totalWithdraw = computed(() => transactionTotals.totalWithdraw)
@@ -460,6 +753,19 @@ const summary = computed(() => [
{ label: t('dashboard.Unclaimed Receipt'), value: customerSummary.unclaimReceipt },
])
const calculatorKey = (bank: Bank) => String(bank.id)
const calculatorToggleId = (bank: Bank) => `bank-calculator-${calculatorKey(bank).replace(/[^a-zA-Z0-9_-]/g, '-')}`
const calculatorResult = (bank: Bank) => calculateBalancePreview(bank.balance, calculatorFormulas[calculatorKey(bank)] || '')
const isCalculatorInvalid = (bank: Bank) => !calculatorResult(bank).valid
const calculatorResultText = (bank: Bank) => {
const result = calculatorResult(bank)
return result.valid ? `AUD ${money(result.value)}` : 'AUD --'
}
const transactionDialog = reactive<{ visible: boolean; loading: boolean; mode: 'create' | 'edit'; editId: number | string | '' }>({
visible: false,
loading: false,
@@ -753,25 +1059,29 @@ onMounted(() => {
.bookkeeping-dashboard {
color: var(--el-text-color-primary);
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.dashboard-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 310px;
gap: 16px;
gap: var(--ba-main-space);
}
.dashboard-panel,
.transaction-section {
border: 1px solid var(--el-border-color);
border-radius: 4px;
background: var(--el-bg-color);
border: 1px solid var(--ba-border-color-soft);
border-radius: var(--ba-radius-panel);
background: var(--ba-bg-color-overlay);
box-shadow: var(--ba-shadow-card);
overflow: hidden;
}
.panel-title {
padding: 11px 14px;
border-bottom: 1px solid var(--el-border-color);
background: var(--el-fill-color-light);
padding: 13px 16px;
border-bottom: 1px solid var(--ba-border-color-soft);
background: var(--ba-bg-color-soft);
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 700;
line-height: 1.4;
}
.table-scroll {
overflow-x: auto;
@@ -781,16 +1091,68 @@ onMounted(() => {
border-collapse: collapse;
th,
td {
padding: 9px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
padding: 11px 14px;
border-bottom: 1px solid var(--ba-border-color-soft);
text-align: left;
vertical-align: middle;
}
th {
color: var(--el-text-color-secondary);
font-size: 12px;
font-weight: 700;
background: var(--ba-bg-color-soft);
white-space: nowrap;
}
tbody tr {
transition: background-color 0.18s ease;
}
tbody tr:hover:not(.bank-colored) {
background: var(--el-color-primary-light-9);
}
tbody tr.bank-colored {
color: var(--bank-row-text-color);
text-shadow: none;
td {
border-bottom-color: var(--bank-row-border-color);
}
small {
color: var(--bank-row-muted-color);
}
.balance {
color: inherit;
}
:deep(.el-button) {
--el-button-text-color: var(--bank-row-text-color);
--el-button-bg-color: var(--bank-row-control-bg);
--el-button-border-color: var(--bank-row-border-color);
--el-button-hover-text-color: var(--bank-row-text-color);
--el-button-hover-bg-color: var(--bank-row-control-hover-bg);
--el-button-hover-border-color: var(--bank-row-border-color);
--el-button-active-text-color: var(--bank-row-text-color);
--el-button-active-bg-color: var(--bank-row-control-hover-bg);
--el-button-active-border-color: var(--bank-row-border-color);
}
.calculator-button {
color: var(--bank-row-text-color);
border-color: var(--bank-row-border-color);
background: var(--bank-row-control-bg);
&:hover,
&:focus-visible {
background: var(--bank-row-control-hover-bg);
}
}
:deep(.el-tag) {
color: var(--bank-row-text-color);
border-color: var(--bank-row-border-color);
background: var(--bank-row-control-bg);
}
}
strong,
small {
display: block;
@@ -815,15 +1177,164 @@ onMounted(() => {
}
.bank-operate {
display: flex;
align-items: center;
gap: 7px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
:deep(.el-button.is-circle) {
width: 30px;
height: 30px;
}
}
.breakdown {
.bank-operate-main {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.calculator-toggle {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.calculator-toggle:not(:checked) ~ .bank-calculator {
display: none;
}
.calculator-toggle:checked + .bank-operate-main .calculator-button {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.calculator-toggle:focus-visible + .bank-operate-main .calculator-button {
outline: 2px solid var(--el-color-primary-light-5);
outline-offset: 2px;
}
.calculator-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid var(--el-border-color);
border-radius: 50%;
background: var(--el-bg-color);
color: var(--el-text-color-regular);
cursor: pointer;
line-height: 1;
transition:
background-color 0.18s ease,
border-color 0.18s ease,
color 0.18s ease;
&:hover,
&:focus-visible {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
outline: none;
}
}
.bank-calculator {
width: min(300px, 100%);
padding: 6px 8px 5px;
border: 1px solid var(--ba-border-color-soft);
border-radius: 2px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08);
color: var(--el-text-color-primary);
}
.calculator-formula {
display: flex;
align-items: center;
gap: 5px;
}
.calculator-input {
flex: 1 1 125px;
min-width: 112px;
:deep(.el-input__wrapper) {
min-height: 28px;
border-radius: 0;
box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.72) inset;
}
}
.calculator-equals {
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 700;
line-height: 1;
}
.calculator-result {
min-width: 92px;
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
&.is-invalid {
color: var(--el-color-danger);
}
}
.calculator-hint {
margin-top: 3px;
color: var(--el-text-color-regular);
font-size: 11px;
line-height: 1.2;
span {
margin-left: 4px;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
}
}
.breakdown {
display: inline-flex;
flex-direction: column;
gap: 4px;
min-width: 118px;
padding: 5px 8px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 4px;
background: rgba(248, 250, 252, 0.94);
color: var(--el-text-color-regular);
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
}
.breakdown-row {
display: grid;
grid-template-columns: 18px auto minmax(0, 1fr);
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 700;
line-height: 1.15;
white-space: nowrap;
}
.breakdown-label {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 3px;
color: #ffffff;
font-size: 11px;
font-weight: 700;
}
.breakdown-count {
color: var(--el-text-color-secondary);
font-weight: 600;
}
.breakdown-in {
color: var(--el-color-success);
.breakdown-label {
background: var(--el-color-success);
}
}
.breakdown-out {
color: var(--el-color-danger);
.breakdown-label {
background: var(--el-color-danger);
}
}
.dot {
font-style: normal;
@@ -840,16 +1351,22 @@ onMounted(() => {
.more-info {
display: block;
width: 100%;
padding: 9px;
padding: 11px;
border: 0;
background: transparent;
border-top: 1px solid var(--ba-border-color-soft);
background: var(--ba-bg-color-soft);
color: var(--el-color-primary);
cursor: pointer;
font-weight: 700;
transition: background-color 0.18s ease;
&:hover {
background: var(--el-color-primary-light-9);
}
}
.summary-side {
display: flex;
flex-direction: column;
gap: 14px;
gap: var(--ba-main-space);
}
.summary-panel dl {
display: grid;
@@ -860,8 +1377,12 @@ onMounted(() => {
.summary-panel dt,
.summary-panel dd {
margin: 0;
padding: 8px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
padding: 10px 14px;
border-bottom: 1px solid var(--ba-border-color-soft);
}
.summary-panel dt {
color: var(--el-text-color-secondary);
font-weight: 600;
}
.summary-panel dd {
color: var(--el-color-primary);
@@ -870,11 +1391,12 @@ onMounted(() => {
}
.create-button {
width: 100%;
min-height: 44px;
min-height: 46px;
font-weight: 700;
}
.webhook-alert {
margin: 16px 0;
margin: var(--ba-main-space) 0;
border: 1px solid rgba(183, 121, 31, 0.24);
p {
margin: 6px 0 0;
line-height: 1.5;
@@ -883,9 +1405,10 @@ onMounted(() => {
.filter-row {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 12px;
border-bottom: 1px solid var(--el-border-color);
gap: 14px;
padding: 14px 16px;
border-bottom: 1px solid var(--ba-border-color-soft);
background: var(--ba-bg-color-soft);
}
.date-filter,
.totals {
@@ -895,10 +1418,18 @@ onMounted(() => {
gap: 8px;
}
.date-filter :deep(.el-date-editor) {
width: 145px;
width: 150px;
}
.date-filter label {
color: var(--el-text-color-secondary);
font-weight: 600;
}
.totals {
justify-content: flex-end;
color: var(--el-text-color-regular);
b {
font-weight: 700;
}
em {
color: var(--el-color-primary);
font-style: normal;
@@ -913,7 +1444,9 @@ onMounted(() => {
.pagination {
display: flex;
justify-content: flex-end;
padding: 12px;
padding: 14px 16px;
border-top: 1px solid var(--ba-border-color-soft);
background: var(--ba-bg-color-soft);
}
.inline-mode {
margin-left: 12px;
@@ -923,7 +1456,7 @@ onMounted(() => {
color: var(--el-text-color-secondary);
}
:deep(.el-dialog__body) {
padding-top: 12px;
padding-top: 16px;
}
:deep(.el-form-item .el-select),
:deep(.el-form-item .el-input) {
@@ -938,11 +1471,24 @@ onMounted(() => {
.filter-row {
flex-direction: column;
}
.date-filter {
align-items: stretch;
label {
width: 100%;
}
:deep(.el-date-editor),
:deep(.el-button) {
width: 100%;
}
}
.totals {
justify-content: flex-start;
}
.bank-table {
min-width: 760px;
}
.pagination {
justify-content: center;
}
}
</style>