feat: 更新财务系统功能和界面优化
- 优化财务仪表板数据展示 - 增强账户管理功能 - 改进预算和分类管理 - 完善报表和统计分析 - 优化交易管理界面 - 更新Workspace工作区 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { getQuery } from 'h3';
|
||||
|
||||
import { listAccounts } from '~/utils/finance-metadata';
|
||||
import { useResponseSuccess } from '~/utils/response';
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { getQuery } from 'h3';
|
||||
|
||||
import { fetchCategories } from '~/utils/finance-repository';
|
||||
import { useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event);
|
||||
const type = query.type as 'income' | 'expense' | undefined;
|
||||
const type = query.type as 'expense' | 'income' | undefined;
|
||||
|
||||
const categories = fetchCategories({ type });
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { readBody } from 'h3';
|
||||
|
||||
import { createCategoryRecord } from '~/utils/finance-metadata';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getRouterParam } from 'h3';
|
||||
|
||||
import { deleteCategoryRecord } from '~/utils/finance-metadata';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getRouterParam, readBody } from 'h3';
|
||||
|
||||
import { updateCategoryRecord } from '~/utils/finance-metadata';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getQuery } from 'h3';
|
||||
|
||||
import { listExchangeRates } from '~/utils/finance-metadata';
|
||||
import { useResponseSuccess } from '~/utils/response';
|
||||
|
||||
@@ -22,7 +21,10 @@ export default defineEventHandler(async (event) => {
|
||||
if (date) {
|
||||
rates = rates.filter((rate) => rate.date === date);
|
||||
} else if (rates.length > 0) {
|
||||
const latestDate = rates.reduce((max, rate) => (rate.date > max ? rate.date : max), rates[0].date);
|
||||
const latestDate = rates.reduce(
|
||||
(max, rate) => Math.max(rate.date, max),
|
||||
rates[0].date,
|
||||
);
|
||||
rates = rates.filter((rate) => rate.date === latestDate);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getQuery } from 'h3';
|
||||
|
||||
import { fetchTransactions } from '~/utils/finance-repository';
|
||||
import { useResponseSuccess } from '~/utils/response';
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { readBody } from 'h3';
|
||||
|
||||
import { createTransaction } from '~/utils/finance-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getRouterParam } from 'h3';
|
||||
|
||||
import { softDeleteTransaction } from '~/utils/finance-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getRouterParam, readBody } from 'h3';
|
||||
|
||||
import { restoreTransaction, updateTransaction } from '~/utils/finance-repository';
|
||||
import {
|
||||
restoreTransaction,
|
||||
updateTransaction,
|
||||
} from '~/utils/finance-repository';
|
||||
import { useResponseError, useResponseSuccess } from '~/utils/response';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -30,10 +32,12 @@ export default defineEventHandler(async (event) => {
|
||||
payload.amount = amount;
|
||||
}
|
||||
if (body?.currency) payload.currency = body.currency;
|
||||
if (body?.categoryId !== undefined) payload.categoryId = body.categoryId ?? null;
|
||||
if (body?.categoryId !== undefined)
|
||||
payload.categoryId = body.categoryId ?? null;
|
||||
if (body?.accountId !== undefined) payload.accountId = body.accountId ?? null;
|
||||
if (body?.transactionDate) payload.transactionDate = body.transactionDate;
|
||||
if (body?.description !== undefined) payload.description = body.description ?? '';
|
||||
if (body?.description !== undefined)
|
||||
payload.description = body.description ?? '';
|
||||
if (body?.project !== undefined) payload.project = body.project ?? null;
|
||||
if (body?.memo !== undefined) payload.memo = body.memo ?? null;
|
||||
if (body?.isDeleted !== undefined) payload.isDeleted = body.isDeleted;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
@@ -110,7 +111,7 @@ db.exec(`
|
||||
);
|
||||
`);
|
||||
|
||||
const RAW_TEXT = fs.readFileSync(inputPath, 'utf-8').replace(/^\ufeff/, '');
|
||||
const RAW_TEXT = fs.readFileSync(inputPath, 'utf8').replace(/^\uFEFF/, '');
|
||||
const lines = RAW_TEXT.split(/\r?\n/).filter((line) => line.trim().length > 0);
|
||||
if (lines.length <= 1) {
|
||||
console.error('CSV 文件内容为空');
|
||||
@@ -126,7 +127,14 @@ const ACCOUNT_IDX = header.indexOf('支出人');
|
||||
const CATEGORY_IDX = header.indexOf('计入');
|
||||
const SHARE_IDX = header.indexOf('阿德应得分红');
|
||||
|
||||
if (DATE_IDX === -1 || PROJECT_IDX === -1 || TYPE_IDX === -1 || AMOUNT_IDX === -1 || ACCOUNT_IDX === -1 || CATEGORY_IDX === -1) {
|
||||
if (
|
||||
DATE_IDX === -1 ||
|
||||
PROJECT_IDX === -1 ||
|
||||
TYPE_IDX === -1 ||
|
||||
AMOUNT_IDX === -1 ||
|
||||
ACCOUNT_IDX === -1 ||
|
||||
CATEGORY_IDX === -1
|
||||
) {
|
||||
console.error('CSV 表头缺少必需字段');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -138,9 +146,27 @@ const CURRENCIES = [
|
||||
];
|
||||
|
||||
const EXCHANGE_RATES = [
|
||||
{ fromCurrency: 'CNY', toCurrency: 'CNY', rate: 1, date: `${baseYear}-01-01`, source: 'system' },
|
||||
{ fromCurrency: 'USD', toCurrency: 'CNY', rate: 7.14, date: `${baseYear}-01-01`, source: 'manual' },
|
||||
{ fromCurrency: 'THB', toCurrency: 'CNY', rate: 0.2, date: `${baseYear}-01-01`, source: 'manual' },
|
||||
{
|
||||
fromCurrency: 'CNY',
|
||||
toCurrency: 'CNY',
|
||||
rate: 1,
|
||||
date: `${baseYear}-01-01`,
|
||||
source: 'system',
|
||||
},
|
||||
{
|
||||
fromCurrency: 'USD',
|
||||
toCurrency: 'CNY',
|
||||
rate: 7.14,
|
||||
date: `${baseYear}-01-01`,
|
||||
source: 'manual',
|
||||
},
|
||||
{
|
||||
fromCurrency: 'THB',
|
||||
toCurrency: 'CNY',
|
||||
rate: 0.2,
|
||||
date: `${baseYear}-01-01`,
|
||||
source: 'manual',
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_EXPENSE_CATEGORY = '未分类支出';
|
||||
@@ -178,7 +204,12 @@ function inferCurrency(accountName, amountText) {
|
||||
const name = accountName ?? '';
|
||||
const text = `${name}${amountText ?? ''}`;
|
||||
const lower = text.toLowerCase();
|
||||
if (lower.includes('美金') || lower.includes('usd') || lower.includes('u$') || lower.includes('u ')) {
|
||||
if (
|
||||
lower.includes('美金') ||
|
||||
lower.includes('usd') ||
|
||||
lower.includes('u$') ||
|
||||
lower.includes('u ')
|
||||
) {
|
||||
return 'USD';
|
||||
}
|
||||
if (lower.includes('泰铢') || lower.includes('thb')) {
|
||||
@@ -190,7 +221,9 @@ function inferCurrency(accountName, amountText) {
|
||||
function parseAmount(raw) {
|
||||
if (!raw) return 0;
|
||||
const matches = String(raw)
|
||||
.replace(/[^0-9.+-]/g, (char) => (char === '+' || char === '-' ? char : ' '))
|
||||
.replaceAll(/[^0-9.+-]/g, (char) =>
|
||||
char === '+' || char === '-' ? char : ' ',
|
||||
)
|
||||
.match(/[-+]?\d+(?:\.\d+)?/g);
|
||||
if (!matches) return 0;
|
||||
return matches.map(Number).reduce((sum, value) => sum + value, 0);
|
||||
@@ -205,10 +238,18 @@ function normalizeDate(value, monthTracker) {
|
||||
const month = Number(match[1]);
|
||||
const day = Number(match[2]);
|
||||
let year = baseYear;
|
||||
if (monthTracker.lastMonth !== null && month > monthTracker.lastMonth && monthTracker.wrapped) {
|
||||
if (
|
||||
monthTracker.lastMonth !== null &&
|
||||
month > monthTracker.lastMonth &&
|
||||
monthTracker.wrapped
|
||||
) {
|
||||
year -= 1;
|
||||
}
|
||||
if (monthTracker.lastMonth !== null && month < monthTracker.lastMonth && !monthTracker.wrapped) {
|
||||
if (
|
||||
monthTracker.lastMonth !== null &&
|
||||
month < monthTracker.lastMonth &&
|
||||
!monthTracker.wrapped
|
||||
) {
|
||||
monthTracker.wrapped = true;
|
||||
}
|
||||
monthTracker.lastMonth = month;
|
||||
@@ -231,12 +272,25 @@ const insertCategory = db.prepare(`
|
||||
|
||||
db.transaction(() => {
|
||||
if (!categoryMap.has(`${DEFAULT_INCOME_CATEGORY}-income`)) {
|
||||
const info = insertCategory.run({ name: DEFAULT_INCOME_CATEGORY, type: 'income', icon: '💰', color: '#10b981' });
|
||||
const info = insertCategory.run({
|
||||
name: DEFAULT_INCOME_CATEGORY,
|
||||
type: 'income',
|
||||
icon: '💰',
|
||||
color: '#10b981',
|
||||
});
|
||||
categoryMap.set(`${DEFAULT_INCOME_CATEGORY}-income`, info.lastInsertRowid);
|
||||
}
|
||||
if (!categoryMap.has(`${DEFAULT_EXPENSE_CATEGORY}-expense`)) {
|
||||
const info = insertCategory.run({ name: DEFAULT_EXPENSE_CATEGORY, type: 'expense', icon: '🏷️', color: '#6366f1' });
|
||||
categoryMap.set(`${DEFAULT_EXPENSE_CATEGORY}-expense`, info.lastInsertRowid);
|
||||
const info = insertCategory.run({
|
||||
name: DEFAULT_EXPENSE_CATEGORY,
|
||||
type: 'expense',
|
||||
icon: '🏷️',
|
||||
color: '#6366f1',
|
||||
});
|
||||
categoryMap.set(
|
||||
`${DEFAULT_EXPENSE_CATEGORY}-expense`,
|
||||
info.lastInsertRowid,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -261,20 +315,26 @@ for (let i = 1; i < lines.length; i += 1) {
|
||||
const amountRaw = row[AMOUNT_IDX].trim();
|
||||
const accountNameRaw = row[ACCOUNT_IDX].trim();
|
||||
const categoryRaw = row[CATEGORY_IDX].trim();
|
||||
const shareRaw = SHARE_IDX >= 0 ? row[SHARE_IDX].trim() : '';
|
||||
const shareRaw = SHARE_IDX === -1 ? '' : row[SHARE_IDX].trim();
|
||||
|
||||
const amount = parseAmount(amountRaw);
|
||||
if (!amount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedType = typeText.includes('收') && !typeText.includes('支') ? 'income' : 'expense';
|
||||
const normalizedType =
|
||||
typeText.includes('收') && !typeText.includes('支') ? 'income' : 'expense';
|
||||
const accountName = accountNameRaw || '美金现金';
|
||||
const currency = inferCurrency(accountNameRaw, amountRaw);
|
||||
|
||||
if (!accountMap.has(accountName)) {
|
||||
const icon = currency === 'USD' ? '💵' : currency === 'THB' ? '💱' : '💰';
|
||||
const color = currency === 'USD' ? '#1677ff' : currency === 'THB' ? '#22c55e' : '#6366f1';
|
||||
const color =
|
||||
currency === 'USD'
|
||||
? '#1677ff'
|
||||
: currency === 'THB'
|
||||
? '#22c55e'
|
||||
: '#6366f1';
|
||||
const info = insertAccount.run({
|
||||
name: accountName,
|
||||
currency,
|
||||
@@ -285,7 +345,11 @@ for (let i = 1; i < lines.length; i += 1) {
|
||||
accountMap.set(accountName, Number(info.lastInsertRowid));
|
||||
}
|
||||
|
||||
const categoryName = categoryRaw || (normalizedType === 'income' ? DEFAULT_INCOME_CATEGORY : DEFAULT_EXPENSE_CATEGORY);
|
||||
const categoryName =
|
||||
categoryRaw ||
|
||||
(normalizedType === 'income'
|
||||
? DEFAULT_INCOME_CATEGORY
|
||||
: DEFAULT_EXPENSE_CATEGORY);
|
||||
const categoryKey = `${categoryName}-${normalizedType}`;
|
||||
if (!categoryMap.has(categoryKey)) {
|
||||
const icon = normalizedType === 'income' ? '💰' : '🏷️';
|
||||
@@ -360,4 +424,6 @@ const insertMany = db.transaction((items) => {
|
||||
|
||||
insertMany(transactions);
|
||||
|
||||
console.log(`已导入 ${transactions.length} 条交易,账户 ${accountMap.size} 个,分类 ${categoryMap.size} 个。`);
|
||||
console.log(
|
||||
`已导入 ${transactions.length} 条交易,账户 ${accountMap.size} 个,分类 ${categoryMap.size} 个。`,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { MOCK_ACCOUNTS, MOCK_BUDGETS, MOCK_CATEGORIES, MOCK_CURRENCIES, MOCK_EXCHANGE_RATES } from './mock-data';
|
||||
import {
|
||||
MOCK_ACCOUNTS,
|
||||
MOCK_BUDGETS,
|
||||
MOCK_CATEGORIES,
|
||||
MOCK_CURRENCIES,
|
||||
MOCK_EXCHANGE_RATES,
|
||||
} from './mock-data';
|
||||
|
||||
export function listAccounts() {
|
||||
return MOCK_ACCOUNTS;
|
||||
@@ -31,7 +37,7 @@ export function createCategoryRecord(category: any) {
|
||||
}
|
||||
|
||||
export function updateCategoryRecord(id: number, category: any) {
|
||||
const index = MOCK_CATEGORIES.findIndex(c => c.id === id);
|
||||
const index = MOCK_CATEGORIES.findIndex((c) => c.id === id);
|
||||
if (index !== -1) {
|
||||
MOCK_CATEGORIES[index] = { ...MOCK_CATEGORIES[index], ...category };
|
||||
return MOCK_CATEGORIES[index];
|
||||
@@ -40,7 +46,7 @@ export function updateCategoryRecord(id: number, category: any) {
|
||||
}
|
||||
|
||||
export function deleteCategoryRecord(id: number) {
|
||||
const index = MOCK_CATEGORIES.findIndex(c => c.id === id);
|
||||
const index = MOCK_CATEGORIES.findIndex((c) => c.id === id);
|
||||
if (index !== -1) {
|
||||
MOCK_CATEGORIES.splice(index, 1);
|
||||
return true;
|
||||
|
||||
@@ -9,27 +9,27 @@ interface TransactionRow {
|
||||
currency: string;
|
||||
exchange_rate_to_base: number;
|
||||
amount_in_base: number;
|
||||
category_id: number | null;
|
||||
account_id: number | null;
|
||||
category_id: null | number;
|
||||
account_id: null | number;
|
||||
transaction_date: string;
|
||||
description: string | null;
|
||||
project: string | null;
|
||||
memo: string | null;
|
||||
description: null | string;
|
||||
project: null | string;
|
||||
memo: null | string;
|
||||
created_at: string;
|
||||
is_deleted: number;
|
||||
deleted_at: string | null;
|
||||
deleted_at: null | string;
|
||||
}
|
||||
|
||||
interface TransactionPayload {
|
||||
type: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
categoryId?: number | null;
|
||||
accountId?: number | null;
|
||||
categoryId?: null | number;
|
||||
accountId?: null | number;
|
||||
transactionDate: string;
|
||||
description?: string;
|
||||
project?: string | null;
|
||||
memo?: string | null;
|
||||
project?: null | string;
|
||||
memo?: null | string;
|
||||
createdAt?: string;
|
||||
isDeleted?: boolean;
|
||||
}
|
||||
@@ -41,7 +41,7 @@ function getExchangeRateToBase(currency: string) {
|
||||
const stmt = db.prepare(
|
||||
`SELECT rate FROM finance_exchange_rates WHERE from_currency = ? AND to_currency = ? ORDER BY date DESC LIMIT 1`,
|
||||
);
|
||||
const row = stmt.get(currency, BASE_CURRENCY) as { rate: number } | undefined;
|
||||
const row = stmt.get(currency, BASE_CURRENCY) as undefined | { rate: number };
|
||||
return row?.rate ?? 1;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ function mapTransaction(row: TransactionRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: 1,
|
||||
type: row.type as 'income' | 'expense' | 'transfer',
|
||||
type: row.type as 'expense' | 'income' | 'transfer',
|
||||
amount: row.amount,
|
||||
currency: row.currency,
|
||||
exchangeRateToBase: row.exchange_rate_to_base,
|
||||
@@ -66,7 +66,9 @@ function mapTransaction(row: TransactionRow) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchTransactions(options: { type?: string; includeDeleted?: boolean } = {}) {
|
||||
export function fetchTransactions(
|
||||
options: { includeDeleted?: boolean; type?: string } = {},
|
||||
) {
|
||||
const clauses: string[] = [];
|
||||
const params: Record<string, unknown> = {};
|
||||
|
||||
@@ -78,7 +80,7 @@ export function fetchTransactions(options: { type?: string; includeDeleted?: boo
|
||||
params.type = options.type;
|
||||
}
|
||||
|
||||
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
|
||||
const stmt = db.prepare<TransactionRow>(
|
||||
`SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted, deleted_at FROM finance_transactions ${where} ORDER BY transaction_date DESC, id DESC`,
|
||||
@@ -98,7 +100,10 @@ export function getTransactionById(id: number) {
|
||||
export function createTransaction(payload: TransactionPayload) {
|
||||
const exchangeRate = getExchangeRateToBase(payload.currency);
|
||||
const amountInBase = +(payload.amount * exchangeRate).toFixed(2);
|
||||
const createdAt = payload.createdAt && payload.createdAt.length ? payload.createdAt : new Date().toISOString();
|
||||
const createdAt =
|
||||
payload.createdAt && payload.createdAt.length > 0
|
||||
? payload.createdAt
|
||||
: new Date().toISOString();
|
||||
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO finance_transactions (type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, is_deleted) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, 0)`,
|
||||
@@ -171,29 +176,35 @@ export function updateTransaction(id: number, payload: TransactionPayload) {
|
||||
}
|
||||
|
||||
export function softDeleteTransaction(id: number) {
|
||||
const stmt = db.prepare(`UPDATE finance_transactions SET is_deleted = 1, deleted_at = @deletedAt WHERE id = @id`);
|
||||
const stmt = db.prepare(
|
||||
`UPDATE finance_transactions SET is_deleted = 1, deleted_at = @deletedAt WHERE id = @id`,
|
||||
);
|
||||
stmt.run({ id, deletedAt: new Date().toISOString() });
|
||||
return getTransactionById(id);
|
||||
}
|
||||
|
||||
export function restoreTransaction(id: number) {
|
||||
const stmt = db.prepare(`UPDATE finance_transactions SET is_deleted = 0, deleted_at = NULL WHERE id = @id`);
|
||||
const stmt = db.prepare(
|
||||
`UPDATE finance_transactions SET is_deleted = 0, deleted_at = NULL WHERE id = @id`,
|
||||
);
|
||||
stmt.run({ id });
|
||||
return getTransactionById(id);
|
||||
}
|
||||
|
||||
export function replaceAllTransactions(rows: Array<{
|
||||
type: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
categoryId: number | null;
|
||||
accountId: number | null;
|
||||
transactionDate: string;
|
||||
description: string;
|
||||
project?: string | null;
|
||||
memo?: string | null;
|
||||
createdAt?: string;
|
||||
}>) {
|
||||
export function replaceAllTransactions(
|
||||
rows: Array<{
|
||||
accountId: null | number;
|
||||
amount: number;
|
||||
categoryId: null | number;
|
||||
createdAt?: string;
|
||||
currency: string;
|
||||
description: string;
|
||||
memo?: null | string;
|
||||
project?: null | string;
|
||||
transactionDate: string;
|
||||
type: string;
|
||||
}>,
|
||||
) {
|
||||
db.prepare('DELETE FROM finance_transactions').run();
|
||||
|
||||
const insert = db.prepare(
|
||||
@@ -206,7 +217,7 @@ export function replaceAllTransactions(rows: Array<{
|
||||
|
||||
const insertMany = db.transaction((items: Array<any>) => {
|
||||
for (const item of items) {
|
||||
const row = getRate.get(item.currency) as { rate: number } | undefined;
|
||||
const row = getRate.get(item.currency) as undefined | { rate: number };
|
||||
const rate = row?.rate ?? 1;
|
||||
const amountInBase = +(item.amount * rate).toFixed(2);
|
||||
insert.run({
|
||||
@@ -215,7 +226,9 @@ export function replaceAllTransactions(rows: Array<{
|
||||
amountInBase,
|
||||
project: item.project ?? null,
|
||||
memo: item.memo ?? null,
|
||||
createdAt: item.createdAt ?? new Date(`${item.transactionDate}T00:00:00Z`).toISOString(),
|
||||
createdAt:
|
||||
item.createdAt ??
|
||||
new Date(`${item.transactionDate}T00:00:00Z`).toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -228,9 +241,9 @@ interface CategoryRow {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
icon: string | null;
|
||||
color: string | null;
|
||||
user_id: number | null;
|
||||
icon: null | string;
|
||||
color: null | string;
|
||||
user_id: null | number;
|
||||
is_active: number;
|
||||
}
|
||||
|
||||
@@ -239,7 +252,7 @@ function mapCategory(row: CategoryRow) {
|
||||
id: row.id,
|
||||
userId: row.user_id ?? null,
|
||||
name: row.name,
|
||||
type: row.type as 'income' | 'expense',
|
||||
type: row.type as 'expense' | 'income',
|
||||
icon: row.icon ?? '📝',
|
||||
color: row.color ?? '#dfe4ea',
|
||||
sortOrder: row.id,
|
||||
@@ -248,8 +261,10 @@ function mapCategory(row: CategoryRow) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCategories(options: { type?: 'income' | 'expense' } = {}) {
|
||||
const where = options.type ? `WHERE type = @type AND is_active = 1` : 'WHERE is_active = 1';
|
||||
export function fetchCategories(options: { type?: 'expense' | 'income' } = {}) {
|
||||
const where = options.type
|
||||
? `WHERE type = @type AND is_active = 1`
|
||||
: 'WHERE is_active = 1';
|
||||
const params = options.type ? { type: options.type } : {};
|
||||
|
||||
const stmt = db.prepare<CategoryRow>(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { dirname, join } from 'pathe';
|
||||
|
||||
const dbFile = join(process.cwd(), 'storage', 'finance.db');
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
// Flatten FinWise Pro menu - Remove parent menu and show children directly
|
||||
(function() {
|
||||
(function () {
|
||||
console.log('[FinWise] Script loaded');
|
||||
|
||||
function flattenFinWiseProMenu() {
|
||||
@@ -41,7 +41,7 @@
|
||||
console.log('[FinWise] Found submenus:', submenus.length);
|
||||
let finwiseMenu = null;
|
||||
|
||||
submenus.forEach(menu => {
|
||||
submenus.forEach((menu) => {
|
||||
const titleEl = menu.querySelector('.vben-sub-menu-content__title');
|
||||
if (titleEl && titleEl.textContent) {
|
||||
console.log('[FinWise] Menu title:', titleEl.textContent.trim());
|
||||
@@ -74,7 +74,7 @@
|
||||
// Move all children to the parent menu
|
||||
const children = Array.from(childrenUL.children);
|
||||
console.log('[FinWise] Moving', children.length, 'children');
|
||||
children.forEach(child => {
|
||||
children.forEach((child) => {
|
||||
parentMenu.insertBefore(child, finwiseMenu);
|
||||
});
|
||||
|
||||
@@ -84,28 +84,30 @@
|
||||
}
|
||||
|
||||
// Run after DOM loads
|
||||
const delays = [500, 1000, 1500, 2000, 2500, 3000, 4000, 5000, 6000, 7000, 8000];
|
||||
const delays = [
|
||||
500, 1000, 1500, 2000, 2500, 3000, 4000, 5000, 6000, 7000, 8000,
|
||||
];
|
||||
console.log('[FinWise] Setting up delays:', delays);
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
console.log('[FinWise] Waiting for DOMContentLoaded');
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
console.log('[FinWise] DOMContentLoaded fired');
|
||||
delays.forEach(delay => {
|
||||
delays.forEach((delay) => {
|
||||
setTimeout(flattenFinWiseProMenu, delay);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.log('[FinWise] DOM already loaded');
|
||||
delays.forEach(delay => {
|
||||
delays.forEach((delay) => {
|
||||
setTimeout(flattenFinWiseProMenu, delay);
|
||||
});
|
||||
}
|
||||
|
||||
// Watch for DOM changes
|
||||
setTimeout(function() {
|
||||
setTimeout(function () {
|
||||
console.log('[FinWise] Setting up MutationObserver');
|
||||
const observer = new MutationObserver(function() {
|
||||
const observer = new MutationObserver(function () {
|
||||
setTimeout(flattenFinWiseProMenu, 200);
|
||||
});
|
||||
|
||||
@@ -113,7 +115,7 @@
|
||||
if (body) {
|
||||
observer.observe(body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
subtree: true,
|
||||
});
|
||||
console.log('[FinWise] MutationObserver active');
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
const INPUT_CSV = '/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正.csv';
|
||||
const OUTPUT_CSV = '/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正_带分类.csv';
|
||||
const INPUT_CSV =
|
||||
'/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正.csv';
|
||||
const OUTPUT_CSV =
|
||||
'/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正_带分类.csv';
|
||||
|
||||
// 智能分类函数
|
||||
function getCategory(project: string): string {
|
||||
const desc = project.toLowerCase();
|
||||
|
||||
// 工资
|
||||
if (desc.includes('工资') || desc.match(/amy|天天|碧桂园|皇|香缇卡|财务|客服|小哥|代理ip|sy|超鹏|小白/)) {
|
||||
if (
|
||||
desc.includes('工资') ||
|
||||
/amy|天天|碧桂园|皇|香缇卡|财务|客服|小哥|代理ip|sy|超鹏|小白/.test(desc)
|
||||
) {
|
||||
return '工资';
|
||||
}
|
||||
|
||||
@@ -23,22 +28,34 @@ function getCategory(project: string): string {
|
||||
}
|
||||
|
||||
// 服务器/技术
|
||||
if (desc.match(/服务器|技术|chatgpt|openai|ai|接口|ip|nat|宝塔|cdn|oss|google|翻译|openrouter|deepseek|claude|cursor|bolt|硅基|chatwoot/)) {
|
||||
if (
|
||||
/服务器|技术|chatgpt|openai|ai|接口|ip|nat|宝塔|cdn|oss|google|翻译|openrouter|deepseek|claude|cursor|bolt|硅基|chatwoot/.test(
|
||||
desc,
|
||||
)
|
||||
) {
|
||||
return '服务器/技术';
|
||||
}
|
||||
|
||||
// 广告推广
|
||||
if (desc.match(/广告|推广|地推|投放|打流量/)) {
|
||||
if (/广告|推广|地推|投放|打流量/.test(desc)) {
|
||||
return '广告推广';
|
||||
}
|
||||
|
||||
// 软件/工具
|
||||
if (desc.match(/会员|007|u盘|processon|飞机|虚拟卡|小红卡|信用卡|cloudflare|uizard|esim/)) {
|
||||
if (
|
||||
/会员|007|u盘|processon|飞机|虚拟卡|小红卡|信用卡|cloudflare|uizard|esim/.test(
|
||||
desc,
|
||||
)
|
||||
) {
|
||||
return '软件/工具';
|
||||
}
|
||||
|
||||
// 固定资产
|
||||
if (desc.match(/买车|电脑|笔记本|显示器|rog|硬盘|服务器.*购买|iphone|路由器|展示屏/)) {
|
||||
if (
|
||||
/买车|电脑|笔记本|显示器|rog|硬盘|服务器.*购买|iphone|路由器|展示屏/.test(
|
||||
desc,
|
||||
)
|
||||
) {
|
||||
return '固定资产';
|
||||
}
|
||||
|
||||
@@ -48,7 +65,11 @@ function getCategory(project: string): string {
|
||||
}
|
||||
|
||||
// 借款/转账
|
||||
if (desc.match(/借|转给|龙腾|投资款|换.*铢|换美金|换现金|报销|房租|生活费|办公室|出差|接待|保关|测试|开工红包/)) {
|
||||
if (
|
||||
/借|转给|龙腾|投资款|换.*铢|换美金|换现金|报销|房租|生活费|办公室|出差|接待|保关|测试|开工红包/.test(
|
||||
desc,
|
||||
)
|
||||
) {
|
||||
return '借款/转账';
|
||||
}
|
||||
|
||||
@@ -57,12 +78,12 @@ function getCategory(project: string): string {
|
||||
}
|
||||
|
||||
// 读取并处理CSV
|
||||
const content = fs.readFileSync(INPUT_CSV, 'utf-8');
|
||||
const content = fs.readFileSync(INPUT_CSV, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// 修改表头,添加"分类"列
|
||||
const header = lines[0];
|
||||
const newHeader = header.trimEnd() + ',分类\n';
|
||||
const newHeader = `${header.trimEnd()},分类\n`;
|
||||
|
||||
// 处理每一行数据
|
||||
const newLines = [newHeader];
|
||||
@@ -84,7 +105,7 @@ for (let i = 1; i < lines.length; i++) {
|
||||
const category = getCategory(project);
|
||||
|
||||
// 添加分类列
|
||||
const newLine = line.trimEnd() + ',' + category + '\n';
|
||||
const newLine = `${line.trimEnd()},${category}\n`;
|
||||
newLines.push(newLine);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const CSV_FILE = '/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正_带分类.csv';
|
||||
const CSV_FILE =
|
||||
'/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正_带分类.csv';
|
||||
const API_URL = 'http://localhost:3000/api/finance/transactions';
|
||||
|
||||
interface CSVRow {
|
||||
@@ -59,11 +59,7 @@ function parseDate(dateStr: string, previousDate: string = ''): string {
|
||||
const prevMonth = Number.parseInt(previousDate.split('-')[1]);
|
||||
|
||||
// 如果月份从大变小(例如12月->2月,或7月->8月),说明跨年了
|
||||
if (month < prevMonth) {
|
||||
year = prevYear + 1;
|
||||
} else {
|
||||
year = prevYear;
|
||||
}
|
||||
year = month < prevMonth ? prevYear + 1 : prevYear;
|
||||
} else if (month >= 8) {
|
||||
// 第一条记录,8-12月是2024年
|
||||
year = 2024;
|
||||
@@ -85,11 +81,7 @@ function parseDate(dateStr: string, previousDate: string = ''): string {
|
||||
const prevYear = Number.parseInt(previousDate.split('-')[0]);
|
||||
const prevMonth = Number.parseInt(previousDate.split('-')[1]);
|
||||
|
||||
if (month < prevMonth) {
|
||||
year = prevYear + 1;
|
||||
} else {
|
||||
year = prevYear;
|
||||
}
|
||||
year = month < prevMonth ? prevYear + 1 : prevYear;
|
||||
} else if (month >= 8) {
|
||||
year = 2024;
|
||||
} else {
|
||||
@@ -109,12 +101,12 @@ function parseAmount(amountStr: string): number {
|
||||
const cleaned = amountStr.trim();
|
||||
|
||||
// 如果包含乘号(*或×或x),先处理乘法
|
||||
if (cleaned.match(/[*×x]/)) {
|
||||
if (/[*×x]/.test(cleaned)) {
|
||||
// 提取乘法表达式,如 "200*3=600" 或 "200*3"
|
||||
const mulMatch = cleaned.match(/(\d+(?:\.\d+)?)\s*[*×x]\s*(\d+(?:\.\d+)?)/);
|
||||
if (mulMatch) {
|
||||
const num1 = parseFloat(mulMatch[1]);
|
||||
const num2 = parseFloat(mulMatch[2]);
|
||||
const num1 = Number.parseFloat(mulMatch[1]);
|
||||
const num2 = Number.parseFloat(mulMatch[2]);
|
||||
if (!isNaN(num1) && !isNaN(num2)) {
|
||||
return num1 * num2;
|
||||
}
|
||||
@@ -126,7 +118,7 @@ function parseAmount(amountStr: string): number {
|
||||
const parts = cleaned.split('+');
|
||||
let sum = 0;
|
||||
for (const part of parts) {
|
||||
const num = parseFloat(part.replace(/[^\d.]/g, ''));
|
||||
const num = Number.parseFloat(part.replaceAll(/[^\d.]/g, ''));
|
||||
if (!isNaN(num)) {
|
||||
sum += num;
|
||||
}
|
||||
@@ -135,22 +127,22 @@ function parseAmount(amountStr: string): number {
|
||||
}
|
||||
|
||||
// 否则直接解析
|
||||
return parseFloat(cleaned.replace(/[^\d.]/g, '')) || 0;
|
||||
return Number.parseFloat(cleaned.replaceAll(/[^\d.]/g, '')) || 0;
|
||||
}
|
||||
|
||||
// 根据分类名称获取分类ID
|
||||
function getCategoryIdByName(categoryName: string): number {
|
||||
const categoryMap: Record<string, number> = {
|
||||
'工资': 5,
|
||||
工资: 5,
|
||||
'佣金/返佣': 6,
|
||||
'分红': 7,
|
||||
分红: 7,
|
||||
'服务器/技术': 8,
|
||||
'广告推广': 9,
|
||||
广告推广: 9,
|
||||
'软件/工具': 10,
|
||||
'固定资产': 11,
|
||||
'退款': 12,
|
||||
固定资产: 11,
|
||||
退款: 12,
|
||||
'借款/转账': 13,
|
||||
'其他支出': 14,
|
||||
其他支出: 14,
|
||||
};
|
||||
|
||||
return categoryMap[categoryName] || 2; // 默认未分类支出
|
||||
@@ -158,7 +150,7 @@ function getCategoryIdByName(categoryName: string): number {
|
||||
|
||||
// 批量导入
|
||||
async function importTransactions() {
|
||||
const content = fs.readFileSync(CSV_FILE, 'utf-8');
|
||||
const content = fs.readFileSync(CSV_FILE, 'utf8');
|
||||
const rows = parseCSV(content);
|
||||
|
||||
console.log(`共解析到 ${rows.length} 条记录`);
|
||||
@@ -202,14 +194,16 @@ async function importTransactions() {
|
||||
|
||||
if (response.ok) {
|
||||
imported++;
|
||||
console.log(`✓ 导入成功 [${imported}/${rows.length}]: ${row.project} - $${amount}`);
|
||||
console.log(
|
||||
`✓ 导入成功 [${imported}/${rows.length}]: ${row.project} - $${amount}`,
|
||||
);
|
||||
} else {
|
||||
failed++;
|
||||
console.error(`✗ 导入失败: ${row.project}`, await response.text());
|
||||
}
|
||||
|
||||
// 避免请求过快
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
} catch (error) {
|
||||
failed++;
|
||||
console.error(`✗ 处理失败: ${row.project}`, error);
|
||||
|
||||
@@ -13,9 +13,9 @@ export namespace FinanceApi {
|
||||
// 分类
|
||||
export interface Category {
|
||||
id: number;
|
||||
userId?: number | null;
|
||||
userId?: null | number;
|
||||
name: string;
|
||||
type: 'income' | 'expense';
|
||||
type: 'expense' | 'income';
|
||||
icon: string;
|
||||
color: string;
|
||||
sortOrder?: number;
|
||||
@@ -28,7 +28,14 @@ export namespace FinanceApi {
|
||||
id: number;
|
||||
userId?: number;
|
||||
name: string;
|
||||
type: 'cash' | 'bank' | 'alipay' | 'wechat' | 'virtual_wallet' | 'investment' | 'credit_card';
|
||||
type:
|
||||
| 'alipay'
|
||||
| 'bank'
|
||||
| 'cash'
|
||||
| 'credit_card'
|
||||
| 'investment'
|
||||
| 'virtual_wallet'
|
||||
| 'wechat';
|
||||
currency: string;
|
||||
balance?: number;
|
||||
icon?: string;
|
||||
@@ -43,20 +50,20 @@ export namespace FinanceApi {
|
||||
toCurrency: string;
|
||||
rate: number;
|
||||
date: string;
|
||||
source: 'manual' | 'api' | 'system';
|
||||
source: 'api' | 'manual' | 'system';
|
||||
}
|
||||
|
||||
// 交易
|
||||
export interface Transaction {
|
||||
id: number;
|
||||
userId: number;
|
||||
type: 'income' | 'expense' | 'transfer';
|
||||
type: 'expense' | 'income' | 'transfer';
|
||||
amount: number;
|
||||
currency: string;
|
||||
exchangeRateToBase: number;
|
||||
amountInBase: number;
|
||||
categoryId?: number | null;
|
||||
accountId?: number | null;
|
||||
categoryId?: null | number;
|
||||
accountId?: null | number;
|
||||
transactionDate: string;
|
||||
description: string;
|
||||
project?: string;
|
||||
@@ -68,7 +75,7 @@ export namespace FinanceApi {
|
||||
|
||||
// 创建交易的参数
|
||||
export interface CreateTransactionParams {
|
||||
type: 'income' | 'expense' | 'transfer';
|
||||
type: 'expense' | 'income' | 'transfer';
|
||||
amount: number;
|
||||
currency: string;
|
||||
categoryId?: number;
|
||||
@@ -92,7 +99,7 @@ export namespace FinanceApi {
|
||||
remaining: number;
|
||||
percentage: number;
|
||||
currency: string;
|
||||
period: 'monthly' | 'weekly' | 'quarterly' | 'yearly';
|
||||
period: 'monthly' | 'quarterly' | 'weekly' | 'yearly';
|
||||
alertThreshold: number;
|
||||
description?: string;
|
||||
autoRenew: boolean;
|
||||
@@ -114,7 +121,7 @@ export namespace FinanceApi {
|
||||
remaining?: number;
|
||||
percentage?: number;
|
||||
currency: string;
|
||||
period: 'monthly' | 'weekly' | 'quarterly' | 'yearly';
|
||||
period: 'monthly' | 'quarterly' | 'weekly' | 'yearly';
|
||||
alertThreshold: number;
|
||||
description?: string;
|
||||
autoRenew: boolean;
|
||||
@@ -133,7 +140,9 @@ export namespace FinanceApi {
|
||||
/**
|
||||
* 获取分类
|
||||
*/
|
||||
export async function getCategories(params?: { type?: 'income' | 'expense' | 'transfer' }) {
|
||||
export async function getCategories(params?: {
|
||||
type?: 'expense' | 'income' | 'transfer';
|
||||
}) {
|
||||
return requestClient.get<Category[]>('/finance/categories', { params });
|
||||
}
|
||||
|
||||
@@ -141,10 +150,10 @@ export namespace FinanceApi {
|
||||
* 创建分类
|
||||
*/
|
||||
export async function createCategory(data: {
|
||||
name: string;
|
||||
type: 'income' | 'expense';
|
||||
icon?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
name: string;
|
||||
type: 'expense' | 'income';
|
||||
}) {
|
||||
return requestClient.post<Category | null>('/finance/categories', data);
|
||||
}
|
||||
@@ -155,13 +164,16 @@ export namespace FinanceApi {
|
||||
export async function updateCategory(
|
||||
id: number,
|
||||
data: {
|
||||
name?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
name?: string;
|
||||
sortOrder?: number;
|
||||
},
|
||||
) {
|
||||
return requestClient.put<Category | null>(`/finance/categories/${id}`, data);
|
||||
return requestClient.put<Category | null>(
|
||||
`/finance/categories/${id}`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,9 +196,9 @@ export namespace FinanceApi {
|
||||
* 获取汇率
|
||||
*/
|
||||
export async function getExchangeRates(params?: {
|
||||
date?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
date?: string;
|
||||
}) {
|
||||
return requestClient.get<ExchangeRate[]>('/finance/exchange-rates', {
|
||||
params,
|
||||
@@ -197,7 +209,7 @@ export namespace FinanceApi {
|
||||
* 获取交易列表
|
||||
*/
|
||||
export async function getTransactions(params?: {
|
||||
type?: 'income' | 'expense' | 'transfer';
|
||||
type?: 'expense' | 'income' | 'transfer';
|
||||
}) {
|
||||
return requestClient.get<Transaction[]>('/finance/transactions', {
|
||||
params,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, watch } from 'vue';
|
||||
import { computed, onMounted } from 'vue';
|
||||
|
||||
import { useAntdDesignTokens } from '@vben/hooks';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
@@ -34,7 +34,7 @@ const flattenFinWiseProMenu = () => {
|
||||
const submenus = document.querySelectorAll('.vben-sub-menu');
|
||||
let finwiseMenu: Element | null = null;
|
||||
|
||||
submenus.forEach(menu => {
|
||||
submenus.forEach((menu) => {
|
||||
const titleEl = menu.querySelector('.vben-sub-menu-content__title');
|
||||
if (titleEl?.textContent?.includes('FinWise Pro')) {
|
||||
finwiseMenu = menu;
|
||||
@@ -49,16 +49,17 @@ const flattenFinWiseProMenu = () => {
|
||||
if (!childrenUL || !parentMenu) return;
|
||||
|
||||
// Check if already processed
|
||||
if ((finwiseMenu as HTMLElement).getAttribute('data-hide-finwise') === 'true') return;
|
||||
if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true')
|
||||
return;
|
||||
|
||||
// Move all children to the parent menu
|
||||
const children = Array.from(childrenUL.children);
|
||||
children.forEach(child => {
|
||||
parentMenu.insertBefore(child, finwiseMenu);
|
||||
const children = [...childrenUL.children];
|
||||
children.forEach((child) => {
|
||||
finwiseMenu.before(child);
|
||||
});
|
||||
|
||||
// Mark for hiding via CSS and hide directly
|
||||
(finwiseMenu as HTMLElement).setAttribute('data-hide-finwise', 'true');
|
||||
(finwiseMenu as HTMLElement).dataset.hideFinwise = 'true';
|
||||
(finwiseMenu as HTMLElement).style.display = 'none';
|
||||
};
|
||||
|
||||
@@ -66,7 +67,12 @@ const flattenFinWiseProMenu = () => {
|
||||
onMounted(() => {
|
||||
// 强制修复sidebar设置,防止被用户UI操作覆盖
|
||||
const fixSidebarPreferences = () => {
|
||||
const prefsKey = Object.keys(localStorage).find(k => k.includes('preferences') && !k.includes('locale') && !k.includes('theme'));
|
||||
const prefsKey = Object.keys(localStorage).find(
|
||||
(k) =>
|
||||
k.includes('preferences') &&
|
||||
!k.includes('locale') &&
|
||||
!k.includes('theme'),
|
||||
);
|
||||
if (prefsKey) {
|
||||
try {
|
||||
const prefs = JSON.parse(localStorage.getItem(prefsKey) || '{}');
|
||||
@@ -78,8 +84,8 @@ onMounted(() => {
|
||||
prefs.value.sidebar.collapsedWidth = 230;
|
||||
localStorage.setItem(prefsKey, JSON.stringify(prefs));
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Failed to fix sidebar preferences:', e);
|
||||
} catch (error) {
|
||||
console.error('Failed to fix sidebar preferences:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -89,7 +95,7 @@ onMounted(() => {
|
||||
|
||||
// Run multiple times with increasing delays to catch menu rendering
|
||||
const delays = [100, 300, 500, 1000, 1500, 2000, 2500, 3000, 4000, 5000];
|
||||
delays.forEach(delay => {
|
||||
delays.forEach((delay) => {
|
||||
setTimeout(flattenFinWiseProMenu, delay);
|
||||
});
|
||||
|
||||
@@ -104,7 +110,7 @@ onMounted(() => {
|
||||
if (body) {
|
||||
observer.observe(body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
@@ -112,7 +118,9 @@ onMounted(() => {
|
||||
// 防止侧边栏自动收起
|
||||
setTimeout(() => {
|
||||
const preventSidebarCollapse = () => {
|
||||
const sidebar = document.querySelector('[class*="sidebar"]') || document.querySelector('aside');
|
||||
const sidebar =
|
||||
document.querySelector('[class*="sidebar"]') ||
|
||||
document.querySelector('aside');
|
||||
|
||||
if (!sidebar) return;
|
||||
|
||||
@@ -120,7 +128,7 @@ onMounted(() => {
|
||||
const sidebarObserver = new MutationObserver(() => {
|
||||
const currentWidth = window.getComputedStyle(sidebar).width;
|
||||
// 如果宽度小于200px,说明可能被收起了,强制恢复
|
||||
if (parseInt(currentWidth) < 200) {
|
||||
if (Number.parseInt(currentWidth) < 200) {
|
||||
(sidebar as HTMLElement).style.width = '230px';
|
||||
}
|
||||
});
|
||||
@@ -128,7 +136,7 @@ onMounted(() => {
|
||||
// 开始观察
|
||||
sidebarObserver.observe(sidebar, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class', 'style']
|
||||
attributeFilter: ['class', 'style'],
|
||||
});
|
||||
|
||||
// 强制设置初始宽度
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/* Hide FinWise Pro parent menu and move children */
|
||||
.vben-sub-menu:has(.vben-sub-menu-content__title:is(:contains("FinWise Pro"), :contains("💎"))) {
|
||||
.vben-sub-menu:has(
|
||||
.vben-sub-menu-content__title:is(:contains('FinWise Pro'), :contains('💎'))
|
||||
) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -9,6 +11,6 @@
|
||||
}
|
||||
|
||||
/* Mark submenu for hiding */
|
||||
.vben-sub-menu[data-hide-finwise="true"] {
|
||||
.vben-sub-menu[data-hide-finwise='true'] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { initPreferences } from '@vben/preferences';
|
||||
import { unmountGlobalLoading } from '@vben/utils';
|
||||
|
||||
import { overridesPreferences } from './preferences';
|
||||
|
||||
import './custom.css';
|
||||
|
||||
/**
|
||||
@@ -36,7 +37,7 @@ function flattenFinWiseProMenu() {
|
||||
const submenus = document.querySelectorAll('.vben-sub-menu');
|
||||
let finwiseMenu: Element | null = null;
|
||||
|
||||
submenus.forEach(menu => {
|
||||
submenus.forEach((menu) => {
|
||||
const titleEl = menu.querySelector('.vben-sub-menu-content__title');
|
||||
if (titleEl?.textContent?.includes('FinWise Pro')) {
|
||||
finwiseMenu = menu;
|
||||
@@ -51,16 +52,17 @@ function flattenFinWiseProMenu() {
|
||||
if (!childrenUL || !parentMenu) return;
|
||||
|
||||
// Check if already processed
|
||||
if ((finwiseMenu as HTMLElement).getAttribute('data-hide-finwise') === 'true') return;
|
||||
if ((finwiseMenu as HTMLElement).dataset.hideFinwise === 'true')
|
||||
return;
|
||||
|
||||
// Move all children to the parent menu
|
||||
const children = Array.from(childrenUL.children);
|
||||
children.forEach(child => {
|
||||
parentMenu.insertBefore(child, finwiseMenu);
|
||||
const children = [...childrenUL.children];
|
||||
children.forEach((child) => {
|
||||
finwiseMenu.before(child);
|
||||
});
|
||||
|
||||
// Mark for hiding via CSS and hide directly
|
||||
(finwiseMenu as HTMLElement).setAttribute('data-hide-finwise', 'true');
|
||||
(finwiseMenu as HTMLElement).dataset.hideFinwise = 'true';
|
||||
(finwiseMenu as HTMLElement).style.display = 'none';
|
||||
}
|
||||
|
||||
@@ -91,7 +93,7 @@ setTimeout(() => {
|
||||
if (body) {
|
||||
observer.observe(body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
|
||||
@@ -40,7 +40,7 @@ router.afterEach(() => {
|
||||
const submenus = document.querySelectorAll('.vben-sub-menu');
|
||||
let finwiseMenu: Element | null = null;
|
||||
|
||||
submenus.forEach(menu => {
|
||||
submenus.forEach((menu) => {
|
||||
const titleEl = menu.querySelector('.vben-sub-menu-content__title');
|
||||
if (titleEl?.textContent?.includes('FinWise Pro')) {
|
||||
finwiseMenu = menu;
|
||||
@@ -55,16 +55,19 @@ router.afterEach(() => {
|
||||
if (!childrenUL || !parentMenu) return;
|
||||
|
||||
// Check if already processed
|
||||
if ((finwiseMenu as HTMLElement).getAttribute('data-hide-finwise') === 'true') return;
|
||||
if (
|
||||
(finwiseMenu as HTMLElement).dataset.hideFinwise === 'true'
|
||||
)
|
||||
return;
|
||||
|
||||
// Move all children to the parent menu
|
||||
const children = Array.from(childrenUL.children);
|
||||
children.forEach(child => {
|
||||
parentMenu.insertBefore(child, finwiseMenu);
|
||||
const children = [...childrenUL.children];
|
||||
children.forEach((child) => {
|
||||
finwiseMenu.before(child);
|
||||
});
|
||||
|
||||
// Mark for hiding via CSS and hide directly
|
||||
(finwiseMenu as HTMLElement).setAttribute('data-hide-finwise', 'true');
|
||||
(finwiseMenu as HTMLElement).dataset.hideFinwise = 'true';
|
||||
(finwiseMenu as HTMLElement).style.display = 'none';
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
import { FinanceApi } from '#/api/core/finance';
|
||||
|
||||
export const useFinanceStore = defineStore('finance', () => {
|
||||
@@ -50,10 +51,10 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
|
||||
// 创建分类
|
||||
async function createCategory(data: {
|
||||
name: string;
|
||||
type: 'income' | 'expense';
|
||||
icon?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
name: string;
|
||||
type: 'expense' | 'income';
|
||||
}) {
|
||||
const category = await FinanceApi.createCategory(data);
|
||||
if (!category) {
|
||||
@@ -71,9 +72,9 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
async function updateCategory(
|
||||
id: number,
|
||||
data: {
|
||||
name?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
name?: string;
|
||||
sortOrder?: number;
|
||||
},
|
||||
) {
|
||||
@@ -97,13 +98,13 @@ export const useFinanceStore = defineStore('finance', () => {
|
||||
await FinanceApi.deleteCategory(id);
|
||||
// 从本地列表中移除
|
||||
let index = incomeCategories.value.findIndex((c) => c.id === id);
|
||||
if (index !== -1) {
|
||||
incomeCategories.value.splice(index, 1);
|
||||
} else {
|
||||
if (index === -1) {
|
||||
index = expenseCategories.value.findIndex((c) => c.id === id);
|
||||
if (index !== -1) {
|
||||
expenseCategories.value.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
incomeCategories.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import type { VbenFormSchema } from '@vben/common-ui';
|
||||
import type { BasicOption } from '@vben/types';
|
||||
|
||||
import { computed, markRaw } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { AuthenticationLogin, SliderCaptcha, z } from '@vben/common-ui';
|
||||
import { AuthenticationLogin, z } from '@vben/common-ui';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
@@ -11,13 +11,17 @@ function handleBackHome() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen w-screen flex-col items-center justify-center bg-background">
|
||||
<h1 class="mb-4 text-6xl font-bold text-foreground">404</h1>
|
||||
<h2 class="mb-2 text-2xl font-semibold text-foreground">哎呀!未找到页面</h2>
|
||||
<p class="mb-8 text-muted-foreground">抱歉,我们无法找到您要找的页面。</p>
|
||||
<div
|
||||
class="bg-background flex h-screen w-screen flex-col items-center justify-center"
|
||||
>
|
||||
<h1 class="text-foreground mb-4 text-6xl font-bold">404</h1>
|
||||
<h2 class="text-foreground mb-2 text-2xl font-semibold">
|
||||
哎呀!未找到页面
|
||||
</h2>
|
||||
<p class="text-muted-foreground mb-8">抱歉,我们无法找到您要找的页面。</p>
|
||||
<button
|
||||
@click="handleBackHome"
|
||||
class="rounded-md bg-primary px-6 py-2 text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
class="bg-primary text-primary-foreground hover:bg-primary/90 rounded-md px-6 py-2 transition-colors"
|
||||
>
|
||||
返回首页
|
||||
</button>
|
||||
|
||||
@@ -21,7 +21,20 @@ import { preferences } from '@vben/preferences';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { Modal, Form, Input, Select, DatePicker, InputNumber, message, Radio, Space, Button, Row, Col, Switch } from 'ant-design-vue';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Switch,
|
||||
} from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { useFinanceStore } from '#/store/finance';
|
||||
@@ -38,7 +51,7 @@ onMounted(async () => {
|
||||
|
||||
// 快速记账弹窗
|
||||
const quickAddVisible = ref(false);
|
||||
const transactionType = ref<'income' | 'expense'>('expense');
|
||||
const transactionType = ref<'expense' | 'income'>('expense');
|
||||
const formRef = ref();
|
||||
const formState = ref({
|
||||
currency: 'CNY', // 默认人民币
|
||||
@@ -57,7 +70,9 @@ const formState = ref({
|
||||
const useQuantityMode = ref(false);
|
||||
|
||||
// 当前选中的日期类型
|
||||
const selectedDateType = ref<'today' | 'yesterday' | 'week' | 'month' | 'custom'>('today');
|
||||
const selectedDateType = ref<
|
||||
'custom' | 'month' | 'today' | 'week' | 'yesterday'
|
||||
>('today');
|
||||
|
||||
// 字段触摸状态(用于判断是否显示验证提示)
|
||||
const touchedFields = ref({
|
||||
@@ -67,11 +82,14 @@ const touchedFields = ref({
|
||||
});
|
||||
|
||||
// 监听单价和数量变化,自动计算总金额
|
||||
watch([() => formState.value.unitPrice, () => formState.value.quantity], ([unitPrice, quantity]) => {
|
||||
if (useQuantityMode.value && unitPrice && quantity) {
|
||||
formState.value.amount = unitPrice * quantity;
|
||||
}
|
||||
});
|
||||
watch(
|
||||
[() => formState.value.unitPrice, () => formState.value.quantity],
|
||||
([unitPrice, quantity]) => {
|
||||
if (useQuantityMode.value && unitPrice && quantity) {
|
||||
formState.value.amount = unitPrice * quantity;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 切换计算模式
|
||||
const toggleQuantityMode = (enabled: boolean) => {
|
||||
@@ -79,7 +97,8 @@ const toggleQuantityMode = (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
// 如果当前有金额,反推单价
|
||||
if (formState.value.amount && formState.value.quantity) {
|
||||
formState.value.unitPrice = formState.value.amount / formState.value.quantity;
|
||||
formState.value.unitPrice =
|
||||
formState.value.amount / formState.value.quantity;
|
||||
}
|
||||
} else {
|
||||
// 关闭模式时清空单价和数量
|
||||
@@ -107,30 +126,38 @@ const currentCurrencySymbol = computed(() => {
|
||||
});
|
||||
|
||||
// 监听货币变化,重置账户选择
|
||||
watch(() => formState.value.currency, () => {
|
||||
formState.value.account = undefined;
|
||||
touchedFields.value.account = true; // 标记账户字段为已触摸
|
||||
});
|
||||
watch(
|
||||
() => formState.value.currency,
|
||||
() => {
|
||||
formState.value.account = undefined;
|
||||
touchedFields.value.account = true; // 标记账户字段为已触摸
|
||||
},
|
||||
);
|
||||
|
||||
// 监听账户变化,保存到localStorage
|
||||
watch(() => formState.value.account, (newAccountId) => {
|
||||
if (newAccountId && transactionType.value) {
|
||||
const storageKey = transactionType.value === 'income'
|
||||
? 'lastWorkspaceIncomeAccountId'
|
||||
: 'lastWorkspaceExpenseAccountId';
|
||||
localStorage.setItem(storageKey, String(newAccountId));
|
||||
}
|
||||
});
|
||||
watch(
|
||||
() => formState.value.account,
|
||||
(newAccountId) => {
|
||||
if (newAccountId && transactionType.value) {
|
||||
const storageKey =
|
||||
transactionType.value === 'income'
|
||||
? 'lastWorkspaceIncomeAccountId'
|
||||
: 'lastWorkspaceExpenseAccountId';
|
||||
localStorage.setItem(storageKey, String(newAccountId));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 打开快速记账弹窗
|
||||
const openQuickAdd = (type: 'income' | 'expense') => {
|
||||
const openQuickAdd = (type: 'expense' | 'income') => {
|
||||
transactionType.value = type;
|
||||
quickAddVisible.value = true;
|
||||
|
||||
// 读取上次选择的账户
|
||||
const storageKey = type === 'income'
|
||||
? 'lastWorkspaceIncomeAccountId'
|
||||
: 'lastWorkspaceExpenseAccountId';
|
||||
const storageKey =
|
||||
type === 'income'
|
||||
? 'lastWorkspaceIncomeAccountId'
|
||||
: 'lastWorkspaceExpenseAccountId';
|
||||
const lastAccountId = localStorage.getItem(storageKey);
|
||||
const accountId = lastAccountId ? Number(lastAccountId) : undefined;
|
||||
|
||||
@@ -160,54 +187,61 @@ const openQuickAdd = (type: 'income' | 'expense') => {
|
||||
};
|
||||
|
||||
// 日期快捷方式
|
||||
const setDate = (type: 'today' | 'yesterday' | 'week' | 'month') => {
|
||||
const setDate = (type: 'month' | 'today' | 'week' | 'yesterday') => {
|
||||
selectedDateType.value = type;
|
||||
switch (type) {
|
||||
case 'today':
|
||||
formState.value.date = dayjs();
|
||||
break;
|
||||
case 'yesterday':
|
||||
formState.value.date = dayjs().subtract(1, 'day');
|
||||
break;
|
||||
case 'week':
|
||||
formState.value.date = dayjs().startOf('week');
|
||||
break;
|
||||
case 'month':
|
||||
case 'month': {
|
||||
formState.value.date = dayjs().startOf('month');
|
||||
break;
|
||||
}
|
||||
case 'today': {
|
||||
formState.value.date = dayjs();
|
||||
break;
|
||||
}
|
||||
case 'week': {
|
||||
formState.value.date = dayjs().startOf('week');
|
||||
break;
|
||||
}
|
||||
case 'yesterday': {
|
||||
formState.value.date = dayjs().subtract(1, 'day');
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 监听日期手动变化,设置为自定义
|
||||
watch(() => formState.value.date, (newDate) => {
|
||||
if (!newDate) return;
|
||||
watch(
|
||||
() => formState.value.date,
|
||||
(newDate) => {
|
||||
if (!newDate) return;
|
||||
|
||||
const today = dayjs();
|
||||
const yesterday = dayjs().subtract(1, 'day');
|
||||
const weekStart = dayjs().startOf('week');
|
||||
const monthStart = dayjs().startOf('month');
|
||||
const today = dayjs();
|
||||
const yesterday = dayjs().subtract(1, 'day');
|
||||
const weekStart = dayjs().startOf('week');
|
||||
const monthStart = dayjs().startOf('month');
|
||||
|
||||
if (newDate.isSame(today, 'day')) {
|
||||
selectedDateType.value = 'today';
|
||||
} else if (newDate.isSame(yesterday, 'day')) {
|
||||
selectedDateType.value = 'yesterday';
|
||||
} else if (newDate.isSame(weekStart, 'day')) {
|
||||
selectedDateType.value = 'week';
|
||||
} else if (newDate.isSame(monthStart, 'day')) {
|
||||
selectedDateType.value = 'month';
|
||||
} else {
|
||||
selectedDateType.value = 'custom';
|
||||
}
|
||||
});
|
||||
if (newDate.isSame(today, 'day')) {
|
||||
selectedDateType.value = 'today';
|
||||
} else if (newDate.isSame(yesterday, 'day')) {
|
||||
selectedDateType.value = 'yesterday';
|
||||
} else if (newDate.isSame(weekStart, 'day')) {
|
||||
selectedDateType.value = 'week';
|
||||
} else if (newDate.isSame(monthStart, 'day')) {
|
||||
selectedDateType.value = 'month';
|
||||
} else {
|
||||
selectedDateType.value = 'custom';
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 获取日期类型对应的颜色
|
||||
const getDateTypeColor = (type: string) => {
|
||||
const colors = {
|
||||
today: '#52c41a', // 绿色 - 今天
|
||||
today: '#52c41a', // 绿色 - 今天
|
||||
yesterday: '#1890ff', // 蓝色 - 昨天
|
||||
week: '#722ed1', // 紫色 - 本周
|
||||
month: '#fa8c16', // 橙色 - 本月
|
||||
custom: '#8c8c8c', // 灰色 - 自定义
|
||||
week: '#722ed1', // 紫色 - 本周
|
||||
month: '#fa8c16', // 橙色 - 本月
|
||||
custom: '#8c8c8c', // 灰色 - 自定义
|
||||
};
|
||||
return colors[type] || colors.custom;
|
||||
};
|
||||
@@ -216,7 +250,9 @@ const getDateTypeColor = (type: string) => {
|
||||
const fieldErrors = computed(() => ({
|
||||
category: touchedFields.value.category && !formState.value.category,
|
||||
account: touchedFields.value.account && !formState.value.account,
|
||||
amount: touchedFields.value.amount && (!formState.value.amount || formState.value.amount <= 0),
|
||||
amount:
|
||||
touchedFields.value.amount &&
|
||||
(!formState.value.amount || formState.value.amount <= 0),
|
||||
}));
|
||||
|
||||
// 提交记账
|
||||
@@ -246,7 +282,9 @@ const handleQuickAdd = async () => {
|
||||
});
|
||||
|
||||
console.log('交易创建成功:', transaction);
|
||||
message.success(`${transactionType.value === 'income' ? '收入' : '支出'}记录成功!`);
|
||||
message.success(
|
||||
`${transactionType.value === 'income' ? '收入' : '支出'}记录成功!`,
|
||||
);
|
||||
quickAddVisible.value = false;
|
||||
|
||||
// 重置表单
|
||||
@@ -385,31 +423,31 @@ const todoItems = ref<WorkbenchTodoItem[]>([
|
||||
{
|
||||
completed: false,
|
||||
content: `记录本月的水电费、房租等固定支出`,
|
||||
date: new Date().toLocaleDateString() + ' 18:00:00',
|
||||
date: `${new Date().toLocaleDateString()} 18:00:00`,
|
||||
title: '录入本月固定支出',
|
||||
},
|
||||
{
|
||||
completed: false,
|
||||
content: `查看并调整各类别的预算设置,确保支出在可控范围内`,
|
||||
date: new Date().toLocaleDateString() + ' 20:00:00',
|
||||
date: `${new Date().toLocaleDateString()} 20:00:00`,
|
||||
title: '检查月度预算执行情况',
|
||||
},
|
||||
{
|
||||
completed: true,
|
||||
content: `完成本周的收入记录,包括工资和其他收入来源`,
|
||||
date: new Date().toLocaleDateString() + ' 10:00:00',
|
||||
date: `${new Date().toLocaleDateString()} 10:00:00`,
|
||||
title: '记录本周收入',
|
||||
},
|
||||
{
|
||||
completed: false,
|
||||
content: `核对银行账户余额,确保系统数据与实际一致`,
|
||||
date: new Date().toLocaleDateString() + ' 15:00:00',
|
||||
date: `${new Date().toLocaleDateString()} 15:00:00`,
|
||||
title: '对账核对',
|
||||
},
|
||||
{
|
||||
completed: false,
|
||||
content: `分析上月的支出报表,找出可以节省开支的地方`,
|
||||
date: new Date().toLocaleDateString() + ' 16:00:00',
|
||||
date: `${new Date().toLocaleDateString()} 16:00:00`,
|
||||
title: '生成月度财务报表',
|
||||
},
|
||||
]);
|
||||
@@ -521,18 +559,28 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
|
||||
<div class="mt-5 flex flex-col lg:flex-row">
|
||||
<div class="mr-4 w-full lg:w-3/5">
|
||||
<WorkbenchProject :items="projectItems" title="财务功能快捷入口" @click="navTo" />
|
||||
<WorkbenchTrends :items="trendItems" class="mt-5" title="最近财务活动" />
|
||||
<WorkbenchProject
|
||||
:items="projectItems"
|
||||
title="财务功能快捷入口"
|
||||
@click="navTo"
|
||||
/>
|
||||
<WorkbenchTrends
|
||||
:items="trendItems"
|
||||
class="mt-5"
|
||||
title="最近财务活动"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full lg:w-2/5">
|
||||
<WorkbenchQuickNav
|
||||
:items="quickNavItems"
|
||||
class="mt-5 lg:mt-0"
|
||||
title="快捷操作"
|
||||
@click="(item) => {
|
||||
console.log('WorkbenchQuickNav click事件触发:', item);
|
||||
navTo(item);
|
||||
}"
|
||||
@click="
|
||||
(item) => {
|
||||
console.log('WorkbenchQuickNav click事件触发:', item);
|
||||
navTo(item);
|
||||
}
|
||||
"
|
||||
/>
|
||||
<WorkbenchTodo :items="todoItems" class="mt-5" title="财务待办事项" />
|
||||
<AnalysisChartCard class="mt-5" title="本月收支概览">
|
||||
@@ -547,15 +595,18 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
:title="transactionType === 'income' ? '💰 添加收入' : '💸 添加支出'"
|
||||
:width="900"
|
||||
@ok="handleQuickAdd"
|
||||
@cancel="() => { quickAddVisible = false; }"
|
||||
@update:open="(val) => { quickAddVisible = val; }"
|
||||
@cancel="
|
||||
() => {
|
||||
quickAddVisible = false;
|
||||
}
|
||||
"
|
||||
@update:open="
|
||||
(val) => {
|
||||
quickAddVisible = val;
|
||||
}
|
||||
"
|
||||
>
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="formState"
|
||||
layout="vertical"
|
||||
class="mt-4"
|
||||
>
|
||||
<Form ref="formRef" :model="formState" layout="vertical" class="mt-4">
|
||||
<Row :gutter="16">
|
||||
<!-- 分类 -->
|
||||
<Col :span="14">
|
||||
@@ -567,7 +618,15 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
:help="fieldErrors.category ? '⚠️ 请选择一个分类' : ''"
|
||||
>
|
||||
<div
|
||||
:style="fieldErrors.category ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '8px' } : {}"
|
||||
:style="
|
||||
fieldErrors.category
|
||||
? {
|
||||
border: '2px solid #ff4d4f',
|
||||
borderRadius: '6px',
|
||||
padding: '8px',
|
||||
}
|
||||
: {}
|
||||
"
|
||||
>
|
||||
<Radio.Group
|
||||
v-model:value="formState.category"
|
||||
@@ -590,10 +649,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
|
||||
<!-- 项目名称 -->
|
||||
<Col :span="10">
|
||||
<Form.Item
|
||||
label="项目名称"
|
||||
name="description"
|
||||
>
|
||||
<Form.Item label="项目名称" name="description">
|
||||
<Input.TextArea
|
||||
v-model:value="formState.description"
|
||||
placeholder="请输入项目名称..."
|
||||
@@ -605,11 +661,13 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
</Row>
|
||||
|
||||
<!-- 货币类型、账户和金额(放在一起) -->
|
||||
<div class="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg mb-4">
|
||||
<div class="mb-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2">货币类型 <span class="text-red-500">*</span></label>
|
||||
<label class="mb-2 block text-sm font-medium"
|
||||
>货币类型 <span class="text-red-500">*</span></label
|
||||
>
|
||||
<Radio.Group
|
||||
v-model:value="formState.currency"
|
||||
size="large"
|
||||
@@ -629,7 +687,10 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
<Col :span="12">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<label class="text-sm font-medium">按数量×单价计算</label>
|
||||
<Switch v-model:checked="useQuantityMode" @change="toggleQuantityMode" />
|
||||
<Switch
|
||||
v-model:checked="useQuantityMode"
|
||||
@change="toggleQuantityMode"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -637,7 +698,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
<!-- 数量×单价模式 -->
|
||||
<Row v-if="useQuantityMode" :gutter="16" class="mb-4">
|
||||
<Col :span="8">
|
||||
<label class="block text-sm font-medium mb-2">数量</label>
|
||||
<label class="mb-2 block text-sm font-medium">数量</label>
|
||||
<InputNumber
|
||||
v-model:value="formState.quantity"
|
||||
:min="0.01"
|
||||
@@ -648,7 +709,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
/>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<label class="block text-sm font-medium mb-2">单价</label>
|
||||
<label class="mb-2 block text-sm font-medium">单价</label>
|
||||
<InputNumber
|
||||
v-model:value="formState.unitPrice"
|
||||
:min="0"
|
||||
@@ -661,12 +722,24 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
</InputNumber>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<label class="block text-sm font-medium mb-2">
|
||||
<label class="mb-2 block text-sm font-medium">
|
||||
总金额 <span class="text-red-500">*</span>
|
||||
<span v-if="fieldErrors.amount" class="text-red-500 text-xs ml-1">⚠️</span>
|
||||
<span
|
||||
v-if="fieldErrors.amount"
|
||||
class="ml-1 text-xs text-red-500"
|
||||
>⚠️</span
|
||||
>
|
||||
</label>
|
||||
<div
|
||||
:style="fieldErrors.amount ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '2px' } : {}"
|
||||
:style="
|
||||
fieldErrors.amount
|
||||
? {
|
||||
border: '2px solid #ff4d4f',
|
||||
borderRadius: '6px',
|
||||
padding: '2px',
|
||||
}
|
||||
: {}
|
||||
"
|
||||
>
|
||||
<InputNumber
|
||||
v-model:value="formState.amount"
|
||||
@@ -687,12 +760,24 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
<!-- 直接输入金额模式 -->
|
||||
<Row v-else :gutter="16" class="mb-4">
|
||||
<Col :span="24">
|
||||
<label class="block text-sm font-medium mb-2">
|
||||
<label class="mb-2 block text-sm font-medium">
|
||||
金额 <span class="text-red-500">*</span>
|
||||
<span v-if="fieldErrors.amount" class="text-red-500 text-xs ml-2">⚠️ 请输入金额</span>
|
||||
<span
|
||||
v-if="fieldErrors.amount"
|
||||
class="ml-2 text-xs text-red-500"
|
||||
>⚠️ 请输入金额</span
|
||||
>
|
||||
</label>
|
||||
<div
|
||||
:style="fieldErrors.amount ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '2px' } : {}"
|
||||
:style="
|
||||
fieldErrors.amount
|
||||
? {
|
||||
border: '2px solid #ff4d4f',
|
||||
borderRadius: '6px',
|
||||
padding: '2px',
|
||||
}
|
||||
: {}
|
||||
"
|
||||
>
|
||||
<InputNumber
|
||||
v-model:value="formState.amount"
|
||||
@@ -712,7 +797,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
<!-- 重量(可选) -->
|
||||
<Row :gutter="16" class="mb-4">
|
||||
<Col :span="16">
|
||||
<label class="block text-sm font-medium mb-2">重量(可选)</label>
|
||||
<label class="mb-2 block text-sm font-medium">重量(可选)</label>
|
||||
<InputNumber
|
||||
v-model:value="formState.weight"
|
||||
:min="0"
|
||||
@@ -722,7 +807,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
/>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<label class="block text-sm font-medium mb-2">单位</label>
|
||||
<label class="mb-2 block text-sm font-medium">单位</label>
|
||||
<Select v-model:value="formState.weightUnit" style="width: 100%">
|
||||
<Select.Option value="kg">千克(kg)</Select.Option>
|
||||
<Select.Option value="g">克(g)</Select.Option>
|
||||
@@ -733,12 +818,23 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
</Row>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">
|
||||
{{ transactionType === 'income' ? '收入账户' : '支出账户' }} <span class="text-red-500">*</span>
|
||||
<span v-if="fieldErrors.account" class="text-red-500 text-xs ml-2">⚠️ 请选择账户</span>
|
||||
<label class="mb-2 block text-sm font-medium">
|
||||
{{ transactionType === 'income' ? '收入账户' : '支出账户' }}
|
||||
<span class="text-red-500">*</span>
|
||||
<span v-if="fieldErrors.account" class="ml-2 text-xs text-red-500"
|
||||
>⚠️ 请选择账户</span
|
||||
>
|
||||
</label>
|
||||
<div
|
||||
:style="fieldErrors.account ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '8px' } : {}"
|
||||
:style="
|
||||
fieldErrors.account
|
||||
? {
|
||||
border: '2px solid #ff4d4f',
|
||||
borderRadius: '6px',
|
||||
padding: '8px',
|
||||
}
|
||||
: {}
|
||||
"
|
||||
>
|
||||
<Radio.Group
|
||||
v-model:value="formState.account"
|
||||
@@ -766,15 +862,35 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
<div class="flex flex-col space-y-2">
|
||||
<Button
|
||||
:type="selectedDateType === 'today' ? 'primary' : 'default'"
|
||||
:style="{ backgroundColor: selectedDateType === 'today' ? getDateTypeColor('today') : undefined, borderColor: selectedDateType === 'today' ? getDateTypeColor('today') : undefined }"
|
||||
:style="{
|
||||
backgroundColor:
|
||||
selectedDateType === 'today'
|
||||
? getDateTypeColor('today')
|
||||
: undefined,
|
||||
borderColor:
|
||||
selectedDateType === 'today'
|
||||
? getDateTypeColor('today')
|
||||
: undefined,
|
||||
}"
|
||||
@click="setDate('today')"
|
||||
block
|
||||
>
|
||||
今天
|
||||
</Button>
|
||||
<Button
|
||||
:type="selectedDateType === 'yesterday' ? 'primary' : 'default'"
|
||||
:style="{ backgroundColor: selectedDateType === 'yesterday' ? getDateTypeColor('yesterday') : undefined, borderColor: selectedDateType === 'yesterday' ? getDateTypeColor('yesterday') : undefined }"
|
||||
:type="
|
||||
selectedDateType === 'yesterday' ? 'primary' : 'default'
|
||||
"
|
||||
:style="{
|
||||
backgroundColor:
|
||||
selectedDateType === 'yesterday'
|
||||
? getDateTypeColor('yesterday')
|
||||
: undefined,
|
||||
borderColor:
|
||||
selectedDateType === 'yesterday'
|
||||
? getDateTypeColor('yesterday')
|
||||
: undefined,
|
||||
}"
|
||||
@click="setDate('yesterday')"
|
||||
block
|
||||
>
|
||||
@@ -782,7 +898,16 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
</Button>
|
||||
<Button
|
||||
:type="selectedDateType === 'week' ? 'primary' : 'default'"
|
||||
:style="{ backgroundColor: selectedDateType === 'week' ? getDateTypeColor('week') : undefined, borderColor: selectedDateType === 'week' ? getDateTypeColor('week') : undefined }"
|
||||
:style="{
|
||||
backgroundColor:
|
||||
selectedDateType === 'week'
|
||||
? getDateTypeColor('week')
|
||||
: undefined,
|
||||
borderColor:
|
||||
selectedDateType === 'week'
|
||||
? getDateTypeColor('week')
|
||||
: undefined,
|
||||
}"
|
||||
@click="setDate('week')"
|
||||
block
|
||||
>
|
||||
@@ -790,7 +915,16 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
</Button>
|
||||
<Button
|
||||
:type="selectedDateType === 'month' ? 'primary' : 'default'"
|
||||
:style="{ backgroundColor: selectedDateType === 'month' ? getDateTypeColor('month') : undefined, borderColor: selectedDateType === 'month' ? getDateTypeColor('month') : undefined }"
|
||||
:style="{
|
||||
backgroundColor:
|
||||
selectedDateType === 'month'
|
||||
? getDateTypeColor('month')
|
||||
: undefined,
|
||||
borderColor:
|
||||
selectedDateType === 'month'
|
||||
? getDateTypeColor('month')
|
||||
: undefined,
|
||||
}"
|
||||
@click="setDate('month')"
|
||||
block
|
||||
>
|
||||
@@ -800,16 +934,13 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="14">
|
||||
<Form.Item
|
||||
label="选择日期"
|
||||
name="date"
|
||||
>
|
||||
<Form.Item label="选择日期" name="date">
|
||||
<div
|
||||
class="date-picker-wrapper"
|
||||
:style="{
|
||||
border: `2px solid ${getDateTypeColor(selectedDateType)}`,
|
||||
borderRadius: '6px',
|
||||
padding: '4px'
|
||||
padding: '4px',
|
||||
}"
|
||||
>
|
||||
<DatePicker
|
||||
@@ -846,10 +977,15 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
:deep(.category-radio-group .ant-radio-button-wrapper:not(:first-child)::before),
|
||||
:deep(.currency-radio-group .ant-radio-button-wrapper:not(:first-child)::before),
|
||||
:deep(.account-radio-group .ant-radio-button-wrapper:not(:first-child)::before) {
|
||||
:deep(
|
||||
.category-radio-group .ant-radio-button-wrapper:not(:first-child)::before
|
||||
),
|
||||
:deep(
|
||||
.currency-radio-group .ant-radio-button-wrapper:not(:first-child)::before
|
||||
),
|
||||
:deep(
|
||||
.account-radio-group .ant-radio-button-wrapper:not(:first-child)::before
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,320 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Dropdown,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Menu,
|
||||
Modal,
|
||||
notification,
|
||||
Progress,
|
||||
Row,
|
||||
Select,
|
||||
Slider,
|
||||
Switch,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { useFinanceStore } from '#/store/finance';
|
||||
|
||||
defineOptions({ name: 'BudgetManagement' });
|
||||
|
||||
const financeStore = useFinanceStore();
|
||||
const budgets = computed(() =>
|
||||
financeStore.budgets.filter((b) => !b.isDeleted),
|
||||
);
|
||||
const showAddModal = ref(false);
|
||||
const formRef = ref();
|
||||
|
||||
// 表单数据
|
||||
const budgetForm = ref({
|
||||
category: '',
|
||||
customCategoryName: '',
|
||||
customCategoryIcon: '',
|
||||
limit: null,
|
||||
currency: 'CNY',
|
||||
customCurrencyCode: '',
|
||||
customCurrencyName: '',
|
||||
period: 'monthly',
|
||||
alertThreshold: 80,
|
||||
description: '',
|
||||
autoRenew: true,
|
||||
overspendAlert: true,
|
||||
dailyReminder: false,
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
category: [{ required: true, message: '请选择预算分类', trigger: 'change' }],
|
||||
limit: [
|
||||
{ required: true, message: '请输入预算金额', trigger: 'blur' },
|
||||
{
|
||||
type: 'number',
|
||||
min: 0.01,
|
||||
message: '预算金额必须大于0',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
currency: [{ required: true, message: '请选择币种', trigger: 'change' }],
|
||||
period: [{ required: true, message: '请选择预算周期', trigger: 'change' }],
|
||||
};
|
||||
|
||||
// 计算属性
|
||||
const totalBudget = computed(() => {
|
||||
return budgets.value.reduce((sum, budget) => sum + budget.limit, 0);
|
||||
});
|
||||
|
||||
const totalSpent = computed(() => {
|
||||
return budgets.value.reduce((sum, budget) => sum + budget.spent, 0);
|
||||
});
|
||||
|
||||
const totalRemaining = computed(() => {
|
||||
return budgets.value.reduce((sum, budget) => sum + budget.remaining, 0);
|
||||
});
|
||||
|
||||
const averageUsage = computed(() => {
|
||||
if (budgets.value.length === 0) return 0;
|
||||
return (
|
||||
budgets.value.reduce((sum, budget) => sum + budget.percentage, 0) /
|
||||
budgets.value.length
|
||||
);
|
||||
});
|
||||
|
||||
// 功能方法
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatCurrencyWithCode = (amount: number, currencyCode: string) => {
|
||||
// 如果是自定义币种(包含括号),直接显示数字 + 币种代码
|
||||
if (currencyCode && currencyCode.includes('(')) {
|
||||
return `${amount.toLocaleString()} ${currencyCode}`;
|
||||
}
|
||||
|
||||
// 对于标准币种,使用格式化
|
||||
try {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: currencyCode || 'CNY',
|
||||
}).format(amount);
|
||||
} catch {
|
||||
// 如果币种代码不被支持,则直接显示数字 + 代码
|
||||
return `${amount.toLocaleString()} ${currencyCode || 'CNY'}`;
|
||||
}
|
||||
};
|
||||
|
||||
const getProgressColor = (percentage: number) => {
|
||||
if (percentage > 100) return '#ff4d4f';
|
||||
if (percentage > 90) return '#faad14';
|
||||
if (percentage > 75) return '#1890ff';
|
||||
return '#52c41a';
|
||||
};
|
||||
|
||||
const getAmountColor = (percentage: number) => {
|
||||
if (percentage > 100) return 'text-red-600';
|
||||
if (percentage > 90) return 'text-orange-600';
|
||||
if (percentage > 75) return 'text-blue-600';
|
||||
return 'text-green-600';
|
||||
};
|
||||
|
||||
const getCategoryEmoji = (category: string) => {
|
||||
const emojiMap = {
|
||||
food: '🍽️',
|
||||
transport: '🚗',
|
||||
shopping: '🛒',
|
||||
entertainment: '🎮',
|
||||
medical: '🏥',
|
||||
housing: '🏠',
|
||||
education: '📚',
|
||||
travel: '✈️',
|
||||
};
|
||||
return emojiMap[category] || '🎯';
|
||||
};
|
||||
|
||||
const getCategoryName = (category: string) => {
|
||||
const nameMap = {
|
||||
food: '餐饮',
|
||||
transport: '交通',
|
||||
shopping: '购物',
|
||||
entertainment: '娱乐',
|
||||
medical: '医疗',
|
||||
housing: '住房',
|
||||
education: '教育',
|
||||
travel: '旅游',
|
||||
};
|
||||
return nameMap[category] || category;
|
||||
};
|
||||
|
||||
const openAddBudgetModal = () => {
|
||||
showAddModal.value = true;
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const submitBudget = async () => {
|
||||
try {
|
||||
// 表单验证
|
||||
await formRef.value.validate();
|
||||
|
||||
// 处理自定义字段
|
||||
const finalCategory =
|
||||
budgetForm.value.category === 'CUSTOM'
|
||||
? budgetForm.value.customCategoryName
|
||||
: getCategoryName(budgetForm.value.category);
|
||||
|
||||
const finalEmoji =
|
||||
budgetForm.value.category === 'CUSTOM'
|
||||
? budgetForm.value.customCategoryIcon
|
||||
: getCategoryEmoji(budgetForm.value.category);
|
||||
|
||||
const finalCurrency =
|
||||
budgetForm.value.currency === 'CUSTOM'
|
||||
? `${budgetForm.value.customCurrencyCode} (${budgetForm.value.customCurrencyName})`
|
||||
: budgetForm.value.currency;
|
||||
|
||||
// 检查分类是否已有预算
|
||||
const existingBudget = budgets.value.find(
|
||||
(b) => b.category === finalCategory,
|
||||
);
|
||||
if (existingBudget) {
|
||||
notification.error({
|
||||
message: '添加失败',
|
||||
description: '该分类已存在预算设置',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建新预算
|
||||
await financeStore.createBudget({
|
||||
category: finalCategory,
|
||||
emoji: finalEmoji,
|
||||
limit: budgetForm.value.limit,
|
||||
currency: finalCurrency,
|
||||
spent: 0,
|
||||
remaining: budgetForm.value.limit,
|
||||
percentage: 0,
|
||||
period: budgetForm.value.period,
|
||||
alertThreshold: budgetForm.value.alertThreshold,
|
||||
description: budgetForm.value.description,
|
||||
autoRenew: budgetForm.value.autoRenew,
|
||||
overspendAlert: budgetForm.value.overspendAlert,
|
||||
dailyReminder: budgetForm.value.dailyReminder,
|
||||
monthlyTrend: 0,
|
||||
});
|
||||
|
||||
notification.success({
|
||||
message: '预算设置成功',
|
||||
description: `${finalCategory} 预算已成功创建`,
|
||||
});
|
||||
|
||||
// 关闭模态框
|
||||
showAddModal.value = false;
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error);
|
||||
notification.error({
|
||||
message: '添加失败',
|
||||
description: '请检查表单信息是否正确',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const cancelAdd = () => {
|
||||
showAddModal.value = false;
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
budgetForm.value = {
|
||||
category: '',
|
||||
customCategoryName: '',
|
||||
customCategoryIcon: '',
|
||||
limit: null,
|
||||
currency: 'CNY',
|
||||
customCurrencyCode: '',
|
||||
customCurrencyName: '',
|
||||
period: 'monthly',
|
||||
alertThreshold: 80,
|
||||
description: '',
|
||||
autoRenew: true,
|
||||
overspendAlert: true,
|
||||
dailyReminder: false,
|
||||
};
|
||||
};
|
||||
|
||||
const handleCategoryChange = (category: string) => {
|
||||
console.log('分类选择:', category);
|
||||
if (category !== 'CUSTOM') {
|
||||
budgetForm.value.customCategoryName = '';
|
||||
budgetForm.value.customCategoryIcon = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleCurrencyChange = (currency: string) => {
|
||||
console.log('币种选择:', currency);
|
||||
if (currency !== 'CUSTOM') {
|
||||
budgetForm.value.customCurrencyCode = '';
|
||||
budgetForm.value.customCurrencyName = '';
|
||||
}
|
||||
};
|
||||
|
||||
// 预算操作方法
|
||||
const editBudget = (budget: any) => {
|
||||
console.log('编辑预算:', budget);
|
||||
notification.info({
|
||||
message: '编辑预算',
|
||||
description: `编辑 ${budget.category} 预算设置`,
|
||||
});
|
||||
};
|
||||
|
||||
const adjustBudget = (budget: any) => {
|
||||
console.log('调整预算额度:', budget);
|
||||
notification.info({
|
||||
message: '调整额度',
|
||||
description: `调整 ${budget.category} 预算额度`,
|
||||
});
|
||||
};
|
||||
|
||||
const viewHistory = (budget: any) => {
|
||||
console.log('查看预算历史:', budget);
|
||||
notification.info({
|
||||
message: '历史记录',
|
||||
description: `查看 ${budget.category} 预算历史`,
|
||||
});
|
||||
};
|
||||
|
||||
const deleteBudget = async (budget: any) => {
|
||||
console.log('删除预算:', budget);
|
||||
await financeStore.deleteBudget(budget.id);
|
||||
notification.success({
|
||||
message: '预算已删除',
|
||||
description: `${budget.category} 预算已删除`,
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await financeStore.fetchBudgets();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">🎯 预算管理</h1>
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">🎯 预算管理</h1>
|
||||
<p class="text-gray-600">设置和监控各类别的预算执行情况</p>
|
||||
</div>
|
||||
|
||||
<div v-if="budgets.length === 0" class="text-center py-12">
|
||||
<div class="text-8xl mb-6">🎯</div>
|
||||
<h3 class="text-xl font-medium text-gray-800 mb-2">暂无预算设置</h3>
|
||||
<p class="text-gray-500 mb-6">设置预算帮助您更好地控制支出</p>
|
||||
<div v-if="budgets.length === 0" class="py-12 text-center">
|
||||
<div class="mb-6 text-8xl">🎯</div>
|
||||
<h3 class="mb-2 text-xl font-medium text-gray-800">暂无预算设置</h3>
|
||||
<p class="mb-6 text-gray-500">设置预算帮助您更好地控制支出</p>
|
||||
<Button type="primary" size="large" @click="openAddBudgetModal">
|
||||
➕ 设置第一个预算
|
||||
</Button>
|
||||
@@ -16,40 +322,52 @@
|
||||
|
||||
<div v-else>
|
||||
<!-- 预算概览统计 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">💰</div>
|
||||
<p class="text-sm text-gray-500">总预算</p>
|
||||
<p class="text-xl font-bold text-blue-600">{{ formatCurrency(totalBudget) }}</p>
|
||||
<p class="text-xl font-bold text-blue-600">
|
||||
{{ formatCurrency(totalBudget) }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">📊</div>
|
||||
<p class="text-sm text-gray-500">已使用</p>
|
||||
<p class="text-xl font-bold text-orange-600">{{ formatCurrency(totalSpent) }}</p>
|
||||
<p class="text-xl font-bold text-orange-600">
|
||||
{{ formatCurrency(totalSpent) }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">🎯</div>
|
||||
<p class="text-sm text-gray-500">剩余预算</p>
|
||||
<p class="text-xl font-bold text-green-600">{{ formatCurrency(totalRemaining) }}</p>
|
||||
<p class="text-xl font-bold text-green-600">
|
||||
{{ formatCurrency(totalRemaining) }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="text-center">
|
||||
<div class="space-y-2">
|
||||
<div class="text-3xl">⚡</div>
|
||||
<p class="text-sm text-gray-500">执行率</p>
|
||||
<p class="text-xl font-bold text-purple-600">{{ averageUsage.toFixed(1) }}%</p>
|
||||
<p class="text-xl font-bold text-purple-600">
|
||||
{{ averageUsage.toFixed(1) }}%
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 预算卡片列表 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
|
||||
<Card v-for="budget in budgets" :key="budget.id" class="relative hover:shadow-lg transition-shadow">
|
||||
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Card
|
||||
v-for="budget in budgets"
|
||||
:key="budget.id"
|
||||
class="relative transition-shadow hover:shadow-lg"
|
||||
>
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -60,9 +378,18 @@
|
||||
<template #overlay>
|
||||
<Menu>
|
||||
<Menu.Item @click="editBudget(budget)">✏️ 编辑</Menu.Item>
|
||||
<Menu.Item @click="adjustBudget(budget)">📊 调整额度</Menu.Item>
|
||||
<Menu.Item @click="viewHistory(budget)">📈 历史记录</Menu.Item>
|
||||
<Menu.Item @click="deleteBudget(budget)" class="text-red-600">🗑️ 删除</Menu.Item>
|
||||
<Menu.Item @click="adjustBudget(budget)">
|
||||
📊 调整额度
|
||||
</Menu.Item>
|
||||
<Menu.Item @click="viewHistory(budget)">
|
||||
📈 历史记录
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
@click="deleteBudget(budget)"
|
||||
class="text-red-600"
|
||||
>
|
||||
🗑️ 删除
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
</template>
|
||||
<Button type="text" size="small">⚙️</Button>
|
||||
@@ -73,8 +400,12 @@
|
||||
<!-- 预算进度 -->
|
||||
<div class="space-y-4">
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold" :class="getAmountColor(budget.percentage)">
|
||||
{{ formatCurrencyWithCode(budget.spent, budget.currency) }} / {{ formatCurrencyWithCode(budget.limit, budget.currency) }}
|
||||
<p
|
||||
class="text-2xl font-bold"
|
||||
:class="getAmountColor(budget.percentage)"
|
||||
>
|
||||
{{ formatCurrencyWithCode(budget.spent, budget.currency) }} /
|
||||
{{ formatCurrencyWithCode(budget.limit, budget.currency) }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">已用 / 预算</p>
|
||||
</div>
|
||||
@@ -85,10 +416,22 @@
|
||||
/>
|
||||
|
||||
<div class="flex justify-between text-sm">
|
||||
<span :class="budget.remaining >= 0 ? 'text-green-600' : 'text-red-600'">
|
||||
{{ budget.remaining >= 0 ? '剩余' : '超支' }}: {{ formatCurrencyWithCode(Math.abs(budget.remaining), budget.currency) }}
|
||||
<span
|
||||
:class="
|
||||
budget.remaining >= 0 ? 'text-green-600' : 'text-red-600'
|
||||
"
|
||||
>
|
||||
{{ budget.remaining >= 0 ? '剩余' : '超支' }}:
|
||||
{{
|
||||
formatCurrencyWithCode(
|
||||
Math.abs(budget.remaining),
|
||||
budget.currency,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span class="text-gray-500">{{ budget.percentage.toFixed(1) }}%</span>
|
||||
<span class="text-gray-500"
|
||||
>{{ budget.percentage.toFixed(1) }}%</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 预算状态标签 -->
|
||||
@@ -102,25 +445,32 @@
|
||||
<Tag v-else-if="budget.percentage > 75" color="blue">
|
||||
ℹ️ 使用正常
|
||||
</Tag>
|
||||
<Tag v-else color="green">
|
||||
✅ 控制良好
|
||||
</Tag>
|
||||
<Tag v-else color="green"> ✅ 控制良好 </Tag>
|
||||
</div>
|
||||
|
||||
<!-- 月度趋势 -->
|
||||
<div v-if="budget.monthlyTrend" class="text-center">
|
||||
<p class="text-xs text-gray-500">相比上月</p>
|
||||
<p :class="budget.monthlyTrend >= 0 ? 'text-red-500' : 'text-green-500'" class="font-medium">
|
||||
{{ budget.monthlyTrend >= 0 ? '↗️' : '↘️' }} {{ Math.abs(budget.monthlyTrend).toFixed(1) }}%
|
||||
<p
|
||||
:class="
|
||||
budget.monthlyTrend >= 0 ? 'text-red-500' : 'text-green-500'
|
||||
"
|
||||
class="font-medium"
|
||||
>
|
||||
{{ budget.monthlyTrend >= 0 ? '↗️' : '↘️' }}
|
||||
{{ Math.abs(budget.monthlyTrend).toFixed(1) }}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 添加预算卡片 -->
|
||||
<Card class="border-2 border-dashed border-gray-300 hover:border-blue-400 cursor-pointer transition-all" @click="openAddBudgetModal">
|
||||
<div class="text-center py-12">
|
||||
<div class="text-6xl mb-4">➕</div>
|
||||
<Card
|
||||
class="cursor-pointer border-2 border-dashed border-gray-300 transition-all hover:border-blue-400"
|
||||
@click="openAddBudgetModal"
|
||||
>
|
||||
<div class="py-12 text-center">
|
||||
<div class="mb-4 text-6xl">➕</div>
|
||||
<h3 class="font-medium text-gray-800">添加新预算</h3>
|
||||
<p class="text-sm text-gray-500">为分类设置预算控制</p>
|
||||
</div>
|
||||
@@ -138,7 +488,12 @@
|
||||
>
|
||||
<Form ref="formRef" :model="budgetForm" :rules="rules" layout="vertical">
|
||||
<Form.Item label="预算分类" name="category" required>
|
||||
<Select v-model:value="budgetForm.category" placeholder="选择分类" size="large" @change="handleCategoryChange">
|
||||
<Select
|
||||
v-model:value="budgetForm.category"
|
||||
placeholder="选择分类"
|
||||
size="large"
|
||||
@change="handleCategoryChange"
|
||||
>
|
||||
<Select.Option value="food">🍽️ 餐饮</Select.Option>
|
||||
<Select.Option value="transport">🚗 交通</Select.Option>
|
||||
<Select.Option value="shopping">🛒 购物</Select.Option>
|
||||
@@ -156,12 +511,18 @@
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="分类名称" required>
|
||||
<Input v-model:value="budgetForm.customCategoryName" placeholder="请输入分类名称,如: 宝贝用品、理财等" />
|
||||
<Input
|
||||
v-model:value="budgetForm.customCategoryName"
|
||||
placeholder="请输入分类名称,如: 宝贝用品、理财等"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="分类图标" required>
|
||||
<Input v-model:value="budgetForm.customCategoryIcon" placeholder="请输入图标,如: 👶, 💹 等" />
|
||||
<Input
|
||||
v-model:value="budgetForm.customCategoryIcon"
|
||||
placeholder="请输入图标,如: 👶, 💹 等"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -182,7 +543,12 @@
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Form.Item label="金额币种" name="currency" required>
|
||||
<Select v-model:value="budgetForm.currency" placeholder="选择币种" size="large" @change="handleCurrencyChange">
|
||||
<Select
|
||||
v-model:value="budgetForm.currency"
|
||||
placeholder="选择币种"
|
||||
size="large"
|
||||
@change="handleCurrencyChange"
|
||||
>
|
||||
<Select.Option value="CNY">🇨🇳 人民币</Select.Option>
|
||||
<Select.Option value="USD">🇺🇸 美元</Select.Option>
|
||||
<Select.Option value="EUR">🇪🇺 欧元</Select.Option>
|
||||
@@ -211,12 +577,19 @@
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="币种代码" required>
|
||||
<Input v-model:value="budgetForm.customCurrencyCode" placeholder="如: THB, AUD 等" style="text-transform: uppercase" />
|
||||
<Input
|
||||
v-model:value="budgetForm.customCurrencyCode"
|
||||
placeholder="如: THB, AUD 等"
|
||||
style="text-transform: uppercase"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="币种名称" required>
|
||||
<Input v-model:value="budgetForm.customCurrencyName" placeholder="如: 泰铢, 澳元 等" />
|
||||
<Input
|
||||
v-model:value="budgetForm.customCurrencyName"
|
||||
placeholder="如: 泰铢, 澳元 等"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -231,7 +604,9 @@
|
||||
:step="5"
|
||||
:marks="{ 50: '50%', 75: '75%', 90: '90%', 100: '100%' }"
|
||||
/>
|
||||
<p class="text-sm text-gray-500">当支出达到预算的 {{ budgetForm.alertThreshold }}% 时发出预警</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
当支出达到预算的 {{ budgetForm.alertThreshold }}% 时发出预警
|
||||
</p>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
@@ -245,15 +620,15 @@
|
||||
|
||||
<Form.Item label="预算设置">
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>自动续期</span>
|
||||
<Switch v-model:checked="budgetForm.autoRenew" />
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>超支提醒</span>
|
||||
<Switch v-model:checked="budgetForm.overspendAlert" />
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>每日提醒</span>
|
||||
<Switch v-model:checked="budgetForm.dailyReminder" />
|
||||
</div>
|
||||
@@ -264,289 +639,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import {
|
||||
Card, Progress, Button, Modal, Form, Input, Select, Row, Col,
|
||||
InputNumber, Slider, Switch, Tag, notification, Dropdown, Menu
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { useFinanceStore } from '#/store/finance';
|
||||
|
||||
defineOptions({ name: 'BudgetManagement' });
|
||||
|
||||
const financeStore = useFinanceStore();
|
||||
const budgets = computed(() => financeStore.budgets.filter(b => !b.isDeleted));
|
||||
const showAddModal = ref(false);
|
||||
const formRef = ref();
|
||||
|
||||
// 表单数据
|
||||
const budgetForm = ref({
|
||||
category: '',
|
||||
customCategoryName: '',
|
||||
customCategoryIcon: '',
|
||||
limit: null,
|
||||
currency: 'CNY',
|
||||
customCurrencyCode: '',
|
||||
customCurrencyName: '',
|
||||
period: 'monthly',
|
||||
alertThreshold: 80,
|
||||
description: '',
|
||||
autoRenew: true,
|
||||
overspendAlert: true,
|
||||
dailyReminder: false
|
||||
});
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
category: [
|
||||
{ required: true, message: '请选择预算分类', trigger: 'change' }
|
||||
],
|
||||
limit: [
|
||||
{ required: true, message: '请输入预算金额', trigger: 'blur' },
|
||||
{ type: 'number', min: 0.01, message: '预算金额必须大于0', trigger: 'blur' }
|
||||
],
|
||||
currency: [
|
||||
{ required: true, message: '请选择币种', trigger: 'change' }
|
||||
],
|
||||
period: [
|
||||
{ required: true, message: '请选择预算周期', trigger: 'change' }
|
||||
]
|
||||
};
|
||||
|
||||
// 计算属性
|
||||
const totalBudget = computed(() => {
|
||||
return budgets.value.reduce((sum, budget) => sum + budget.limit, 0);
|
||||
});
|
||||
|
||||
const totalSpent = computed(() => {
|
||||
return budgets.value.reduce((sum, budget) => sum + budget.spent, 0);
|
||||
});
|
||||
|
||||
const totalRemaining = computed(() => {
|
||||
return budgets.value.reduce((sum, budget) => sum + budget.remaining, 0);
|
||||
});
|
||||
|
||||
const averageUsage = computed(() => {
|
||||
if (budgets.value.length === 0) return 0;
|
||||
return budgets.value.reduce((sum, budget) => sum + budget.percentage, 0) / budgets.value.length;
|
||||
});
|
||||
|
||||
// 功能方法
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatCurrencyWithCode = (amount: number, currencyCode: string) => {
|
||||
// 如果是自定义币种(包含括号),直接显示数字 + 币种代码
|
||||
if (currencyCode && currencyCode.includes('(')) {
|
||||
return `${amount.toLocaleString()} ${currencyCode}`;
|
||||
}
|
||||
|
||||
// 对于标准币种,使用格式化
|
||||
try {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: currencyCode || 'CNY'
|
||||
}).format(amount);
|
||||
} catch {
|
||||
// 如果币种代码不被支持,则直接显示数字 + 代码
|
||||
return `${amount.toLocaleString()} ${currencyCode || 'CNY'}`;
|
||||
}
|
||||
};
|
||||
|
||||
const getProgressColor = (percentage: number) => {
|
||||
if (percentage > 100) return '#ff4d4f';
|
||||
if (percentage > 90) return '#faad14';
|
||||
if (percentage > 75) return '#1890ff';
|
||||
return '#52c41a';
|
||||
};
|
||||
|
||||
const getAmountColor = (percentage: number) => {
|
||||
if (percentage > 100) return 'text-red-600';
|
||||
if (percentage > 90) return 'text-orange-600';
|
||||
if (percentage > 75) return 'text-blue-600';
|
||||
return 'text-green-600';
|
||||
};
|
||||
|
||||
const getCategoryEmoji = (category: string) => {
|
||||
const emojiMap = {
|
||||
'food': '🍽️',
|
||||
'transport': '🚗',
|
||||
'shopping': '🛒',
|
||||
'entertainment': '🎮',
|
||||
'medical': '🏥',
|
||||
'housing': '🏠',
|
||||
'education': '📚',
|
||||
'travel': '✈️'
|
||||
};
|
||||
return emojiMap[category] || '🎯';
|
||||
};
|
||||
|
||||
const getCategoryName = (category: string) => {
|
||||
const nameMap = {
|
||||
'food': '餐饮',
|
||||
'transport': '交通',
|
||||
'shopping': '购物',
|
||||
'entertainment': '娱乐',
|
||||
'medical': '医疗',
|
||||
'housing': '住房',
|
||||
'education': '教育',
|
||||
'travel': '旅游'
|
||||
};
|
||||
return nameMap[category] || category;
|
||||
};
|
||||
|
||||
const openAddBudgetModal = () => {
|
||||
showAddModal.value = true;
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const submitBudget = async () => {
|
||||
try {
|
||||
// 表单验证
|
||||
await formRef.value.validate();
|
||||
|
||||
// 处理自定义字段
|
||||
const finalCategory = budgetForm.value.category === 'CUSTOM'
|
||||
? budgetForm.value.customCategoryName
|
||||
: getCategoryName(budgetForm.value.category);
|
||||
|
||||
const finalEmoji = budgetForm.value.category === 'CUSTOM'
|
||||
? budgetForm.value.customCategoryIcon
|
||||
: getCategoryEmoji(budgetForm.value.category);
|
||||
|
||||
const finalCurrency = budgetForm.value.currency === 'CUSTOM'
|
||||
? `${budgetForm.value.customCurrencyCode} (${budgetForm.value.customCurrencyName})`
|
||||
: budgetForm.value.currency;
|
||||
|
||||
// 检查分类是否已有预算
|
||||
const existingBudget = budgets.value.find(b => b.category === finalCategory);
|
||||
if (existingBudget) {
|
||||
notification.error({
|
||||
message: '添加失败',
|
||||
description: '该分类已存在预算设置'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建新预算
|
||||
await financeStore.createBudget({
|
||||
category: finalCategory,
|
||||
emoji: finalEmoji,
|
||||
limit: budgetForm.value.limit,
|
||||
currency: finalCurrency,
|
||||
spent: 0,
|
||||
remaining: budgetForm.value.limit,
|
||||
percentage: 0,
|
||||
period: budgetForm.value.period,
|
||||
alertThreshold: budgetForm.value.alertThreshold,
|
||||
description: budgetForm.value.description,
|
||||
autoRenew: budgetForm.value.autoRenew,
|
||||
overspendAlert: budgetForm.value.overspendAlert,
|
||||
dailyReminder: budgetForm.value.dailyReminder,
|
||||
monthlyTrend: 0,
|
||||
});
|
||||
|
||||
notification.success({
|
||||
message: '预算设置成功',
|
||||
description: `${finalCategory} 预算已成功创建`
|
||||
});
|
||||
|
||||
// 关闭模态框
|
||||
showAddModal.value = false;
|
||||
resetForm();
|
||||
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error);
|
||||
notification.error({
|
||||
message: '添加失败',
|
||||
description: '请检查表单信息是否正确'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const cancelAdd = () => {
|
||||
showAddModal.value = false;
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
budgetForm.value = {
|
||||
category: '',
|
||||
customCategoryName: '',
|
||||
customCategoryIcon: '',
|
||||
limit: null,
|
||||
currency: 'CNY',
|
||||
customCurrencyCode: '',
|
||||
customCurrencyName: '',
|
||||
period: 'monthly',
|
||||
alertThreshold: 80,
|
||||
description: '',
|
||||
autoRenew: true,
|
||||
overspendAlert: true,
|
||||
dailyReminder: false
|
||||
};
|
||||
};
|
||||
|
||||
const handleCategoryChange = (category: string) => {
|
||||
console.log('分类选择:', category);
|
||||
if (category !== 'CUSTOM') {
|
||||
budgetForm.value.customCategoryName = '';
|
||||
budgetForm.value.customCategoryIcon = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleCurrencyChange = (currency: string) => {
|
||||
console.log('币种选择:', currency);
|
||||
if (currency !== 'CUSTOM') {
|
||||
budgetForm.value.customCurrencyCode = '';
|
||||
budgetForm.value.customCurrencyName = '';
|
||||
}
|
||||
};
|
||||
|
||||
// 预算操作方法
|
||||
const editBudget = (budget: any) => {
|
||||
console.log('编辑预算:', budget);
|
||||
notification.info({
|
||||
message: '编辑预算',
|
||||
description: `编辑 ${budget.category} 预算设置`
|
||||
});
|
||||
};
|
||||
|
||||
const adjustBudget = (budget: any) => {
|
||||
console.log('调整预算额度:', budget);
|
||||
notification.info({
|
||||
message: '调整额度',
|
||||
description: `调整 ${budget.category} 预算额度`
|
||||
});
|
||||
};
|
||||
|
||||
const viewHistory = (budget: any) => {
|
||||
console.log('查看预算历史:', budget);
|
||||
notification.info({
|
||||
message: '历史记录',
|
||||
description: `查看 ${budget.category} 预算历史`
|
||||
});
|
||||
};
|
||||
|
||||
const deleteBudget = async (budget: any) => {
|
||||
console.log('删除预算:', budget);
|
||||
await financeStore.deleteBudget(budget.id);
|
||||
notification.success({
|
||||
message: '预算已删除',
|
||||
description: `${budget.category} 预算已删除`
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await financeStore.fetchBudgets();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; }
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
@@ -1,300 +1,18 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">🏷️ 分类管理</h1>
|
||||
<p class="text-gray-600">管理收支分类,支持层级结构</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card title="📁 分类树结构">
|
||||
<div v-if="categories.length === 0" class="text-center py-8">
|
||||
<div class="text-6xl mb-4">🏷️</div>
|
||||
<p class="text-gray-500 mb-4">暂无分类数据</p>
|
||||
<Button type="primary" @click="openAddCategoryModal">➕ 添加分类</Button>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="category in categories" :key="category.id" class="p-4 border rounded-lg hover:shadow-md transition-shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-2xl">{{ category.icon }}</span>
|
||||
<div>
|
||||
<span class="font-medium text-lg">{{ category.name }}</span>
|
||||
<div class="flex items-center space-x-2 mt-1">
|
||||
<Tag :color="category.type === 'income' ? 'green' : 'red'" size="small">
|
||||
{{ category.type === 'income' ? '📈 收入' : '📉 支出' }}
|
||||
</Tag>
|
||||
<Tag v-if="category.isSystem" color="blue" size="small">系统分类</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="space-x-2">
|
||||
<Button type="link" size="small" @click="editCategory(category)">✏️ 编辑</Button>
|
||||
<Button type="link" size="small" danger @click="deleteCategory(category)" :disabled="category.isSystem">🗑️ 删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="📊 分类统计">
|
||||
<div v-if="categories.length === 0" class="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-2">📈</div>
|
||||
<p class="text-gray-600">添加分类后查看统计</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<!-- 分类统计数据 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="text-center p-3 bg-blue-50 rounded-lg">
|
||||
<p class="text-sm text-gray-500">总分类数</p>
|
||||
<p class="text-xl font-bold text-blue-600">{{ categoryStats.total }}</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-green-50 rounded-lg">
|
||||
<p class="text-sm text-gray-500">收入分类</p>
|
||||
<p class="text-xl font-bold text-green-600">{{ categoryStats.income }}</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-red-50 rounded-lg">
|
||||
<p class="text-sm text-gray-500">支出分类</p>
|
||||
<p class="text-xl font-bold text-red-600">{{ categoryStats.expense }}</p>
|
||||
</div>
|
||||
<div class="text-center p-3 bg-purple-50 rounded-lg">
|
||||
<p class="text-sm text-gray-500">预算总额</p>
|
||||
<p class="text-xl font-bold text-purple-600">¥{{ categoryStats.budgetTotal.toLocaleString() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类列表 -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium">📈 收入分类</h4>
|
||||
<div class="space-y-2">
|
||||
<div v-for="category in incomeCategories" :key="category.id"
|
||||
class="flex items-center justify-between p-2 bg-green-50 rounded">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span>{{ category.icon }}</span>
|
||||
<span>{{ category.name }}</span>
|
||||
</div>
|
||||
<Tag :color="category.color" size="small">{{ category.isSystem ? '系统' : '自定义' }}</Tag>
|
||||
</div>
|
||||
<div v-if="incomeCategories.length === 0" class="text-center text-gray-500 py-2">
|
||||
暂无收入分类
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="font-medium mt-4">📉 支出分类</h4>
|
||||
<div class="space-y-2">
|
||||
<div v-for="category in expenseCategories" :key="category.id"
|
||||
class="flex items-center justify-between p-2 bg-red-50 rounded">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span>{{ category.icon }}</span>
|
||||
<span>{{ category.name }}</span>
|
||||
</div>
|
||||
<Tag :color="category.color" size="small">{{ category.isSystem ? '系统' : '自定义' }}</Tag>
|
||||
</div>
|
||||
<div v-if="expenseCategories.length === 0" class="text-center text-gray-500 py-2">
|
||||
暂无支出分类
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 编辑分类模态框 -->
|
||||
<Modal
|
||||
v-model:open="showEditModal"
|
||||
title="✏️ 编辑分类"
|
||||
@ok="submitEditCategory"
|
||||
@cancel="() => { showEditModal = false; editingCategory = null; }"
|
||||
width="500px"
|
||||
>
|
||||
<Form
|
||||
ref="editFormRef"
|
||||
:model="editForm"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
class="mt-4"
|
||||
>
|
||||
<Form.Item label="分类名称" name="name" required>
|
||||
<Input
|
||||
v-model:value="editForm.name"
|
||||
placeholder="请输入分类名称"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="图标" name="icon">
|
||||
<Select v-model:value="editForm.icon" placeholder="选择图标" size="large">
|
||||
<Select.Option value="🍽️">🍽️ 餐饮</Select.Option>
|
||||
<Select.Option value="🚗">🚗 交通</Select.Option>
|
||||
<Select.Option value="🛒">🛒 购物</Select.Option>
|
||||
<Select.Option value="🎮">🎮 娱乐</Select.Option>
|
||||
<Select.Option value="💻">💻 软件订阅</Select.Option>
|
||||
<Select.Option value="📊">📊 投资</Select.Option>
|
||||
<Select.Option value="🏥">🏥 医疗</Select.Option>
|
||||
<Select.Option value="🏠">🏠 住房</Select.Option>
|
||||
<Select.Option value="📚">📚 教育</Select.Option>
|
||||
<Select.Option value="💰">💰 工资</Select.Option>
|
||||
<Select.Option value="🎁">🎁 奖金</Select.Option>
|
||||
<Select.Option value="💼">💼 副业</Select.Option>
|
||||
<Select.Option value="CUSTOM">➕ 自定义图标</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<!-- 自定义图标输入 -->
|
||||
<div v-if="editForm.icon === 'CUSTOM'" class="mb-4">
|
||||
<Form.Item label="自定义图标" required>
|
||||
<Input v-model:value="editForm.customIcon" placeholder="请输入一个表情符号,如: 🍕, 🎬, 📚 等" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item label="分类颜色">
|
||||
<div class="flex space-x-2">
|
||||
<div
|
||||
v-for="color in categoryColors"
|
||||
:key="color"
|
||||
class="w-8 h-8 rounded-full cursor-pointer border-2 hover:scale-110 transition-all"
|
||||
:class="editForm.color === color ? 'border-gray-800 scale-110' : 'border-gray-300'"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="editForm.color = color"
|
||||
></div>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<!-- 添加分类模态框 -->
|
||||
<Modal
|
||||
v-model:open="showAddModal"
|
||||
title="➕ 添加新分类"
|
||||
@ok="submitCategory"
|
||||
@cancel="cancelAdd"
|
||||
width="500px"
|
||||
>
|
||||
<Form ref="formRef" :model="categoryForm" :rules="rules" layout="vertical">
|
||||
<Form.Item label="分类名称" name="name" required>
|
||||
<Input
|
||||
v-model:value="categoryForm.name"
|
||||
placeholder="请输入分类名称,如:餐饮、交通等"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="分类类型" name="type" required>
|
||||
<Select v-model:value="categoryForm.type" placeholder="选择类型" size="large">
|
||||
<Select.Option value="income">
|
||||
<span>📈 收入分类</span>
|
||||
</Select.Option>
|
||||
<Select.Option value="expense">
|
||||
<span>📉 支出分类</span>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="图标" name="icon">
|
||||
<Select v-model:value="categoryForm.icon" placeholder="选择图标" size="large" @change="handleIconChange">
|
||||
<Select.Option value="🍽️">🍽️ 餐饮</Select.Option>
|
||||
<Select.Option value="🚗">🚗 交通</Select.Option>
|
||||
<Select.Option value="🛒">🛒 购物</Select.Option>
|
||||
<Select.Option value="🎮">🎮 娱乐</Select.Option>
|
||||
<Select.Option value="🏥">🏥 医疗</Select.Option>
|
||||
<Select.Option value="🏠">🏠 住房</Select.Option>
|
||||
<Select.Option value="💰">💰 工资</Select.Option>
|
||||
<Select.Option value="📈">📈 投资</Select.Option>
|
||||
<Select.Option value="🎁">🎁 奖金</Select.Option>
|
||||
<Select.Option value="💼">💼 兼职</Select.Option>
|
||||
<Select.Option value="CUSTOM">➕ 自定义图标</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 自定义图标输入 -->
|
||||
<div v-if="categoryForm.icon === 'CUSTOM'" class="mb-4">
|
||||
<Form.Item label="自定义图标" required>
|
||||
<Input v-model:value="categoryForm.customIcon" placeholder="请输入一个表情符号,如: 🍕, 🎬, 📚 等" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="月度预算" name="budget">
|
||||
<InputNumber
|
||||
v-model:value="categoryForm.budget"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
placeholder="0.00"
|
||||
:min="0"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="预算币种" name="budgetCurrency">
|
||||
<Select v-model:value="categoryForm.budgetCurrency" placeholder="选择币种" size="large" @change="handleBudgetCurrencyChange">
|
||||
<Select.Option value="CNY">🇨🇳 人民币</Select.Option>
|
||||
<Select.Option value="USD">🇺🇸 美元</Select.Option>
|
||||
<Select.Option value="EUR">🇪🇺 欧元</Select.Option>
|
||||
<Select.Option value="JPY">🇯🇵 日元</Select.Option>
|
||||
<Select.Option value="GBP">🇬🇧 英镑</Select.Option>
|
||||
<Select.Option value="HKD">🇭🇰 港币</Select.Option>
|
||||
<Select.Option value="KRW">🇰🇷 韩元</Select.Option>
|
||||
<Select.Option value="CUSTOM">➕ 自定义币种</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 自定义币种输入 -->
|
||||
<div v-if="categoryForm.budgetCurrency === 'CUSTOM'" class="mb-4">
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="币种代码" required>
|
||||
<Input v-model:value="categoryForm.customCurrencyCode" placeholder="如: THB, AUD 等" style="text-transform: uppercase" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="币种名称" required>
|
||||
<Input v-model:value="categoryForm.customCurrencyName" placeholder="如: 泰铢, 澳元 等" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<Form.Item label="分类描述">
|
||||
<Input.TextArea
|
||||
v-model:value="categoryForm.description"
|
||||
:rows="3"
|
||||
placeholder="分类用途描述..."
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="分类颜色">
|
||||
<div class="flex space-x-2">
|
||||
<div
|
||||
v-for="color in categoryColors"
|
||||
:key="color"
|
||||
class="w-8 h-8 rounded-full cursor-pointer border-2 hover:scale-110 transition-all"
|
||||
:class="categoryForm.color === color ? 'border-gray-800 scale-110' : 'border-gray-300'"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="categoryForm.color = color"
|
||||
></div>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Card, Tag, Button, Modal, Form, Input, Select, Row, Col,
|
||||
InputNumber, notification
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
notification,
|
||||
Row,
|
||||
Select,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { useFinanceStore } from '#/store/finance';
|
||||
@@ -329,7 +47,7 @@ const categoryForm = ref({
|
||||
customCurrencyCode: '',
|
||||
customCurrencyName: '',
|
||||
description: '',
|
||||
color: '#1890ff'
|
||||
color: '#1890ff',
|
||||
});
|
||||
|
||||
// 编辑表单数据
|
||||
@@ -337,46 +55,56 @@ const editForm = ref({
|
||||
name: '',
|
||||
icon: '🏷️',
|
||||
customIcon: '',
|
||||
color: '#1890ff'
|
||||
color: '#1890ff',
|
||||
});
|
||||
|
||||
// 分类颜色选项
|
||||
const categoryColors = ref([
|
||||
'#1890ff', '#52c41a', '#fa541c', '#722ed1', '#eb2f96', '#13c2c2',
|
||||
'#f5222d', '#fa8c16', '#fadb14', '#a0d911', '#36cfc9', '#b37feb'
|
||||
'#1890ff',
|
||||
'#52c41a',
|
||||
'#fa541c',
|
||||
'#722ed1',
|
||||
'#eb2f96',
|
||||
'#13c2c2',
|
||||
'#f5222d',
|
||||
'#fa8c16',
|
||||
'#fadb14',
|
||||
'#a0d911',
|
||||
'#36cfc9',
|
||||
'#b37feb',
|
||||
]);
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入分类名称', trigger: 'blur' },
|
||||
{ min: 2, max: 20, message: '分类名称长度在2-20个字符', trigger: 'blur' }
|
||||
{ min: 2, max: 20, message: '分类名称长度在2-20个字符', trigger: 'blur' },
|
||||
],
|
||||
type: [
|
||||
{ required: true, message: '请选择分类类型', trigger: 'change' }
|
||||
]
|
||||
type: [{ required: true, message: '请选择分类类型', trigger: 'change' }],
|
||||
};
|
||||
|
||||
// 计算统计
|
||||
const categoryStats = computed(() => {
|
||||
const incomeCategories = categories.value.filter(c => c.type === 'income');
|
||||
const expenseCategories = categories.value.filter(c => c.type === 'expense');
|
||||
const incomeCategories = categories.value.filter((c) => c.type === 'income');
|
||||
const expenseCategories = categories.value.filter(
|
||||
(c) => c.type === 'expense',
|
||||
);
|
||||
|
||||
return {
|
||||
total: categories.value.length,
|
||||
income: incomeCategories.length,
|
||||
expense: expenseCategories.length,
|
||||
budgetTotal: 0 // 预算功能待实现
|
||||
budgetTotal: 0, // 预算功能待实现
|
||||
};
|
||||
});
|
||||
|
||||
// 分类分组
|
||||
const incomeCategories = computed(() => {
|
||||
return categories.value.filter(c => c.type === 'income');
|
||||
return categories.value.filter((c) => c.type === 'income');
|
||||
});
|
||||
|
||||
const expenseCategories = computed(() => {
|
||||
return categories.value.filter(c => c.type === 'expense');
|
||||
return categories.value.filter((c) => c.type === 'expense');
|
||||
});
|
||||
|
||||
// 功能方法
|
||||
@@ -391,9 +119,10 @@ const submitCategory = async () => {
|
||||
await formRef.value.validate();
|
||||
|
||||
// 处理自定义图标
|
||||
const finalIcon = categoryForm.value.icon === 'CUSTOM'
|
||||
? categoryForm.value.customIcon
|
||||
: categoryForm.value.icon;
|
||||
const finalIcon =
|
||||
categoryForm.value.icon === 'CUSTOM'
|
||||
? categoryForm.value.customIcon
|
||||
: categoryForm.value.icon;
|
||||
|
||||
// 调用 store 创建分类
|
||||
await financeStore.createCategory({
|
||||
@@ -405,18 +134,17 @@ const submitCategory = async () => {
|
||||
|
||||
notification.success({
|
||||
message: '分类添加成功',
|
||||
description: `分类 "${categoryForm.value.name}" 已成功创建`
|
||||
description: `分类 "${categoryForm.value.name}" 已成功创建`,
|
||||
});
|
||||
|
||||
// 关闭模态框
|
||||
showAddModal.value = false;
|
||||
resetForm();
|
||||
|
||||
} catch (error) {
|
||||
console.error('创建分类失败:', error);
|
||||
notification.error({
|
||||
message: '添加失败',
|
||||
description: '请检查表单信息是否正确'
|
||||
description: '请检查表单信息是否正确',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -437,7 +165,7 @@ const resetForm = () => {
|
||||
customCurrencyCode: '',
|
||||
customCurrencyName: '',
|
||||
description: '',
|
||||
color: '#1890ff'
|
||||
color: '#1890ff',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -472,9 +200,10 @@ const submitEditCategory = async () => {
|
||||
await editFormRef.value?.validate();
|
||||
|
||||
// 处理自定义图标
|
||||
const finalIcon = editForm.value.icon === 'CUSTOM'
|
||||
? editForm.value.customIcon
|
||||
: editForm.value.icon;
|
||||
const finalIcon =
|
||||
editForm.value.icon === 'CUSTOM'
|
||||
? editForm.value.customIcon
|
||||
: editForm.value.icon;
|
||||
|
||||
// 调用 store 更新分类
|
||||
await financeStore.updateCategory(editingCategory.value.id, {
|
||||
@@ -485,17 +214,16 @@ const submitEditCategory = async () => {
|
||||
|
||||
notification.success({
|
||||
message: '分类更新成功',
|
||||
description: `分类 "${editForm.value.name}" 已更新`
|
||||
description: `分类 "${editForm.value.name}" 已更新`,
|
||||
});
|
||||
|
||||
showEditModal.value = false;
|
||||
editingCategory.value = null;
|
||||
|
||||
} catch (error) {
|
||||
console.error('更新分类失败:', error);
|
||||
notification.error({
|
||||
message: '更新失败',
|
||||
description: '请检查表单信息是否正确'
|
||||
description: '请检查表单信息是否正确',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -505,7 +233,7 @@ const deleteCategory = (category: any) => {
|
||||
if (category.isSystem) {
|
||||
notification.warning({
|
||||
message: '无法删除',
|
||||
description: '系统分类不允许删除'
|
||||
description: '系统分类不允许删除',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -521,16 +249,16 @@ const deleteCategory = (category: any) => {
|
||||
await financeStore.deleteCategory(category.id);
|
||||
notification.success({
|
||||
message: '分类已删除',
|
||||
description: `分类 "${category.name}" 已删除`
|
||||
description: `分类 "${category.name}" 已删除`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('删除分类失败:', error);
|
||||
notification.error({
|
||||
message: '删除失败',
|
||||
description: '删除分类时出错,请稍后重试'
|
||||
description: '删除分类时出错,请稍后重试',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -538,11 +266,406 @@ const setBudget = (category: any) => {
|
||||
console.log('设置预算:', category);
|
||||
notification.info({
|
||||
message: '预算设置',
|
||||
description: `为分类 "${category.name}" 设置预算`
|
||||
description: `为分类 "${category.name}" 设置预算`,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">🏷️ 分类管理</h1>
|
||||
<p class="text-gray-600">管理收支分类,支持层级结构</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card title="📁 分类树结构">
|
||||
<div v-if="categories.length === 0" class="py-8 text-center">
|
||||
<div class="mb-4 text-6xl">🏷️</div>
|
||||
<p class="mb-4 text-gray-500">暂无分类数据</p>
|
||||
<Button type="primary" @click="openAddCategoryModal">
|
||||
➕ 添加分类
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="rounded-lg border p-4 transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-2xl">{{ category.icon }}</span>
|
||||
<div>
|
||||
<span class="text-lg font-medium">{{ category.name }}</span>
|
||||
<div class="mt-1 flex items-center space-x-2">
|
||||
<Tag
|
||||
:color="category.type === 'income' ? 'green' : 'red'"
|
||||
size="small"
|
||||
>
|
||||
{{ category.type === 'income' ? '📈 收入' : '📉 支出' }}
|
||||
</Tag>
|
||||
<Tag v-if="category.isSystem" color="blue" size="small">
|
||||
系统分类
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="editCategory(category)"
|
||||
>
|
||||
✏️ 编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
@click="deleteCategory(category)"
|
||||
:disabled="category.isSystem"
|
||||
>
|
||||
🗑️ 删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="📊 分类统计">
|
||||
<div
|
||||
v-if="categories.length === 0"
|
||||
class="flex h-64 items-center justify-center rounded-lg bg-gray-50"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="mb-2 text-4xl">📈</div>
|
||||
<p class="text-gray-600">添加分类后查看统计</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<!-- 分类统计数据 -->
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div class="rounded-lg bg-blue-50 p-3 text-center">
|
||||
<p class="text-sm text-gray-500">总分类数</p>
|
||||
<p class="text-xl font-bold text-blue-600">
|
||||
{{ categoryStats.total }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-green-50 p-3 text-center">
|
||||
<p class="text-sm text-gray-500">收入分类</p>
|
||||
<p class="text-xl font-bold text-green-600">
|
||||
{{ categoryStats.income }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-red-50 p-3 text-center">
|
||||
<p class="text-sm text-gray-500">支出分类</p>
|
||||
<p class="text-xl font-bold text-red-600">
|
||||
{{ categoryStats.expense }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-purple-50 p-3 text-center">
|
||||
<p class="text-sm text-gray-500">预算总额</p>
|
||||
<p class="text-xl font-bold text-purple-600">
|
||||
¥{{ categoryStats.budgetTotal.toLocaleString() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分类列表 -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium">📈 收入分类</h4>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="category in incomeCategories"
|
||||
:key="category.id"
|
||||
class="flex items-center justify-between rounded bg-green-50 p-2"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span>{{ category.icon }}</span>
|
||||
<span>{{ category.name }}</span>
|
||||
</div>
|
||||
<Tag :color="category.color" size="small">
|
||||
{{ category.isSystem ? '系统' : '自定义' }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div
|
||||
v-if="incomeCategories.length === 0"
|
||||
class="py-2 text-center text-gray-500"
|
||||
>
|
||||
暂无收入分类
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-4 font-medium">📉 支出分类</h4>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="category in expenseCategories"
|
||||
:key="category.id"
|
||||
class="flex items-center justify-between rounded bg-red-50 p-2"
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span>{{ category.icon }}</span>
|
||||
<span>{{ category.name }}</span>
|
||||
</div>
|
||||
<Tag :color="category.color" size="small">
|
||||
{{ category.isSystem ? '系统' : '自定义' }}
|
||||
</Tag>
|
||||
</div>
|
||||
<div
|
||||
v-if="expenseCategories.length === 0"
|
||||
class="py-2 text-center text-gray-500"
|
||||
>
|
||||
暂无支出分类
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 编辑分类模态框 -->
|
||||
<Modal
|
||||
v-model:open="showEditModal"
|
||||
title="✏️ 编辑分类"
|
||||
@ok="submitEditCategory"
|
||||
@cancel="
|
||||
() => {
|
||||
showEditModal = false;
|
||||
editingCategory = null;
|
||||
}
|
||||
"
|
||||
width="500px"
|
||||
>
|
||||
<Form
|
||||
ref="editFormRef"
|
||||
:model="editForm"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
class="mt-4"
|
||||
>
|
||||
<Form.Item label="分类名称" name="name" required>
|
||||
<Input
|
||||
v-model:value="editForm.name"
|
||||
placeholder="请输入分类名称"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="图标" name="icon">
|
||||
<Select
|
||||
v-model:value="editForm.icon"
|
||||
placeholder="选择图标"
|
||||
size="large"
|
||||
>
|
||||
<Select.Option value="🍽️">🍽️ 餐饮</Select.Option>
|
||||
<Select.Option value="🚗">🚗 交通</Select.Option>
|
||||
<Select.Option value="🛒">🛒 购物</Select.Option>
|
||||
<Select.Option value="🎮">🎮 娱乐</Select.Option>
|
||||
<Select.Option value="💻">💻 软件订阅</Select.Option>
|
||||
<Select.Option value="📊">📊 投资</Select.Option>
|
||||
<Select.Option value="🏥">🏥 医疗</Select.Option>
|
||||
<Select.Option value="🏠">🏠 住房</Select.Option>
|
||||
<Select.Option value="📚">📚 教育</Select.Option>
|
||||
<Select.Option value="💰">💰 工资</Select.Option>
|
||||
<Select.Option value="🎁">🎁 奖金</Select.Option>
|
||||
<Select.Option value="💼">💼 副业</Select.Option>
|
||||
<Select.Option value="CUSTOM">➕ 自定义图标</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<!-- 自定义图标输入 -->
|
||||
<div v-if="editForm.icon === 'CUSTOM'" class="mb-4">
|
||||
<Form.Item label="自定义图标" required>
|
||||
<Input
|
||||
v-model:value="editForm.customIcon"
|
||||
placeholder="请输入一个表情符号,如: 🍕, 🎬, 📚 等"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item label="分类颜色">
|
||||
<div class="flex space-x-2">
|
||||
<div
|
||||
v-for="color in categoryColors"
|
||||
:key="color"
|
||||
class="h-8 w-8 cursor-pointer rounded-full border-2 transition-all hover:scale-110"
|
||||
:class="
|
||||
editForm.color === color
|
||||
? 'scale-110 border-gray-800'
|
||||
: 'border-gray-300'
|
||||
"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="editForm.color = color"
|
||||
></div>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<!-- 添加分类模态框 -->
|
||||
<Modal
|
||||
v-model:open="showAddModal"
|
||||
title="➕ 添加新分类"
|
||||
@ok="submitCategory"
|
||||
@cancel="cancelAdd"
|
||||
width="500px"
|
||||
>
|
||||
<Form
|
||||
ref="formRef"
|
||||
:model="categoryForm"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item label="分类名称" name="name" required>
|
||||
<Input
|
||||
v-model:value="categoryForm.name"
|
||||
placeholder="请输入分类名称,如:餐饮、交通等"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="分类类型" name="type" required>
|
||||
<Select
|
||||
v-model:value="categoryForm.type"
|
||||
placeholder="选择类型"
|
||||
size="large"
|
||||
>
|
||||
<Select.Option value="income">
|
||||
<span>📈 收入分类</span>
|
||||
</Select.Option>
|
||||
<Select.Option value="expense">
|
||||
<span>📉 支出分类</span>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="图标" name="icon">
|
||||
<Select
|
||||
v-model:value="categoryForm.icon"
|
||||
placeholder="选择图标"
|
||||
size="large"
|
||||
@change="handleIconChange"
|
||||
>
|
||||
<Select.Option value="🍽️">🍽️ 餐饮</Select.Option>
|
||||
<Select.Option value="🚗">🚗 交通</Select.Option>
|
||||
<Select.Option value="🛒">🛒 购物</Select.Option>
|
||||
<Select.Option value="🎮">🎮 娱乐</Select.Option>
|
||||
<Select.Option value="🏥">🏥 医疗</Select.Option>
|
||||
<Select.Option value="🏠">🏠 住房</Select.Option>
|
||||
<Select.Option value="💰">💰 工资</Select.Option>
|
||||
<Select.Option value="📈">📈 投资</Select.Option>
|
||||
<Select.Option value="🎁">🎁 奖金</Select.Option>
|
||||
<Select.Option value="💼">💼 兼职</Select.Option>
|
||||
<Select.Option value="CUSTOM">➕ 自定义图标</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 自定义图标输入 -->
|
||||
<div v-if="categoryForm.icon === 'CUSTOM'" class="mb-4">
|
||||
<Form.Item label="自定义图标" required>
|
||||
<Input
|
||||
v-model:value="categoryForm.customIcon"
|
||||
placeholder="请输入一个表情符号,如: 🍕, 🎬, 📚 等"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="月度预算" name="budget">
|
||||
<InputNumber
|
||||
v-model:value="categoryForm.budget"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
placeholder="0.00"
|
||||
:min="0"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="预算币种" name="budgetCurrency">
|
||||
<Select
|
||||
v-model:value="categoryForm.budgetCurrency"
|
||||
placeholder="选择币种"
|
||||
size="large"
|
||||
@change="handleBudgetCurrencyChange"
|
||||
>
|
||||
<Select.Option value="CNY">🇨🇳 人民币</Select.Option>
|
||||
<Select.Option value="USD">🇺🇸 美元</Select.Option>
|
||||
<Select.Option value="EUR">🇪🇺 欧元</Select.Option>
|
||||
<Select.Option value="JPY">🇯🇵 日元</Select.Option>
|
||||
<Select.Option value="GBP">🇬🇧 英镑</Select.Option>
|
||||
<Select.Option value="HKD">🇭🇰 港币</Select.Option>
|
||||
<Select.Option value="KRW">🇰🇷 韩元</Select.Option>
|
||||
<Select.Option value="CUSTOM">➕ 自定义币种</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<!-- 自定义币种输入 -->
|
||||
<div v-if="categoryForm.budgetCurrency === 'CUSTOM'" class="mb-4">
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<Form.Item label="币种代码" required>
|
||||
<Input
|
||||
v-model:value="categoryForm.customCurrencyCode"
|
||||
placeholder="如: THB, AUD 等"
|
||||
style="text-transform: uppercase"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col :span="12">
|
||||
<Form.Item label="币种名称" required>
|
||||
<Input
|
||||
v-model:value="categoryForm.customCurrencyName"
|
||||
placeholder="如: 泰铢, 澳元 等"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<Form.Item label="分类描述">
|
||||
<Input.TextArea
|
||||
v-model:value="categoryForm.description"
|
||||
:rows="3"
|
||||
placeholder="分类用途描述..."
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="分类颜色">
|
||||
<div class="flex space-x-2">
|
||||
<div
|
||||
v-for="color in categoryColors"
|
||||
:key="color"
|
||||
class="h-8 w-8 cursor-pointer rounded-full border-2 transition-all hover:scale-110"
|
||||
:class="
|
||||
categoryForm.color === color
|
||||
? 'scale-110 border-gray-800'
|
||||
: 'border-gray-300'
|
||||
"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="categoryForm.color = color"
|
||||
></div>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; }
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,65 +1,400 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Form,
|
||||
Modal,
|
||||
notification,
|
||||
Switch,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'FinanceSettings' });
|
||||
|
||||
// 系统设置
|
||||
const settings = ref({
|
||||
defaultCurrency: 'CNY',
|
||||
notifications: {
|
||||
budget: true,
|
||||
bills: true,
|
||||
investment: false,
|
||||
},
|
||||
autoBackup: true,
|
||||
compactMode: false,
|
||||
autoLock: false,
|
||||
analytics: true,
|
||||
});
|
||||
|
||||
// 操作加载状态
|
||||
const operationLoading = ref({
|
||||
backup: false,
|
||||
import: false,
|
||||
cache: false,
|
||||
reset: false,
|
||||
});
|
||||
|
||||
// 功能方法
|
||||
const saveCurrencySettings = (currency: string) => {
|
||||
console.log('货币设置更改为:', currency);
|
||||
localStorage.setItem('app-currency', currency);
|
||||
notification.success({
|
||||
message: '货币设置已更新',
|
||||
description: `默认货币已设置为 ${currency}`,
|
||||
});
|
||||
};
|
||||
|
||||
const saveNotificationSettings = () => {
|
||||
console.log('通知设置已保存:', settings.value.notifications);
|
||||
localStorage.setItem(
|
||||
'app-notifications',
|
||||
JSON.stringify(settings.value.notifications),
|
||||
);
|
||||
notification.info({
|
||||
message: '通知设置已保存',
|
||||
description: '通知偏好设置已更新',
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAutoBackup = (enabled: boolean) => {
|
||||
console.log('自动备份:', enabled);
|
||||
localStorage.setItem('app-auto-backup', enabled.toString());
|
||||
notification.info({
|
||||
message: enabled ? '自动备份已启用' : '自动备份已禁用',
|
||||
description: enabled ? '系统将定期自动备份数据' : '已关闭自动备份功能',
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCompactMode = (enabled: boolean) => {
|
||||
console.log('紧凑模式:', enabled);
|
||||
document.documentElement.classList.toggle('compact', enabled);
|
||||
localStorage.setItem('app-compact-mode', enabled.toString());
|
||||
notification.info({
|
||||
message: enabled ? '紧凑模式已启用' : '紧凑模式已禁用',
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAutoLock = (enabled: boolean) => {
|
||||
console.log('自动锁屏:', enabled);
|
||||
localStorage.setItem('app-auto-lock', enabled.toString());
|
||||
notification.info({
|
||||
message: enabled ? '自动锁屏已启用' : '自动锁屏已禁用',
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAnalytics = (enabled: boolean) => {
|
||||
console.log('数据统计:', enabled);
|
||||
localStorage.setItem('app-analytics', enabled.toString());
|
||||
notification.info({
|
||||
message: enabled ? '数据统计已启用' : '数据统计已禁用',
|
||||
});
|
||||
};
|
||||
|
||||
const backupData = async () => {
|
||||
operationLoading.value.backup = true;
|
||||
try {
|
||||
// 模拟备份过程
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// 创建备份数据
|
||||
const backupData = {
|
||||
settings: settings.value,
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
// 下载备份文件
|
||||
const blob = new Blob([JSON.stringify(backupData, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `finwise-pro-backup-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
notification.success({
|
||||
message: '数据备份成功',
|
||||
description: '备份文件已下载到本地',
|
||||
});
|
||||
} catch {
|
||||
notification.error({
|
||||
message: '备份失败',
|
||||
description: '数据备份过程中出现错误',
|
||||
});
|
||||
} finally {
|
||||
operationLoading.value.backup = false;
|
||||
}
|
||||
};
|
||||
|
||||
const importData = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.addEventListener('change', async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
operationLoading.value.import = true;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const importedData = JSON.parse(text);
|
||||
|
||||
// 验证数据格式
|
||||
if (importedData.settings && importedData.version) {
|
||||
settings.value = { ...settings.value, ...importedData.settings };
|
||||
|
||||
notification.success({
|
||||
message: '数据导入成功',
|
||||
description: '设置已从备份文件恢复',
|
||||
});
|
||||
} else {
|
||||
throw new Error('无效的备份文件格式');
|
||||
}
|
||||
} catch {
|
||||
notification.error({
|
||||
message: '导入失败',
|
||||
description: '备份文件格式无效或已损坏',
|
||||
});
|
||||
} finally {
|
||||
operationLoading.value.import = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
input.click();
|
||||
};
|
||||
|
||||
const clearCache = async () => {
|
||||
operationLoading.value.cache = true;
|
||||
try {
|
||||
// 模拟清除缓存过程
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
// 清除各种缓存
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map((name) => caches.delete(name)));
|
||||
}
|
||||
|
||||
// 清除localStorage中的缓存数据
|
||||
const keysToKeep = new Set(['app-currency', 'app-language', 'app-theme']);
|
||||
Object.keys(localStorage).forEach((key) => {
|
||||
if (!keysToKeep.has(key)) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
notification.success({
|
||||
message: '缓存清除成功',
|
||||
description: '系统缓存已清理完成',
|
||||
});
|
||||
} catch {
|
||||
notification.error({
|
||||
message: '清除失败',
|
||||
description: '缓存清除过程中出现错误',
|
||||
});
|
||||
} finally {
|
||||
operationLoading.value.cache = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetSystem = () => {
|
||||
Modal.confirm({
|
||||
title: '⚠️ 确认重置系统',
|
||||
content: '此操作将删除所有数据和设置,且不可恢复。确定要继续吗?',
|
||||
okText: '确定重置',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
operationLoading.value.reset = true;
|
||||
try {
|
||||
// 模拟重置过程
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// 清除所有本地数据
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
notification.success({
|
||||
message: '系统重置成功',
|
||||
description: '系统将重新加载以应用重置',
|
||||
});
|
||||
|
||||
// 延迟重新加载
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} catch {
|
||||
notification.error({
|
||||
message: '重置失败',
|
||||
description: '系统重置过程中出现错误',
|
||||
});
|
||||
} finally {
|
||||
operationLoading.value.reset = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const saveAllSettings = () => {
|
||||
console.log('保存所有设置:', settings.value);
|
||||
localStorage.setItem('app-all-settings', JSON.stringify(settings.value));
|
||||
notification.success({
|
||||
message: '设置保存成功',
|
||||
description: '所有配置已保存',
|
||||
});
|
||||
};
|
||||
|
||||
const resetAllSettings = () => {
|
||||
settings.value = {
|
||||
defaultCurrency: 'CNY',
|
||||
notifications: {
|
||||
budget: true,
|
||||
bills: true,
|
||||
investment: false,
|
||||
},
|
||||
autoBackup: true,
|
||||
compactMode: false,
|
||||
autoLock: false,
|
||||
analytics: true,
|
||||
};
|
||||
|
||||
notification.success({
|
||||
message: '设置已重置',
|
||||
description: '所有设置已恢复为默认值',
|
||||
});
|
||||
};
|
||||
|
||||
const exportAllSettings = () => {
|
||||
const settingsData = {
|
||||
settings: settings.value,
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0.0',
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(settingsData, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `finwise-pro-settings-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
notification.success({
|
||||
message: '设置导出成功',
|
||||
description: '配置文件已下载',
|
||||
});
|
||||
};
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 从localStorage恢复设置
|
||||
try {
|
||||
const savedSettings = localStorage.getItem('app-all-settings');
|
||||
if (savedSettings) {
|
||||
const parsed = JSON.parse(savedSettings);
|
||||
settings.value = { ...settings.value, ...parsed };
|
||||
}
|
||||
|
||||
settings.value.defaultCurrency =
|
||||
localStorage.getItem('app-currency') || 'CNY';
|
||||
} catch (error) {
|
||||
console.error('设置恢复失败:', error);
|
||||
}
|
||||
|
||||
console.log('系统设置页面加载完成');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">⚙️ 系统设置</h1>
|
||||
<h1 class="mb-2 text-3xl font-bold text-gray-900">⚙️ 系统设置</h1>
|
||||
<p class="text-gray-600">财务系统的个性化配置和偏好设置</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<Card title="🔧 基本设置">
|
||||
<Form :model="settings" layout="vertical">
|
||||
<Divider>通知设置</Divider>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium">💰 预算提醒</span>
|
||||
<p class="text-sm text-gray-500">预算接近或超支时提醒</p>
|
||||
</div>
|
||||
<Switch v-model:checked="settings.notifications.budget" @change="saveNotificationSettings" />
|
||||
<Switch
|
||||
v-model:checked="settings.notifications.budget"
|
||||
@change="saveNotificationSettings"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium">🔔 账单提醒</span>
|
||||
<p class="text-sm text-gray-500">账单到期前提醒缴费</p>
|
||||
</div>
|
||||
<Switch v-model:checked="settings.notifications.bills" @change="saveNotificationSettings" />
|
||||
<Switch
|
||||
v-model:checked="settings.notifications.bills"
|
||||
@change="saveNotificationSettings"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium">📊 投资更新</span>
|
||||
<p class="text-sm text-gray-500">投资收益变化通知</p>
|
||||
</div>
|
||||
<Switch v-model:checked="settings.notifications.investment" @change="saveNotificationSettings" />
|
||||
<Switch
|
||||
v-model:checked="settings.notifications.investment"
|
||||
@change="saveNotificationSettings"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="font-medium">💾 自动备份</span>
|
||||
<p class="text-sm text-gray-500">定期自动备份数据</p>
|
||||
</div>
|
||||
<Switch v-model:checked="settings.autoBackup" @change="toggleAutoBackup" />
|
||||
<Switch
|
||||
v-model:checked="settings.autoBackup"
|
||||
@change="toggleAutoBackup"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider>高级设置</Divider>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>🎨 紧凑模式</span>
|
||||
<Switch v-model:checked="settings.compactMode" @change="toggleCompactMode" />
|
||||
<Switch
|
||||
v-model:checked="settings.compactMode"
|
||||
@change="toggleCompactMode"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>🔒 自动锁屏</span>
|
||||
<Switch v-model:checked="settings.autoLock" @change="toggleAutoLock" />
|
||||
<Switch
|
||||
v-model:checked="settings.autoLock"
|
||||
@change="toggleAutoLock"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<span>📈 数据统计</span>
|
||||
<Switch v-model:checked="settings.analytics" @change="toggleAnalytics" />
|
||||
<Switch
|
||||
v-model:checked="settings.analytics"
|
||||
@change="toggleAnalytics"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-x-4">
|
||||
<Button type="primary" @click="saveAllSettings">💾 保存所有设置</Button>
|
||||
<Button type="primary" @click="saveAllSettings">
|
||||
💾 保存所有设置
|
||||
</Button>
|
||||
<Button @click="resetAllSettings">🔄 恢复默认</Button>
|
||||
<Button @click="exportAllSettings">📤 导出配置</Button>
|
||||
</div>
|
||||
@@ -95,7 +430,12 @@
|
||||
<Button block @click="clearCache" :loading="operationLoading.cache">
|
||||
🧹 清除缓存
|
||||
</Button>
|
||||
<Button block danger @click="resetSystem" :loading="operationLoading.reset">
|
||||
<Button
|
||||
block
|
||||
danger
|
||||
@click="resetSystem"
|
||||
:loading="operationLoading.reset"
|
||||
>
|
||||
🗑️ 重置系统
|
||||
</Button>
|
||||
</div>
|
||||
@@ -104,306 +444,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import {
|
||||
Card, Select, Switch, Tag, Button, Form,
|
||||
Divider, notification, Modal
|
||||
} from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'FinanceSettings' });
|
||||
|
||||
// 系统设置
|
||||
const settings = ref({
|
||||
defaultCurrency: 'CNY',
|
||||
notifications: {
|
||||
budget: true,
|
||||
bills: true,
|
||||
investment: false
|
||||
},
|
||||
autoBackup: true,
|
||||
compactMode: false,
|
||||
autoLock: false,
|
||||
analytics: true
|
||||
});
|
||||
|
||||
// 操作加载状态
|
||||
const operationLoading = ref({
|
||||
backup: false,
|
||||
import: false,
|
||||
cache: false,
|
||||
reset: false
|
||||
});
|
||||
|
||||
// 功能方法
|
||||
const saveCurrencySettings = (currency: string) => {
|
||||
console.log('货币设置更改为:', currency);
|
||||
localStorage.setItem('app-currency', currency);
|
||||
notification.success({
|
||||
message: '货币设置已更新',
|
||||
description: `默认货币已设置为 ${currency}`
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const saveNotificationSettings = () => {
|
||||
console.log('通知设置已保存:', settings.value.notifications);
|
||||
localStorage.setItem('app-notifications', JSON.stringify(settings.value.notifications));
|
||||
notification.info({
|
||||
message: '通知设置已保存',
|
||||
description: '通知偏好设置已更新'
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAutoBackup = (enabled: boolean) => {
|
||||
console.log('自动备份:', enabled);
|
||||
localStorage.setItem('app-auto-backup', enabled.toString());
|
||||
notification.info({
|
||||
message: enabled ? '自动备份已启用' : '自动备份已禁用',
|
||||
description: enabled ? '系统将定期自动备份数据' : '已关闭自动备份功能'
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCompactMode = (enabled: boolean) => {
|
||||
console.log('紧凑模式:', enabled);
|
||||
document.documentElement.classList.toggle('compact', enabled);
|
||||
localStorage.setItem('app-compact-mode', enabled.toString());
|
||||
notification.info({
|
||||
message: enabled ? '紧凑模式已启用' : '紧凑模式已禁用'
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAutoLock = (enabled: boolean) => {
|
||||
console.log('自动锁屏:', enabled);
|
||||
localStorage.setItem('app-auto-lock', enabled.toString());
|
||||
notification.info({
|
||||
message: enabled ? '自动锁屏已启用' : '自动锁屏已禁用'
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAnalytics = (enabled: boolean) => {
|
||||
console.log('数据统计:', enabled);
|
||||
localStorage.setItem('app-analytics', enabled.toString());
|
||||
notification.info({
|
||||
message: enabled ? '数据统计已启用' : '数据统计已禁用'
|
||||
});
|
||||
};
|
||||
|
||||
const backupData = async () => {
|
||||
operationLoading.value.backup = true;
|
||||
try {
|
||||
// 模拟备份过程
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// 创建备份数据
|
||||
const backupData = {
|
||||
settings: settings.value,
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0.0'
|
||||
};
|
||||
|
||||
// 下载备份文件
|
||||
const blob = new Blob([JSON.stringify(backupData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `finwise-pro-backup-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
notification.success({
|
||||
message: '数据备份成功',
|
||||
description: '备份文件已下载到本地'
|
||||
});
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
message: '备份失败',
|
||||
description: '数据备份过程中出现错误'
|
||||
});
|
||||
} finally {
|
||||
operationLoading.value.backup = false;
|
||||
}
|
||||
};
|
||||
|
||||
const importData = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
operationLoading.value.import = true;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const importedData = JSON.parse(text);
|
||||
|
||||
// 验证数据格式
|
||||
if (importedData.settings && importedData.version) {
|
||||
settings.value = { ...settings.value, ...importedData.settings };
|
||||
|
||||
notification.success({
|
||||
message: '数据导入成功',
|
||||
description: '设置已从备份文件恢复'
|
||||
});
|
||||
} else {
|
||||
throw new Error('无效的备份文件格式');
|
||||
}
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
message: '导入失败',
|
||||
description: '备份文件格式无效或已损坏'
|
||||
});
|
||||
} finally {
|
||||
operationLoading.value.import = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const clearCache = async () => {
|
||||
operationLoading.value.cache = true;
|
||||
try {
|
||||
// 模拟清除缓存过程
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// 清除各种缓存
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
||||
}
|
||||
|
||||
// 清除localStorage中的缓存数据
|
||||
const keysToKeep = ['app-language', 'app-theme', 'app-currency'];
|
||||
Object.keys(localStorage).forEach(key => {
|
||||
if (!keysToKeep.includes(key)) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
notification.success({
|
||||
message: '缓存清除成功',
|
||||
description: '系统缓存已清理完成'
|
||||
});
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
message: '清除失败',
|
||||
description: '缓存清除过程中出现错误'
|
||||
});
|
||||
} finally {
|
||||
operationLoading.value.cache = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetSystem = () => {
|
||||
Modal.confirm({
|
||||
title: '⚠️ 确认重置系统',
|
||||
content: '此操作将删除所有数据和设置,且不可恢复。确定要继续吗?',
|
||||
okText: '确定重置',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
operationLoading.value.reset = true;
|
||||
try {
|
||||
// 模拟重置过程
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// 清除所有本地数据
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
notification.success({
|
||||
message: '系统重置成功',
|
||||
description: '系统将重新加载以应用重置'
|
||||
});
|
||||
|
||||
// 延迟重新加载
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
notification.error({
|
||||
message: '重置失败',
|
||||
description: '系统重置过程中出现错误'
|
||||
});
|
||||
} finally {
|
||||
operationLoading.value.reset = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveAllSettings = () => {
|
||||
console.log('保存所有设置:', settings.value);
|
||||
localStorage.setItem('app-all-settings', JSON.stringify(settings.value));
|
||||
notification.success({
|
||||
message: '设置保存成功',
|
||||
description: '所有配置已保存'
|
||||
});
|
||||
};
|
||||
|
||||
const resetAllSettings = () => {
|
||||
settings.value = {
|
||||
defaultCurrency: 'CNY',
|
||||
notifications: {
|
||||
budget: true,
|
||||
bills: true,
|
||||
investment: false
|
||||
},
|
||||
autoBackup: true,
|
||||
compactMode: false,
|
||||
autoLock: false,
|
||||
analytics: true
|
||||
};
|
||||
|
||||
notification.success({
|
||||
message: '设置已重置',
|
||||
description: '所有设置已恢复为默认值'
|
||||
});
|
||||
};
|
||||
|
||||
const exportAllSettings = () => {
|
||||
const settingsData = {
|
||||
settings: settings.value,
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0.0'
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(settingsData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `finwise-pro-settings-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
notification.success({
|
||||
message: '设置导出成功',
|
||||
description: '配置文件已下载'
|
||||
});
|
||||
};
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 从localStorage恢复设置
|
||||
try {
|
||||
const savedSettings = localStorage.getItem('app-all-settings');
|
||||
if (savedSettings) {
|
||||
const parsed = JSON.parse(savedSettings);
|
||||
settings.value = { ...settings.value, ...parsed };
|
||||
}
|
||||
|
||||
settings.value.defaultCurrency = localStorage.getItem('app-currency') || 'CNY';
|
||||
|
||||
} catch (error) {
|
||||
console.error('设置恢复失败:', error);
|
||||
}
|
||||
|
||||
console.log('系统设置页面加载完成');
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; }
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1398,7 +1398,9 @@ const _handleAccountChange = (account: string) => {
|
||||
<div class="flex space-x-2">
|
||||
<Button @click="openRecycleBin">
|
||||
🗑️ 回收站
|
||||
<span v-if="deletedTransactions.length > 0" class="ml-1">({{ deletedTransactions.length }})</span>
|
||||
<span v-if="deletedTransactions.length > 0" class="ml-1"
|
||||
>({{ deletedTransactions.length }})</span
|
||||
>
|
||||
</Button>
|
||||
<Button type="primary" @click="quickAddIncome"> 💰 添加收入 </Button>
|
||||
<Button @click="quickAddExpense"> 💸 添加支出 </Button>
|
||||
@@ -1526,7 +1528,9 @@ const _handleAccountChange = (account: string) => {
|
||||
<Row :gutter="16">
|
||||
<Col :span="12">
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium">货币类型 <span class="text-red-500">*</span></label>
|
||||
<label class="mb-2 block text-sm font-medium"
|
||||
>货币类型 <span class="text-red-500">*</span></label
|
||||
>
|
||||
<Radio.Group
|
||||
v-model:value="quickIncomeForm.currency"
|
||||
size="large"
|
||||
@@ -1581,7 +1585,9 @@ const _handleAccountChange = (account: string) => {
|
||||
</InputNumber>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<label class="mb-2 block text-sm font-medium">总金额 <span class="text-red-500">*</span></label>
|
||||
<label class="mb-2 block text-sm font-medium"
|
||||
>总金额 <span class="text-red-500">*</span></label
|
||||
>
|
||||
<InputNumber
|
||||
v-model:value="quickIncomeForm.amount"
|
||||
:min="0"
|
||||
@@ -1599,7 +1605,9 @@ const _handleAccountChange = (account: string) => {
|
||||
<!-- 直接输入金额模式 -->
|
||||
<Row v-else :gutter="16" class="mb-4">
|
||||
<Col :span="24">
|
||||
<label class="mb-2 block text-sm font-medium">金额 <span class="text-red-500">*</span></label>
|
||||
<label class="mb-2 block text-sm font-medium"
|
||||
>金额 <span class="text-red-500">*</span></label
|
||||
>
|
||||
<InputNumber
|
||||
v-model:value="quickIncomeForm.amount"
|
||||
:min="0"
|
||||
@@ -1640,7 +1648,9 @@ const _handleAccountChange = (account: string) => {
|
||||
</Row>
|
||||
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium">收入账户 <span class="text-red-500">*</span></label>
|
||||
<label class="mb-2 block text-sm font-medium"
|
||||
>收入账户 <span class="text-red-500">*</span></label
|
||||
>
|
||||
<Radio.Group
|
||||
v-model:value="quickIncomeForm.accountId"
|
||||
size="large"
|
||||
@@ -2382,7 +2392,9 @@ const _handleAccountChange = (account: string) => {
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">币种字段(可选,默认USD)</label>
|
||||
<label class="mb-1 block text-sm font-medium"
|
||||
>币种字段(可选,默认USD)</label
|
||||
>
|
||||
<Select
|
||||
v-model:value="importMapping.currency"
|
||||
placeholder="选择对应列"
|
||||
|
||||
@@ -9,7 +9,7 @@ export default defineConfig(async () => {
|
||||
'/api': {
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
target: 'http://localhost:3000/api',
|
||||
target: 'http://localhost:5320/api',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user