feat: 更新财务系统功能和界面优化

- 优化财务仪表板数据展示
- 增强账户管理功能
- 改进预算和分类管理
- 完善报表和统计分析
- 优化交易管理界面
- 更新Workspace工作区

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
woshiqp465
2025-10-05 15:10:06 +08:00
parent a1dc8de7e5
commit 1def26f74f
35 changed files with 4449 additions and 3000 deletions

View File

@@ -1,5 +1,4 @@
import { getQuery } from 'h3'; import { getQuery } from 'h3';
import { listAccounts } from '~/utils/finance-metadata'; import { listAccounts } from '~/utils/finance-metadata';
import { useResponseSuccess } from '~/utils/response'; import { useResponseSuccess } from '~/utils/response';

View File

@@ -1,11 +1,10 @@
import { getQuery } from 'h3'; import { getQuery } from 'h3';
import { fetchCategories } from '~/utils/finance-repository'; import { fetchCategories } from '~/utils/finance-repository';
import { useResponseSuccess } from '~/utils/response'; import { useResponseSuccess } from '~/utils/response';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const query = getQuery(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 }); const categories = fetchCategories({ type });

View File

@@ -1,5 +1,4 @@
import { readBody } from 'h3'; import { readBody } from 'h3';
import { createCategoryRecord } from '~/utils/finance-metadata'; import { createCategoryRecord } from '~/utils/finance-metadata';
import { useResponseError, useResponseSuccess } from '~/utils/response'; import { useResponseError, useResponseSuccess } from '~/utils/response';

View File

@@ -1,5 +1,4 @@
import { getRouterParam } from 'h3'; import { getRouterParam } from 'h3';
import { deleteCategoryRecord } from '~/utils/finance-metadata'; import { deleteCategoryRecord } from '~/utils/finance-metadata';
import { useResponseError, useResponseSuccess } from '~/utils/response'; import { useResponseError, useResponseSuccess } from '~/utils/response';

View File

@@ -1,5 +1,4 @@
import { getRouterParam, readBody } from 'h3'; import { getRouterParam, readBody } from 'h3';
import { updateCategoryRecord } from '~/utils/finance-metadata'; import { updateCategoryRecord } from '~/utils/finance-metadata';
import { useResponseError, useResponseSuccess } from '~/utils/response'; import { useResponseError, useResponseSuccess } from '~/utils/response';

View File

@@ -1,5 +1,4 @@
import { getQuery } from 'h3'; import { getQuery } from 'h3';
import { listExchangeRates } from '~/utils/finance-metadata'; import { listExchangeRates } from '~/utils/finance-metadata';
import { useResponseSuccess } from '~/utils/response'; import { useResponseSuccess } from '~/utils/response';
@@ -22,7 +21,10 @@ export default defineEventHandler(async (event) => {
if (date) { if (date) {
rates = rates.filter((rate) => rate.date === date); rates = rates.filter((rate) => rate.date === date);
} else if (rates.length > 0) { } 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); rates = rates.filter((rate) => rate.date === latestDate);
} }

View File

@@ -1,5 +1,4 @@
import { getQuery } from 'h3'; import { getQuery } from 'h3';
import { fetchTransactions } from '~/utils/finance-repository'; import { fetchTransactions } from '~/utils/finance-repository';
import { useResponseSuccess } from '~/utils/response'; import { useResponseSuccess } from '~/utils/response';

View File

@@ -1,5 +1,4 @@
import { readBody } from 'h3'; import { readBody } from 'h3';
import { createTransaction } from '~/utils/finance-repository'; import { createTransaction } from '~/utils/finance-repository';
import { useResponseError, useResponseSuccess } from '~/utils/response'; import { useResponseError, useResponseSuccess } from '~/utils/response';

View File

@@ -1,5 +1,4 @@
import { getRouterParam } from 'h3'; import { getRouterParam } from 'h3';
import { softDeleteTransaction } from '~/utils/finance-repository'; import { softDeleteTransaction } from '~/utils/finance-repository';
import { useResponseError, useResponseSuccess } from '~/utils/response'; import { useResponseError, useResponseSuccess } from '~/utils/response';

View File

@@ -1,6 +1,8 @@
import { getRouterParam, readBody } from 'h3'; import { getRouterParam, readBody } from 'h3';
import {
import { restoreTransaction, updateTransaction } from '~/utils/finance-repository'; restoreTransaction,
updateTransaction,
} from '~/utils/finance-repository';
import { useResponseError, useResponseSuccess } from '~/utils/response'; import { useResponseError, useResponseSuccess } from '~/utils/response';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -30,10 +32,12 @@ export default defineEventHandler(async (event) => {
payload.amount = amount; payload.amount = amount;
} }
if (body?.currency) payload.currency = body.currency; 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?.accountId !== undefined) payload.accountId = body.accountId ?? null;
if (body?.transactionDate) payload.transactionDate = body.transactionDate; 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?.project !== undefined) payload.project = body.project ?? null;
if (body?.memo !== undefined) payload.memo = body.memo ?? null; if (body?.memo !== undefined) payload.memo = body.memo ?? null;
if (body?.isDeleted !== undefined) payload.isDeleted = body.isDeleted; if (body?.isDeleted !== undefined) payload.isDeleted = body.isDeleted;

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
const fs = require('fs'); const fs = require('node:fs');
const path = require('path'); const path = require('node:path');
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const args = process.argv.slice(2); 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); const lines = RAW_TEXT.split(/\r?\n/).filter((line) => line.trim().length > 0);
if (lines.length <= 1) { if (lines.length <= 1) {
console.error('CSV 文件内容为空'); console.error('CSV 文件内容为空');
@@ -126,7 +127,14 @@ const ACCOUNT_IDX = header.indexOf('支出人');
const CATEGORY_IDX = header.indexOf('计入'); const CATEGORY_IDX = header.indexOf('计入');
const SHARE_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 表头缺少必需字段'); console.error('CSV 表头缺少必需字段');
process.exit(1); process.exit(1);
} }
@@ -138,9 +146,27 @@ const CURRENCIES = [
]; ];
const EXCHANGE_RATES = [ 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: 'CNY',
{ fromCurrency: 'THB', toCurrency: 'CNY', rate: 0.2, date: `${baseYear}-01-01`, source: 'manual' }, 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 = '未分类支出'; const DEFAULT_EXPENSE_CATEGORY = '未分类支出';
@@ -178,7 +204,12 @@ function inferCurrency(accountName, amountText) {
const name = accountName ?? ''; const name = accountName ?? '';
const text = `${name}${amountText ?? ''}`; const text = `${name}${amountText ?? ''}`;
const lower = text.toLowerCase(); 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'; return 'USD';
} }
if (lower.includes('泰铢') || lower.includes('thb')) { if (lower.includes('泰铢') || lower.includes('thb')) {
@@ -190,7 +221,9 @@ function inferCurrency(accountName, amountText) {
function parseAmount(raw) { function parseAmount(raw) {
if (!raw) return 0; if (!raw) return 0;
const matches = String(raw) const matches = String(raw)
.replace(/[^0-9.+-]/g, (char) => (char === '+' || char === '-' ? char : ' ')) .replaceAll(/[^0-9.+-]/g, (char) =>
char === '+' || char === '-' ? char : ' ',
)
.match(/[-+]?\d+(?:\.\d+)?/g); .match(/[-+]?\d+(?:\.\d+)?/g);
if (!matches) return 0; if (!matches) return 0;
return matches.map(Number).reduce((sum, value) => sum + value, 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 month = Number(match[1]);
const day = Number(match[2]); const day = Number(match[2]);
let year = baseYear; let year = baseYear;
if (monthTracker.lastMonth !== null && month > monthTracker.lastMonth && monthTracker.wrapped) { if (
monthTracker.lastMonth !== null &&
month > monthTracker.lastMonth &&
monthTracker.wrapped
) {
year -= 1; year -= 1;
} }
if (monthTracker.lastMonth !== null && month < monthTracker.lastMonth && !monthTracker.wrapped) { if (
monthTracker.lastMonth !== null &&
month < monthTracker.lastMonth &&
!monthTracker.wrapped
) {
monthTracker.wrapped = true; monthTracker.wrapped = true;
} }
monthTracker.lastMonth = month; monthTracker.lastMonth = month;
@@ -231,12 +272,25 @@ const insertCategory = db.prepare(`
db.transaction(() => { db.transaction(() => {
if (!categoryMap.has(`${DEFAULT_INCOME_CATEGORY}-income`)) { 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); categoryMap.set(`${DEFAULT_INCOME_CATEGORY}-income`, info.lastInsertRowid);
} }
if (!categoryMap.has(`${DEFAULT_EXPENSE_CATEGORY}-expense`)) { if (!categoryMap.has(`${DEFAULT_EXPENSE_CATEGORY}-expense`)) {
const info = insertCategory.run({ name: DEFAULT_EXPENSE_CATEGORY, type: 'expense', icon: '🏷️', color: '#6366f1' }); const info = insertCategory.run({
categoryMap.set(`${DEFAULT_EXPENSE_CATEGORY}-expense`, info.lastInsertRowid); 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 amountRaw = row[AMOUNT_IDX].trim();
const accountNameRaw = row[ACCOUNT_IDX].trim(); const accountNameRaw = row[ACCOUNT_IDX].trim();
const categoryRaw = row[CATEGORY_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); const amount = parseAmount(amountRaw);
if (!amount) { if (!amount) {
continue; continue;
} }
const normalizedType = typeText.includes('收') && !typeText.includes('支') ? 'income' : 'expense'; const normalizedType =
typeText.includes('收') && !typeText.includes('支') ? 'income' : 'expense';
const accountName = accountNameRaw || '美金现金'; const accountName = accountNameRaw || '美金现金';
const currency = inferCurrency(accountNameRaw, amountRaw); const currency = inferCurrency(accountNameRaw, amountRaw);
if (!accountMap.has(accountName)) { if (!accountMap.has(accountName)) {
const icon = currency === 'USD' ? '💵' : currency === 'THB' ? '💱' : '💰'; 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({ const info = insertAccount.run({
name: accountName, name: accountName,
currency, currency,
@@ -285,7 +345,11 @@ for (let i = 1; i < lines.length; i += 1) {
accountMap.set(accountName, Number(info.lastInsertRowid)); 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}`; const categoryKey = `${categoryName}-${normalizedType}`;
if (!categoryMap.has(categoryKey)) { if (!categoryMap.has(categoryKey)) {
const icon = normalizedType === 'income' ? '💰' : '🏷️'; const icon = normalizedType === 'income' ? '💰' : '🏷️';
@@ -360,4 +424,6 @@ const insertMany = db.transaction((items) => {
insertMany(transactions); insertMany(transactions);
console.log(`已导入 ${transactions.length} 条交易,账户 ${accountMap.size} 个,分类 ${categoryMap.size} 个。`); console.log(
`已导入 ${transactions.length} 条交易,账户 ${accountMap.size} 个,分类 ${categoryMap.size} 个。`,
);

View File

@@ -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() { export function listAccounts() {
return MOCK_ACCOUNTS; return MOCK_ACCOUNTS;
@@ -31,7 +37,7 @@ export function createCategoryRecord(category: any) {
} }
export function updateCategoryRecord(id: number, 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) { if (index !== -1) {
MOCK_CATEGORIES[index] = { ...MOCK_CATEGORIES[index], ...category }; MOCK_CATEGORIES[index] = { ...MOCK_CATEGORIES[index], ...category };
return MOCK_CATEGORIES[index]; return MOCK_CATEGORIES[index];
@@ -40,7 +46,7 @@ export function updateCategoryRecord(id: number, category: any) {
} }
export function deleteCategoryRecord(id: number) { 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) { if (index !== -1) {
MOCK_CATEGORIES.splice(index, 1); MOCK_CATEGORIES.splice(index, 1);
return true; return true;

View File

@@ -9,27 +9,27 @@ interface TransactionRow {
currency: string; currency: string;
exchange_rate_to_base: number; exchange_rate_to_base: number;
amount_in_base: number; amount_in_base: number;
category_id: number | null; category_id: null | number;
account_id: number | null; account_id: null | number;
transaction_date: string; transaction_date: string;
description: string | null; description: null | string;
project: string | null; project: null | string;
memo: string | null; memo: null | string;
created_at: string; created_at: string;
is_deleted: number; is_deleted: number;
deleted_at: string | null; deleted_at: null | string;
} }
interface TransactionPayload { interface TransactionPayload {
type: string; type: string;
amount: number; amount: number;
currency: string; currency: string;
categoryId?: number | null; categoryId?: null | number;
accountId?: number | null; accountId?: null | number;
transactionDate: string; transactionDate: string;
description?: string; description?: string;
project?: string | null; project?: null | string;
memo?: string | null; memo?: null | string;
createdAt?: string; createdAt?: string;
isDeleted?: boolean; isDeleted?: boolean;
} }
@@ -41,7 +41,7 @@ function getExchangeRateToBase(currency: string) {
const stmt = db.prepare( const stmt = db.prepare(
`SELECT rate FROM finance_exchange_rates WHERE from_currency = ? AND to_currency = ? ORDER BY date DESC LIMIT 1`, `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; return row?.rate ?? 1;
} }
@@ -49,7 +49,7 @@ function mapTransaction(row: TransactionRow) {
return { return {
id: row.id, id: row.id,
userId: 1, userId: 1,
type: row.type as 'income' | 'expense' | 'transfer', type: row.type as 'expense' | 'income' | 'transfer',
amount: row.amount, amount: row.amount,
currency: row.currency, currency: row.currency,
exchangeRateToBase: row.exchange_rate_to_base, 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 clauses: string[] = [];
const params: Record<string, unknown> = {}; const params: Record<string, unknown> = {};
@@ -78,7 +80,7 @@ export function fetchTransactions(options: { type?: string; includeDeleted?: boo
params.type = options.type; 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>( 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`, `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) { export function createTransaction(payload: TransactionPayload) {
const exchangeRate = getExchangeRateToBase(payload.currency); const exchangeRate = getExchangeRateToBase(payload.currency);
const amountInBase = +(payload.amount * exchangeRate).toFixed(2); 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( 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)`, `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) { 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() }); stmt.run({ id, deletedAt: new Date().toISOString() });
return getTransactionById(id); return getTransactionById(id);
} }
export function restoreTransaction(id: number) { 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 }); stmt.run({ id });
return getTransactionById(id); return getTransactionById(id);
} }
export function replaceAllTransactions(rows: Array<{ export function replaceAllTransactions(
type: string; rows: Array<{
amount: number; accountId: null | number;
currency: string; amount: number;
categoryId: number | null; categoryId: null | number;
accountId: number | null; createdAt?: string;
transactionDate: string; currency: string;
description: string; description: string;
project?: string | null; memo?: null | string;
memo?: string | null; project?: null | string;
createdAt?: string; transactionDate: string;
}>) { type: string;
}>,
) {
db.prepare('DELETE FROM finance_transactions').run(); db.prepare('DELETE FROM finance_transactions').run();
const insert = db.prepare( const insert = db.prepare(
@@ -206,7 +217,7 @@ export function replaceAllTransactions(rows: Array<{
const insertMany = db.transaction((items: Array<any>) => { const insertMany = db.transaction((items: Array<any>) => {
for (const item of items) { 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 rate = row?.rate ?? 1;
const amountInBase = +(item.amount * rate).toFixed(2); const amountInBase = +(item.amount * rate).toFixed(2);
insert.run({ insert.run({
@@ -215,7 +226,9 @@ export function replaceAllTransactions(rows: Array<{
amountInBase, amountInBase,
project: item.project ?? null, project: item.project ?? null,
memo: item.memo ?? 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; id: number;
name: string; name: string;
type: string; type: string;
icon: string | null; icon: null | string;
color: string | null; color: null | string;
user_id: number | null; user_id: null | number;
is_active: number; is_active: number;
} }
@@ -239,7 +252,7 @@ function mapCategory(row: CategoryRow) {
id: row.id, id: row.id,
userId: row.user_id ?? null, userId: row.user_id ?? null,
name: row.name, name: row.name,
type: row.type as 'income' | 'expense', type: row.type as 'expense' | 'income',
icon: row.icon ?? '📝', icon: row.icon ?? '📝',
color: row.color ?? '#dfe4ea', color: row.color ?? '#dfe4ea',
sortOrder: row.id, sortOrder: row.id,
@@ -248,8 +261,10 @@ function mapCategory(row: CategoryRow) {
}; };
} }
export function fetchCategories(options: { type?: 'income' | 'expense' } = {}) { export function fetchCategories(options: { type?: 'expense' | 'income' } = {}) {
const where = options.type ? `WHERE type = @type AND is_active = 1` : 'WHERE is_active = 1'; const where = options.type
? `WHERE type = @type AND is_active = 1`
: 'WHERE is_active = 1';
const params = options.type ? { type: options.type } : {}; const params = options.type ? { type: options.type } : {};
const stmt = db.prepare<CategoryRow>( const stmt = db.prepare<CategoryRow>(

View File

@@ -1,5 +1,6 @@
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import Database from 'better-sqlite3';
import { dirname, join } from 'pathe'; import { dirname, join } from 'pathe';
const dbFile = join(process.cwd(), 'storage', 'finance.db'); const dbFile = join(process.cwd(), 'storage', 'finance.db');

View File

@@ -32,7 +32,7 @@
<div id="app"></div> <div id="app"></div>
<script> <script>
// Flatten FinWise Pro menu - Remove parent menu and show children directly // Flatten FinWise Pro menu - Remove parent menu and show children directly
(function() { (function () {
console.log('[FinWise] Script loaded'); console.log('[FinWise] Script loaded');
function flattenFinWiseProMenu() { function flattenFinWiseProMenu() {
@@ -41,7 +41,7 @@
console.log('[FinWise] Found submenus:', submenus.length); console.log('[FinWise] Found submenus:', submenus.length);
let finwiseMenu = null; let finwiseMenu = null;
submenus.forEach(menu => { submenus.forEach((menu) => {
const titleEl = menu.querySelector('.vben-sub-menu-content__title'); const titleEl = menu.querySelector('.vben-sub-menu-content__title');
if (titleEl && titleEl.textContent) { if (titleEl && titleEl.textContent) {
console.log('[FinWise] Menu title:', titleEl.textContent.trim()); console.log('[FinWise] Menu title:', titleEl.textContent.trim());
@@ -74,7 +74,7 @@
// Move all children to the parent menu // Move all children to the parent menu
const children = Array.from(childrenUL.children); const children = Array.from(childrenUL.children);
console.log('[FinWise] Moving', children.length, 'children'); console.log('[FinWise] Moving', children.length, 'children');
children.forEach(child => { children.forEach((child) => {
parentMenu.insertBefore(child, finwiseMenu); parentMenu.insertBefore(child, finwiseMenu);
}); });
@@ -84,28 +84,30 @@
} }
// Run after DOM loads // 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); console.log('[FinWise] Setting up delays:', delays);
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
console.log('[FinWise] Waiting for DOMContentLoaded'); console.log('[FinWise] Waiting for DOMContentLoaded');
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
console.log('[FinWise] DOMContentLoaded fired'); console.log('[FinWise] DOMContentLoaded fired');
delays.forEach(delay => { delays.forEach((delay) => {
setTimeout(flattenFinWiseProMenu, delay); setTimeout(flattenFinWiseProMenu, delay);
}); });
}); });
} else { } else {
console.log('[FinWise] DOM already loaded'); console.log('[FinWise] DOM already loaded');
delays.forEach(delay => { delays.forEach((delay) => {
setTimeout(flattenFinWiseProMenu, delay); setTimeout(flattenFinWiseProMenu, delay);
}); });
} }
// Watch for DOM changes // Watch for DOM changes
setTimeout(function() { setTimeout(function () {
console.log('[FinWise] Setting up MutationObserver'); console.log('[FinWise] Setting up MutationObserver');
const observer = new MutationObserver(function() { const observer = new MutationObserver(function () {
setTimeout(flattenFinWiseProMenu, 200); setTimeout(flattenFinWiseProMenu, 200);
}); });
@@ -113,7 +115,7 @@
if (body) { if (body) {
observer.observe(body, { observer.observe(body, {
childList: true, childList: true,
subtree: true subtree: true,
}); });
console.log('[FinWise] MutationObserver active'); console.log('[FinWise] MutationObserver active');
} }

View File

@@ -1,14 +1,19 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
const INPUT_CSV = '/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正.csv'; const INPUT_CSV =
const OUTPUT_CSV = '/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正_带分类.csv'; '/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正.csv';
const OUTPUT_CSV =
'/Users/fuwuqi/Downloads/Telegram Desktop/控天-控天_完全修正_带分类.csv';
// 智能分类函数 // 智能分类函数
function getCategory(project: string): string { function getCategory(project: string): string {
const desc = project.toLowerCase(); const desc = project.toLowerCase();
// 工资 // 工资
if (desc.includes('工资') || desc.match(/amy|天天|碧桂园|皇|香缇卡|财务|客服|小哥|代理ip|sy|超鹏|小白/)) { if (
desc.includes('工资') ||
/amy|天天|碧桂园|皇|香缇卡|财务|客服|小哥|代理ip|sy|超鹏|小白/.test(desc)
) {
return '工资'; 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 '服务器/技术'; return '服务器/技术';
} }
// 广告推广 // 广告推广
if (desc.match(/广告|推广|地推|投放|打流量/)) { if (/广告|推广|地推|投放|打流量/.test(desc)) {
return '广告推广'; return '广告推广';
} }
// 软件/工具 // 软件/工具
if (desc.match(/会员|007|u盘|processon|飞机|虚拟卡|小红卡|信用卡|cloudflare|uizard|esim/)) { if (
/会员|007|u盘|processon|飞机|虚拟卡|小红卡|信用卡|cloudflare|uizard|esim/.test(
desc,
)
) {
return '软件/工具'; return '软件/工具';
} }
// 固定资产 // 固定资产
if (desc.match(/买车|电脑|笔记本|显示器|rog|硬盘|服务器.*购买|iphone|路由器|展示屏/)) { if (
/买车|电脑|笔记本|显示器|rog|硬盘|服务器.*购买|iphone|路由器|展示屏/.test(
desc,
)
) {
return '固定资产'; return '固定资产';
} }
@@ -48,7 +65,11 @@ function getCategory(project: string): string {
} }
// 借款/转账 // 借款/转账
if (desc.match(/借|转给|龙腾|投资款|换.*铢|换美金|换现金|报销|房租|生活费|办公室|出差|接待|保关|测试|开工红包/)) { if (
/借|转给|龙腾|投资款|换.*铢|换美金|换现金|报销|房租|生活费|办公室|出差|接待|保关|测试|开工红包/.test(
desc,
)
) {
return '借款/转账'; return '借款/转账';
} }
@@ -57,12 +78,12 @@ function getCategory(project: string): string {
} }
// 读取并处理CSV // 读取并处理CSV
const content = fs.readFileSync(INPUT_CSV, 'utf-8'); const content = fs.readFileSync(INPUT_CSV, 'utf8');
const lines = content.split('\n'); const lines = content.split('\n');
// 修改表头,添加"分类"列 // 修改表头,添加"分类"列
const header = lines[0]; const header = lines[0];
const newHeader = header.trimEnd() + ',分类\n'; const newHeader = `${header.trimEnd()},分类\n`;
// 处理每一行数据 // 处理每一行数据
const newLines = [newHeader]; const newLines = [newHeader];
@@ -84,7 +105,7 @@ for (let i = 1; i < lines.length; i++) {
const category = getCategory(project); const category = getCategory(project);
// 添加分类列 // 添加分类列
const newLine = line.trimEnd() + ',' + category + '\n'; const newLine = `${line.trimEnd()},${category}\n`;
newLines.push(newLine); newLines.push(newLine);
} }

View File

@@ -1,7 +1,7 @@
import * as fs from 'node:fs'; 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'; const API_URL = 'http://localhost:3000/api/finance/transactions';
interface CSVRow { interface CSVRow {
@@ -59,11 +59,7 @@ function parseDate(dateStr: string, previousDate: string = ''): string {
const prevMonth = Number.parseInt(previousDate.split('-')[1]); const prevMonth = Number.parseInt(previousDate.split('-')[1]);
// 如果月份从大变小例如12月->2月或7月->8月说明跨年了 // 如果月份从大变小例如12月->2月或7月->8月说明跨年了
if (month < prevMonth) { year = month < prevMonth ? prevYear + 1 : prevYear;
year = prevYear + 1;
} else {
year = prevYear;
}
} else if (month >= 8) { } else if (month >= 8) {
// 第一条记录8-12月是2024年 // 第一条记录8-12月是2024年
year = 2024; year = 2024;
@@ -85,11 +81,7 @@ function parseDate(dateStr: string, previousDate: string = ''): string {
const prevYear = Number.parseInt(previousDate.split('-')[0]); const prevYear = Number.parseInt(previousDate.split('-')[0]);
const prevMonth = Number.parseInt(previousDate.split('-')[1]); const prevMonth = Number.parseInt(previousDate.split('-')[1]);
if (month < prevMonth) { year = month < prevMonth ? prevYear + 1 : prevYear;
year = prevYear + 1;
} else {
year = prevYear;
}
} else if (month >= 8) { } else if (month >= 8) {
year = 2024; year = 2024;
} else { } else {
@@ -109,12 +101,12 @@ function parseAmount(amountStr: string): number {
const cleaned = amountStr.trim(); const cleaned = amountStr.trim();
// 如果包含乘号(*或×或x先处理乘法 // 如果包含乘号(*或×或x先处理乘法
if (cleaned.match(/[*×x]/)) { if (/[*×x]/.test(cleaned)) {
// 提取乘法表达式,如 "200*3=600" 或 "200*3" // 提取乘法表达式,如 "200*3=600" 或 "200*3"
const mulMatch = cleaned.match(/(\d+(?:\.\d+)?)\s*[*×x]\s*(\d+(?:\.\d+)?)/); const mulMatch = cleaned.match(/(\d+(?:\.\d+)?)\s*[*×x]\s*(\d+(?:\.\d+)?)/);
if (mulMatch) { if (mulMatch) {
const num1 = parseFloat(mulMatch[1]); const num1 = Number.parseFloat(mulMatch[1]);
const num2 = parseFloat(mulMatch[2]); const num2 = Number.parseFloat(mulMatch[2]);
if (!isNaN(num1) && !isNaN(num2)) { if (!isNaN(num1) && !isNaN(num2)) {
return num1 * num2; return num1 * num2;
} }
@@ -126,7 +118,7 @@ function parseAmount(amountStr: string): number {
const parts = cleaned.split('+'); const parts = cleaned.split('+');
let sum = 0; let sum = 0;
for (const part of parts) { for (const part of parts) {
const num = parseFloat(part.replace(/[^\d.]/g, '')); const num = Number.parseFloat(part.replaceAll(/[^\d.]/g, ''));
if (!isNaN(num)) { if (!isNaN(num)) {
sum += 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 // 根据分类名称获取分类ID
function getCategoryIdByName(categoryName: string): number { function getCategoryIdByName(categoryName: string): number {
const categoryMap: Record<string, number> = { const categoryMap: Record<string, number> = {
'工资': 5, 工资: 5,
'佣金/返佣': 6, '佣金/返佣': 6,
'分红': 7, 分红: 7,
'服务器/技术': 8, '服务器/技术': 8,
'广告推广': 9, 广告推广: 9,
'软件/工具': 10, '软件/工具': 10,
'固定资产': 11, 固定资产: 11,
'退款': 12, 退款: 12,
'借款/转账': 13, '借款/转账': 13,
'其他支出': 14, 其他支出: 14,
}; };
return categoryMap[categoryName] || 2; // 默认未分类支出 return categoryMap[categoryName] || 2; // 默认未分类支出
@@ -158,7 +150,7 @@ function getCategoryIdByName(categoryName: string): number {
// 批量导入 // 批量导入
async function importTransactions() { async function importTransactions() {
const content = fs.readFileSync(CSV_FILE, 'utf-8'); const content = fs.readFileSync(CSV_FILE, 'utf8');
const rows = parseCSV(content); const rows = parseCSV(content);
console.log(`共解析到 ${rows.length} 条记录`); console.log(`共解析到 ${rows.length} 条记录`);
@@ -202,14 +194,16 @@ async function importTransactions() {
if (response.ok) { if (response.ok) {
imported++; imported++;
console.log(`✓ 导入成功 [${imported}/${rows.length}]: ${row.project} - $${amount}`); console.log(
`✓ 导入成功 [${imported}/${rows.length}]: ${row.project} - $${amount}`,
);
} else { } else {
failed++; failed++;
console.error(`✗ 导入失败: ${row.project}`, await response.text()); console.error(`✗ 导入失败: ${row.project}`, await response.text());
} }
// 避免请求过快 // 避免请求过快
await new Promise(resolve => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
} catch (error) { } catch (error) {
failed++; failed++;
console.error(`✗ 处理失败: ${row.project}`, error); console.error(`✗ 处理失败: ${row.project}`, error);

View File

@@ -13,9 +13,9 @@ export namespace FinanceApi {
// 分类 // 分类
export interface Category { export interface Category {
id: number; id: number;
userId?: number | null; userId?: null | number;
name: string; name: string;
type: 'income' | 'expense'; type: 'expense' | 'income';
icon: string; icon: string;
color: string; color: string;
sortOrder?: number; sortOrder?: number;
@@ -28,7 +28,14 @@ export namespace FinanceApi {
id: number; id: number;
userId?: number; userId?: number;
name: string; name: string;
type: 'cash' | 'bank' | 'alipay' | 'wechat' | 'virtual_wallet' | 'investment' | 'credit_card'; type:
| 'alipay'
| 'bank'
| 'cash'
| 'credit_card'
| 'investment'
| 'virtual_wallet'
| 'wechat';
currency: string; currency: string;
balance?: number; balance?: number;
icon?: string; icon?: string;
@@ -43,20 +50,20 @@ export namespace FinanceApi {
toCurrency: string; toCurrency: string;
rate: number; rate: number;
date: string; date: string;
source: 'manual' | 'api' | 'system'; source: 'api' | 'manual' | 'system';
} }
// 交易 // 交易
export interface Transaction { export interface Transaction {
id: number; id: number;
userId: number; userId: number;
type: 'income' | 'expense' | 'transfer'; type: 'expense' | 'income' | 'transfer';
amount: number; amount: number;
currency: string; currency: string;
exchangeRateToBase: number; exchangeRateToBase: number;
amountInBase: number; amountInBase: number;
categoryId?: number | null; categoryId?: null | number;
accountId?: number | null; accountId?: null | number;
transactionDate: string; transactionDate: string;
description: string; description: string;
project?: string; project?: string;
@@ -68,7 +75,7 @@ export namespace FinanceApi {
// 创建交易的参数 // 创建交易的参数
export interface CreateTransactionParams { export interface CreateTransactionParams {
type: 'income' | 'expense' | 'transfer'; type: 'expense' | 'income' | 'transfer';
amount: number; amount: number;
currency: string; currency: string;
categoryId?: number; categoryId?: number;
@@ -92,7 +99,7 @@ export namespace FinanceApi {
remaining: number; remaining: number;
percentage: number; percentage: number;
currency: string; currency: string;
period: 'monthly' | 'weekly' | 'quarterly' | 'yearly'; period: 'monthly' | 'quarterly' | 'weekly' | 'yearly';
alertThreshold: number; alertThreshold: number;
description?: string; description?: string;
autoRenew: boolean; autoRenew: boolean;
@@ -114,7 +121,7 @@ export namespace FinanceApi {
remaining?: number; remaining?: number;
percentage?: number; percentage?: number;
currency: string; currency: string;
period: 'monthly' | 'weekly' | 'quarterly' | 'yearly'; period: 'monthly' | 'quarterly' | 'weekly' | 'yearly';
alertThreshold: number; alertThreshold: number;
description?: string; description?: string;
autoRenew: boolean; 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 }); return requestClient.get<Category[]>('/finance/categories', { params });
} }
@@ -141,10 +150,10 @@ export namespace FinanceApi {
* 创建分类 * 创建分类
*/ */
export async function createCategory(data: { export async function createCategory(data: {
name: string;
type: 'income' | 'expense';
icon?: string;
color?: string; color?: string;
icon?: string;
name: string;
type: 'expense' | 'income';
}) { }) {
return requestClient.post<Category | null>('/finance/categories', data); return requestClient.post<Category | null>('/finance/categories', data);
} }
@@ -155,13 +164,16 @@ export namespace FinanceApi {
export async function updateCategory( export async function updateCategory(
id: number, id: number,
data: { data: {
name?: string;
icon?: string;
color?: string; color?: string;
icon?: string;
name?: string;
sortOrder?: number; 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?: { export async function getExchangeRates(params?: {
date?: string;
from?: string; from?: string;
to?: string; to?: string;
date?: string;
}) { }) {
return requestClient.get<ExchangeRate[]>('/finance/exchange-rates', { return requestClient.get<ExchangeRate[]>('/finance/exchange-rates', {
params, params,
@@ -197,7 +209,7 @@ export namespace FinanceApi {
* 获取交易列表 * 获取交易列表
*/ */
export async function getTransactions(params?: { export async function getTransactions(params?: {
type?: 'income' | 'expense' | 'transfer'; type?: 'expense' | 'income' | 'transfer';
}) { }) {
return requestClient.get<Transaction[]>('/finance/transactions', { return requestClient.get<Transaction[]>('/finance/transactions', {
params, params,

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, watch } from 'vue'; import { computed, onMounted } from 'vue';
import { useAntdDesignTokens } from '@vben/hooks'; import { useAntdDesignTokens } from '@vben/hooks';
import { preferences, usePreferences } from '@vben/preferences'; import { preferences, usePreferences } from '@vben/preferences';
@@ -34,7 +34,7 @@ const flattenFinWiseProMenu = () => {
const submenus = document.querySelectorAll('.vben-sub-menu'); const submenus = document.querySelectorAll('.vben-sub-menu');
let finwiseMenu: Element | null = null; let finwiseMenu: Element | null = null;
submenus.forEach(menu => { submenus.forEach((menu) => {
const titleEl = menu.querySelector('.vben-sub-menu-content__title'); const titleEl = menu.querySelector('.vben-sub-menu-content__title');
if (titleEl?.textContent?.includes('FinWise Pro')) { if (titleEl?.textContent?.includes('FinWise Pro')) {
finwiseMenu = menu; finwiseMenu = menu;
@@ -49,16 +49,17 @@ const flattenFinWiseProMenu = () => {
if (!childrenUL || !parentMenu) return; if (!childrenUL || !parentMenu) return;
// Check if already processed // 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 // Move all children to the parent menu
const children = Array.from(childrenUL.children); const children = [...childrenUL.children];
children.forEach(child => { children.forEach((child) => {
parentMenu.insertBefore(child, finwiseMenu); finwiseMenu.before(child);
}); });
// Mark for hiding via CSS and hide directly // 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'; (finwiseMenu as HTMLElement).style.display = 'none';
}; };
@@ -66,7 +67,12 @@ const flattenFinWiseProMenu = () => {
onMounted(() => { onMounted(() => {
// 强制修复sidebar设置防止被用户UI操作覆盖 // 强制修复sidebar设置防止被用户UI操作覆盖
const fixSidebarPreferences = () => { 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) { if (prefsKey) {
try { try {
const prefs = JSON.parse(localStorage.getItem(prefsKey) || '{}'); const prefs = JSON.parse(localStorage.getItem(prefsKey) || '{}');
@@ -78,8 +84,8 @@ onMounted(() => {
prefs.value.sidebar.collapsedWidth = 230; prefs.value.sidebar.collapsedWidth = 230;
localStorage.setItem(prefsKey, JSON.stringify(prefs)); localStorage.setItem(prefsKey, JSON.stringify(prefs));
} }
} catch(e) { } catch (error) {
console.error('Failed to fix sidebar preferences:', e); console.error('Failed to fix sidebar preferences:', error);
} }
} }
}; };
@@ -89,7 +95,7 @@ onMounted(() => {
// Run multiple times with increasing delays to catch menu rendering // Run multiple times with increasing delays to catch menu rendering
const delays = [100, 300, 500, 1000, 1500, 2000, 2500, 3000, 4000, 5000]; const delays = [100, 300, 500, 1000, 1500, 2000, 2500, 3000, 4000, 5000];
delays.forEach(delay => { delays.forEach((delay) => {
setTimeout(flattenFinWiseProMenu, delay); setTimeout(flattenFinWiseProMenu, delay);
}); });
@@ -104,7 +110,7 @@ onMounted(() => {
if (body) { if (body) {
observer.observe(body, { observer.observe(body, {
childList: true, childList: true,
subtree: true subtree: true,
}); });
} }
}, 100); }, 100);
@@ -112,7 +118,9 @@ onMounted(() => {
// 防止侧边栏自动收起 // 防止侧边栏自动收起
setTimeout(() => { setTimeout(() => {
const preventSidebarCollapse = () => { const preventSidebarCollapse = () => {
const sidebar = document.querySelector('[class*="sidebar"]') || document.querySelector('aside'); const sidebar =
document.querySelector('[class*="sidebar"]') ||
document.querySelector('aside');
if (!sidebar) return; if (!sidebar) return;
@@ -120,7 +128,7 @@ onMounted(() => {
const sidebarObserver = new MutationObserver(() => { const sidebarObserver = new MutationObserver(() => {
const currentWidth = window.getComputedStyle(sidebar).width; const currentWidth = window.getComputedStyle(sidebar).width;
// 如果宽度小于200px说明可能被收起了强制恢复 // 如果宽度小于200px说明可能被收起了强制恢复
if (parseInt(currentWidth) < 200) { if (Number.parseInt(currentWidth) < 200) {
(sidebar as HTMLElement).style.width = '230px'; (sidebar as HTMLElement).style.width = '230px';
} }
}); });
@@ -128,7 +136,7 @@ onMounted(() => {
// 开始观察 // 开始观察
sidebarObserver.observe(sidebar, { sidebarObserver.observe(sidebar, {
attributes: true, attributes: true,
attributeFilter: ['class', 'style'] attributeFilter: ['class', 'style'],
}); });
// 强制设置初始宽度 // 强制设置初始宽度

View File

@@ -1,5 +1,7 @@
/* Hide FinWise Pro parent menu and move children */ /* 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; display: none !important;
} }
@@ -9,6 +11,6 @@
} }
/* Mark submenu for hiding */ /* Mark submenu for hiding */
.vben-sub-menu[data-hide-finwise="true"] { .vben-sub-menu[data-hide-finwise='true'] {
display: none !important; display: none !important;
} }

View File

@@ -2,6 +2,7 @@ import { initPreferences } from '@vben/preferences';
import { unmountGlobalLoading } from '@vben/utils'; import { unmountGlobalLoading } from '@vben/utils';
import { overridesPreferences } from './preferences'; import { overridesPreferences } from './preferences';
import './custom.css'; import './custom.css';
/** /**
@@ -36,7 +37,7 @@ function flattenFinWiseProMenu() {
const submenus = document.querySelectorAll('.vben-sub-menu'); const submenus = document.querySelectorAll('.vben-sub-menu');
let finwiseMenu: Element | null = null; let finwiseMenu: Element | null = null;
submenus.forEach(menu => { submenus.forEach((menu) => {
const titleEl = menu.querySelector('.vben-sub-menu-content__title'); const titleEl = menu.querySelector('.vben-sub-menu-content__title');
if (titleEl?.textContent?.includes('FinWise Pro')) { if (titleEl?.textContent?.includes('FinWise Pro')) {
finwiseMenu = menu; finwiseMenu = menu;
@@ -51,16 +52,17 @@ function flattenFinWiseProMenu() {
if (!childrenUL || !parentMenu) return; if (!childrenUL || !parentMenu) return;
// Check if already processed // 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 // Move all children to the parent menu
const children = Array.from(childrenUL.children); const children = [...childrenUL.children];
children.forEach(child => { children.forEach((child) => {
parentMenu.insertBefore(child, finwiseMenu); finwiseMenu.before(child);
}); });
// Mark for hiding via CSS and hide directly // 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'; (finwiseMenu as HTMLElement).style.display = 'none';
} }
@@ -91,7 +93,7 @@ setTimeout(() => {
if (body) { if (body) {
observer.observe(body, { observer.observe(body, {
childList: true, childList: true,
subtree: true subtree: true,
}); });
} }
}, 500); }, 500);

View File

@@ -40,7 +40,7 @@ router.afterEach(() => {
const submenus = document.querySelectorAll('.vben-sub-menu'); const submenus = document.querySelectorAll('.vben-sub-menu');
let finwiseMenu: Element | null = null; let finwiseMenu: Element | null = null;
submenus.forEach(menu => { submenus.forEach((menu) => {
const titleEl = menu.querySelector('.vben-sub-menu-content__title'); const titleEl = menu.querySelector('.vben-sub-menu-content__title');
if (titleEl?.textContent?.includes('FinWise Pro')) { if (titleEl?.textContent?.includes('FinWise Pro')) {
finwiseMenu = menu; finwiseMenu = menu;
@@ -55,16 +55,19 @@ router.afterEach(() => {
if (!childrenUL || !parentMenu) return; if (!childrenUL || !parentMenu) return;
// Check if already processed // 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 // Move all children to the parent menu
const children = Array.from(childrenUL.children); const children = [...childrenUL.children];
children.forEach(child => { children.forEach((child) => {
parentMenu.insertBefore(child, finwiseMenu); finwiseMenu.before(child);
}); });
// Mark for hiding via CSS and hide directly // 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'; (finwiseMenu as HTMLElement).style.display = 'none';
}; };

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia';
import { ref } from 'vue'; import { ref } from 'vue';
import { defineStore } from 'pinia';
import { FinanceApi } from '#/api/core/finance'; import { FinanceApi } from '#/api/core/finance';
export const useFinanceStore = defineStore('finance', () => { export const useFinanceStore = defineStore('finance', () => {
@@ -50,10 +51,10 @@ export const useFinanceStore = defineStore('finance', () => {
// 创建分类 // 创建分类
async function createCategory(data: { async function createCategory(data: {
name: string;
type: 'income' | 'expense';
icon?: string;
color?: string; color?: string;
icon?: string;
name: string;
type: 'expense' | 'income';
}) { }) {
const category = await FinanceApi.createCategory(data); const category = await FinanceApi.createCategory(data);
if (!category) { if (!category) {
@@ -71,9 +72,9 @@ export const useFinanceStore = defineStore('finance', () => {
async function updateCategory( async function updateCategory(
id: number, id: number,
data: { data: {
name?: string;
icon?: string;
color?: string; color?: string;
icon?: string;
name?: string;
sortOrder?: number; sortOrder?: number;
}, },
) { ) {
@@ -97,13 +98,13 @@ export const useFinanceStore = defineStore('finance', () => {
await FinanceApi.deleteCategory(id); await FinanceApi.deleteCategory(id);
// 从本地列表中移除 // 从本地列表中移除
let index = incomeCategories.value.findIndex((c) => c.id === id); let index = incomeCategories.value.findIndex((c) => c.id === id);
if (index !== -1) { if (index === -1) {
incomeCategories.value.splice(index, 1);
} else {
index = expenseCategories.value.findIndex((c) => c.id === id); index = expenseCategories.value.findIndex((c) => c.id === id);
if (index !== -1) { if (index !== -1) {
expenseCategories.value.splice(index, 1); expenseCategories.value.splice(index, 1);
} }
} else {
incomeCategories.value.splice(index, 1);
} }
} }

View File

@@ -2,9 +2,9 @@
import type { VbenFormSchema } from '@vben/common-ui'; import type { VbenFormSchema } from '@vben/common-ui';
import type { BasicOption } from '@vben/types'; 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 { $t } from '@vben/locales';
import { useAuthStore } from '#/store'; import { useAuthStore } from '#/store';

View File

@@ -11,13 +11,17 @@ function handleBackHome() {
</script> </script>
<template> <template>
<div class="flex h-screen w-screen flex-col items-center justify-center bg-background"> <div
<h1 class="mb-4 text-6xl font-bold text-foreground">404</h1> class="bg-background flex h-screen w-screen flex-col items-center justify-center"
<h2 class="mb-2 text-2xl font-semibold text-foreground">哎呀未找到页面</h2> >
<p class="mb-8 text-muted-foreground">抱歉我们无法找到您要找的页面</p> <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 <button
@click="handleBackHome" @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> </button>

View File

@@ -21,7 +21,20 @@ import { preferences } from '@vben/preferences';
import { useUserStore } from '@vben/stores'; import { useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils'; 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 dayjs from 'dayjs';
import { useFinanceStore } from '#/store/finance'; import { useFinanceStore } from '#/store/finance';
@@ -38,7 +51,7 @@ onMounted(async () => {
// 快速记账弹窗 // 快速记账弹窗
const quickAddVisible = ref(false); const quickAddVisible = ref(false);
const transactionType = ref<'income' | 'expense'>('expense'); const transactionType = ref<'expense' | 'income'>('expense');
const formRef = ref(); const formRef = ref();
const formState = ref({ const formState = ref({
currency: 'CNY', // 默认人民币 currency: 'CNY', // 默认人民币
@@ -57,7 +70,9 @@ const formState = ref({
const useQuantityMode = ref(false); 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({ const touchedFields = ref({
@@ -67,11 +82,14 @@ const touchedFields = ref({
}); });
// 监听单价和数量变化,自动计算总金额 // 监听单价和数量变化,自动计算总金额
watch([() => formState.value.unitPrice, () => formState.value.quantity], ([unitPrice, quantity]) => { watch(
if (useQuantityMode.value && unitPrice && quantity) { [() => formState.value.unitPrice, () => formState.value.quantity],
formState.value.amount = unitPrice * quantity; ([unitPrice, quantity]) => {
} if (useQuantityMode.value && unitPrice && quantity) {
}); formState.value.amount = unitPrice * quantity;
}
},
);
// 切换计算模式 // 切换计算模式
const toggleQuantityMode = (enabled: boolean) => { const toggleQuantityMode = (enabled: boolean) => {
@@ -79,7 +97,8 @@ const toggleQuantityMode = (enabled: boolean) => {
if (enabled) { if (enabled) {
// 如果当前有金额,反推单价 // 如果当前有金额,反推单价
if (formState.value.amount && formState.value.quantity) { 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 { } else {
// 关闭模式时清空单价和数量 // 关闭模式时清空单价和数量
@@ -107,30 +126,38 @@ const currentCurrencySymbol = computed(() => {
}); });
// 监听货币变化,重置账户选择 // 监听货币变化,重置账户选择
watch(() => formState.value.currency, () => { watch(
formState.value.account = undefined; () => formState.value.currency,
touchedFields.value.account = true; // 标记账户字段为已触摸 () => {
}); formState.value.account = undefined;
touchedFields.value.account = true; // 标记账户字段为已触摸
},
);
// 监听账户变化保存到localStorage // 监听账户变化保存到localStorage
watch(() => formState.value.account, (newAccountId) => { watch(
if (newAccountId && transactionType.value) { () => formState.value.account,
const storageKey = transactionType.value === 'income' (newAccountId) => {
? 'lastWorkspaceIncomeAccountId' if (newAccountId && transactionType.value) {
: 'lastWorkspaceExpenseAccountId'; const storageKey =
localStorage.setItem(storageKey, String(newAccountId)); transactionType.value === 'income'
} ? 'lastWorkspaceIncomeAccountId'
}); : 'lastWorkspaceExpenseAccountId';
localStorage.setItem(storageKey, String(newAccountId));
}
},
);
// 打开快速记账弹窗 // 打开快速记账弹窗
const openQuickAdd = (type: 'income' | 'expense') => { const openQuickAdd = (type: 'expense' | 'income') => {
transactionType.value = type; transactionType.value = type;
quickAddVisible.value = true; quickAddVisible.value = true;
// 读取上次选择的账户 // 读取上次选择的账户
const storageKey = type === 'income' const storageKey =
? 'lastWorkspaceIncomeAccountId' type === 'income'
: 'lastWorkspaceExpenseAccountId'; ? 'lastWorkspaceIncomeAccountId'
: 'lastWorkspaceExpenseAccountId';
const lastAccountId = localStorage.getItem(storageKey); const lastAccountId = localStorage.getItem(storageKey);
const accountId = lastAccountId ? Number(lastAccountId) : undefined; 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; selectedDateType.value = type;
switch (type) { switch (type) {
case 'today': case 'month': {
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':
formState.value.date = dayjs().startOf('month'); formState.value.date = dayjs().startOf('month');
break; 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) => { watch(
if (!newDate) return; () => formState.value.date,
(newDate) => {
if (!newDate) return;
const today = dayjs(); const today = dayjs();
const yesterday = dayjs().subtract(1, 'day'); const yesterday = dayjs().subtract(1, 'day');
const weekStart = dayjs().startOf('week'); const weekStart = dayjs().startOf('week');
const monthStart = dayjs().startOf('month'); const monthStart = dayjs().startOf('month');
if (newDate.isSame(today, 'day')) { if (newDate.isSame(today, 'day')) {
selectedDateType.value = 'today'; selectedDateType.value = 'today';
} else if (newDate.isSame(yesterday, 'day')) { } else if (newDate.isSame(yesterday, 'day')) {
selectedDateType.value = 'yesterday'; selectedDateType.value = 'yesterday';
} else if (newDate.isSame(weekStart, 'day')) { } else if (newDate.isSame(weekStart, 'day')) {
selectedDateType.value = 'week'; selectedDateType.value = 'week';
} else if (newDate.isSame(monthStart, 'day')) { } else if (newDate.isSame(monthStart, 'day')) {
selectedDateType.value = 'month'; selectedDateType.value = 'month';
} else { } else {
selectedDateType.value = 'custom'; selectedDateType.value = 'custom';
} }
}); },
);
// 获取日期类型对应的颜色 // 获取日期类型对应的颜色
const getDateTypeColor = (type: string) => { const getDateTypeColor = (type: string) => {
const colors = { const colors = {
today: '#52c41a', // 绿色 - 今天 today: '#52c41a', // 绿色 - 今天
yesterday: '#1890ff', // 蓝色 - 昨天 yesterday: '#1890ff', // 蓝色 - 昨天
week: '#722ed1', // 紫色 - 本周 week: '#722ed1', // 紫色 - 本周
month: '#fa8c16', // 橙色 - 本月 month: '#fa8c16', // 橙色 - 本月
custom: '#8c8c8c', // 灰色 - 自定义 custom: '#8c8c8c', // 灰色 - 自定义
}; };
return colors[type] || colors.custom; return colors[type] || colors.custom;
}; };
@@ -216,7 +250,9 @@ const getDateTypeColor = (type: string) => {
const fieldErrors = computed(() => ({ const fieldErrors = computed(() => ({
category: touchedFields.value.category && !formState.value.category, category: touchedFields.value.category && !formState.value.category,
account: touchedFields.value.account && !formState.value.account, 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); console.log('交易创建成功:', transaction);
message.success(`${transactionType.value === 'income' ? '收入' : '支出'}记录成功!`); message.success(
`${transactionType.value === 'income' ? '收入' : '支出'}记录成功!`,
);
quickAddVisible.value = false; quickAddVisible.value = false;
// 重置表单 // 重置表单
@@ -385,31 +423,31 @@ const todoItems = ref<WorkbenchTodoItem[]>([
{ {
completed: false, completed: false,
content: `记录本月的水电费、房租等固定支出`, content: `记录本月的水电费、房租等固定支出`,
date: new Date().toLocaleDateString() + ' 18:00:00', date: `${new Date().toLocaleDateString()} 18:00:00`,
title: '录入本月固定支出', title: '录入本月固定支出',
}, },
{ {
completed: false, completed: false,
content: `查看并调整各类别的预算设置,确保支出在可控范围内`, content: `查看并调整各类别的预算设置,确保支出在可控范围内`,
date: new Date().toLocaleDateString() + ' 20:00:00', date: `${new Date().toLocaleDateString()} 20:00:00`,
title: '检查月度预算执行情况', title: '检查月度预算执行情况',
}, },
{ {
completed: true, completed: true,
content: `完成本周的收入记录,包括工资和其他收入来源`, content: `完成本周的收入记录,包括工资和其他收入来源`,
date: new Date().toLocaleDateString() + ' 10:00:00', date: `${new Date().toLocaleDateString()} 10:00:00`,
title: '记录本周收入', title: '记录本周收入',
}, },
{ {
completed: false, completed: false,
content: `核对银行账户余额,确保系统数据与实际一致`, content: `核对银行账户余额,确保系统数据与实际一致`,
date: new Date().toLocaleDateString() + ' 15:00:00', date: `${new Date().toLocaleDateString()} 15:00:00`,
title: '对账核对', title: '对账核对',
}, },
{ {
completed: false, completed: false,
content: `分析上月的支出报表,找出可以节省开支的地方`, content: `分析上月的支出报表,找出可以节省开支的地方`,
date: new Date().toLocaleDateString() + ' 16:00:00', date: `${new Date().toLocaleDateString()} 16:00:00`,
title: '生成月度财务报表', title: '生成月度财务报表',
}, },
]); ]);
@@ -521,18 +559,28 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<div class="mt-5 flex flex-col lg:flex-row"> <div class="mt-5 flex flex-col lg:flex-row">
<div class="mr-4 w-full lg:w-3/5"> <div class="mr-4 w-full lg:w-3/5">
<WorkbenchProject :items="projectItems" title="财务功能快捷入口" @click="navTo" /> <WorkbenchProject
<WorkbenchTrends :items="trendItems" class="mt-5" title="最近财务活动" /> :items="projectItems"
title="财务功能快捷入口"
@click="navTo"
/>
<WorkbenchTrends
:items="trendItems"
class="mt-5"
title="最近财务活动"
/>
</div> </div>
<div class="w-full lg:w-2/5"> <div class="w-full lg:w-2/5">
<WorkbenchQuickNav <WorkbenchQuickNav
:items="quickNavItems" :items="quickNavItems"
class="mt-5 lg:mt-0" class="mt-5 lg:mt-0"
title="快捷操作" title="快捷操作"
@click="(item) => { @click="
console.log('WorkbenchQuickNav click事件触发:', item); (item) => {
navTo(item); console.log('WorkbenchQuickNav click事件触发:', item);
}" navTo(item);
}
"
/> />
<WorkbenchTodo :items="todoItems" class="mt-5" title="财务待办事项" /> <WorkbenchTodo :items="todoItems" class="mt-5" title="财务待办事项" />
<AnalysisChartCard class="mt-5" title="本月收支概览"> <AnalysisChartCard class="mt-5" title="本月收支概览">
@@ -547,15 +595,18 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
:title="transactionType === 'income' ? '💰 添加收入' : '💸 添加支出'" :title="transactionType === 'income' ? '💰 添加收入' : '💸 添加支出'"
:width="900" :width="900"
@ok="handleQuickAdd" @ok="handleQuickAdd"
@cancel="() => { quickAddVisible = false; }" @cancel="
@update:open="(val) => { quickAddVisible = val; }" () => {
quickAddVisible = false;
}
"
@update:open="
(val) => {
quickAddVisible = val;
}
"
> >
<Form <Form ref="formRef" :model="formState" layout="vertical" class="mt-4">
ref="formRef"
:model="formState"
layout="vertical"
class="mt-4"
>
<Row :gutter="16"> <Row :gutter="16">
<!-- 分类 --> <!-- 分类 -->
<Col :span="14"> <Col :span="14">
@@ -567,7 +618,15 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
:help="fieldErrors.category ? '⚠️ 请选择一个分类' : ''" :help="fieldErrors.category ? '⚠️ 请选择一个分类' : ''"
> >
<div <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 <Radio.Group
v-model:value="formState.category" v-model:value="formState.category"
@@ -590,10 +649,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<!-- 项目名称 --> <!-- 项目名称 -->
<Col :span="10"> <Col :span="10">
<Form.Item <Form.Item label="项目名称" name="description">
label="项目名称"
name="description"
>
<Input.TextArea <Input.TextArea
v-model:value="formState.description" v-model:value="formState.description"
placeholder="请输入项目名称..." placeholder="请输入项目名称..."
@@ -605,11 +661,13 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
</Row> </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"> <Row :gutter="16">
<Col :span="12"> <Col :span="12">
<div class="mb-4"> <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 <Radio.Group
v-model:value="formState.currency" v-model:value="formState.currency"
size="large" size="large"
@@ -629,7 +687,10 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<Col :span="12"> <Col :span="12">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<label class="text-sm font-medium">按数量×单价计算</label> <label class="text-sm font-medium">按数量×单价计算</label>
<Switch v-model:checked="useQuantityMode" @change="toggleQuantityMode" /> <Switch
v-model:checked="useQuantityMode"
@change="toggleQuantityMode"
/>
</div> </div>
</Col> </Col>
</Row> </Row>
@@ -637,7 +698,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<!-- 数量×单价模式 --> <!-- 数量×单价模式 -->
<Row v-if="useQuantityMode" :gutter="16" class="mb-4"> <Row v-if="useQuantityMode" :gutter="16" class="mb-4">
<Col :span="8"> <Col :span="8">
<label class="block text-sm font-medium mb-2">数量</label> <label class="mb-2 block text-sm font-medium">数量</label>
<InputNumber <InputNumber
v-model:value="formState.quantity" v-model:value="formState.quantity"
:min="0.01" :min="0.01"
@@ -648,7 +709,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
/> />
</Col> </Col>
<Col :span="8"> <Col :span="8">
<label class="block text-sm font-medium mb-2">单价</label> <label class="mb-2 block text-sm font-medium">单价</label>
<InputNumber <InputNumber
v-model:value="formState.unitPrice" v-model:value="formState.unitPrice"
:min="0" :min="0"
@@ -661,12 +722,24 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
</InputNumber> </InputNumber>
</Col> </Col>
<Col :span="8"> <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 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> </label>
<div <div
:style="fieldErrors.amount ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '2px' } : {}" :style="
fieldErrors.amount
? {
border: '2px solid #ff4d4f',
borderRadius: '6px',
padding: '2px',
}
: {}
"
> >
<InputNumber <InputNumber
v-model:value="formState.amount" v-model:value="formState.amount"
@@ -687,12 +760,24 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<!-- 直接输入金额模式 --> <!-- 直接输入金额模式 -->
<Row v-else :gutter="16" class="mb-4"> <Row v-else :gutter="16" class="mb-4">
<Col :span="24"> <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 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> </label>
<div <div
:style="fieldErrors.amount ? { border: '2px solid #ff4d4f', borderRadius: '6px', padding: '2px' } : {}" :style="
fieldErrors.amount
? {
border: '2px solid #ff4d4f',
borderRadius: '6px',
padding: '2px',
}
: {}
"
> >
<InputNumber <InputNumber
v-model:value="formState.amount" v-model:value="formState.amount"
@@ -712,7 +797,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<!-- 重量可选 --> <!-- 重量可选 -->
<Row :gutter="16" class="mb-4"> <Row :gutter="16" class="mb-4">
<Col :span="16"> <Col :span="16">
<label class="block text-sm font-medium mb-2">重量可选</label> <label class="mb-2 block text-sm font-medium">重量可选</label>
<InputNumber <InputNumber
v-model:value="formState.weight" v-model:value="formState.weight"
:min="0" :min="0"
@@ -722,7 +807,7 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
/> />
</Col> </Col>
<Col :span="8"> <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 v-model:value="formState.weightUnit" style="width: 100%">
<Select.Option value="kg">千克(kg)</Select.Option> <Select.Option value="kg">千克(kg)</Select.Option>
<Select.Option value="g">(g)</Select.Option> <Select.Option value="g">(g)</Select.Option>
@@ -733,12 +818,23 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
</Row> </Row>
<div> <div>
<label class="block text-sm font-medium mb-2"> <label class="mb-2 block text-sm font-medium">
{{ transactionType === 'income' ? '收入账户' : '支出账户' }} <span class="text-red-500">*</span> {{ transactionType === 'income' ? '收入账户' : '支出账户' }}
<span v-if="fieldErrors.account" class="text-red-500 text-xs ml-2"> 请选择账户</span> <span class="text-red-500">*</span>
<span v-if="fieldErrors.account" class="ml-2 text-xs text-red-500"
> 请选择账户</span
>
</label> </label>
<div <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 <Radio.Group
v-model:value="formState.account" v-model:value="formState.account"
@@ -766,15 +862,35 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<Button <Button
:type="selectedDateType === 'today' ? 'primary' : 'default'" :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')" @click="setDate('today')"
block block
> >
今天 今天
</Button> </Button>
<Button <Button
:type="selectedDateType === 'yesterday' ? 'primary' : 'default'" :type="
:style="{ backgroundColor: selectedDateType === 'yesterday' ? getDateTypeColor('yesterday') : undefined, borderColor: selectedDateType === 'yesterday' ? getDateTypeColor('yesterday') : undefined }" selectedDateType === 'yesterday' ? 'primary' : 'default'
"
:style="{
backgroundColor:
selectedDateType === 'yesterday'
? getDateTypeColor('yesterday')
: undefined,
borderColor:
selectedDateType === 'yesterday'
? getDateTypeColor('yesterday')
: undefined,
}"
@click="setDate('yesterday')" @click="setDate('yesterday')"
block block
> >
@@ -782,7 +898,16 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
</Button> </Button>
<Button <Button
:type="selectedDateType === 'week' ? 'primary' : 'default'" :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')" @click="setDate('week')"
block block
> >
@@ -790,7 +915,16 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
</Button> </Button>
<Button <Button
:type="selectedDateType === 'month' ? 'primary' : 'default'" :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')" @click="setDate('month')"
block block
> >
@@ -800,16 +934,13 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
</Form.Item> </Form.Item>
</Col> </Col>
<Col :span="14"> <Col :span="14">
<Form.Item <Form.Item label="选择日期" name="date">
label="选择日期"
name="date"
>
<div <div
class="date-picker-wrapper" class="date-picker-wrapper"
:style="{ :style="{
border: `2px solid ${getDateTypeColor(selectedDateType)}`, border: `2px solid ${getDateTypeColor(selectedDateType)}`,
borderRadius: '6px', borderRadius: '6px',
padding: '4px' padding: '4px',
}" }"
> >
<DatePicker <DatePicker
@@ -846,10 +977,15 @@ function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
border-radius: 6px !important; border-radius: 6px !important;
} }
:deep(.category-radio-group .ant-radio-button-wrapper:not(:first-child)::before), :deep(
:deep(.currency-radio-group .ant-radio-button-wrapper:not(:first-child)::before), .category-radio-group .ant-radio-button-wrapper:not(:first-child)::before
:deep(.account-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; display: none;
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +1,373 @@
<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> <template>
<div class="p-6"> <div class="p-6">
<div class="mb-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> <p class="text-gray-600">设置和监控各类别的预算执行情况</p>
</div> </div>
<div v-if="budgets.length === 0" class="text-center py-12"> <div v-if="budgets.length === 0" class="py-12 text-center">
<div class="text-8xl mb-6">🎯</div> <div class="mb-6 text-8xl">🎯</div>
<h3 class="text-xl font-medium text-gray-800 mb-2">暂无预算设置</h3> <h3 class="mb-2 text-xl font-medium text-gray-800">暂无预算设置</h3>
<p class="text-gray-500 mb-6">设置预算帮助您更好地控制支出</p> <p class="mb-6 text-gray-500">设置预算帮助您更好地控制支出</p>
<Button type="primary" size="large" @click="openAddBudgetModal"> <Button type="primary" size="large" @click="openAddBudgetModal">
设置第一个预算 设置第一个预算
</Button> </Button>
</div> </div>
<div v-else> <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"> <Card class="text-center">
<div class="space-y-2"> <div class="space-y-2">
<div class="text-3xl">💰</div> <div class="text-3xl">💰</div>
<p class="text-sm text-gray-500">总预算</p> <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> </div>
</Card> </Card>
<Card class="text-center"> <Card class="text-center">
<div class="space-y-2"> <div class="space-y-2">
<div class="text-3xl">📊</div> <div class="text-3xl">📊</div>
<p class="text-sm text-gray-500">已使用</p> <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> </div>
</Card> </Card>
<Card class="text-center"> <Card class="text-center">
<div class="space-y-2"> <div class="space-y-2">
<div class="text-3xl">🎯</div> <div class="text-3xl">🎯</div>
<p class="text-sm text-gray-500">剩余预算</p> <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> </div>
</Card> </Card>
<Card class="text-center"> <Card class="text-center">
<div class="space-y-2"> <div class="space-y-2">
<div class="text-3xl"></div> <div class="text-3xl"></div>
<p class="text-sm text-gray-500">执行率</p> <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> </div>
</Card> </Card>
</div> </div>
<!-- 预算卡片列表 --> <!-- 预算卡片列表 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6"> <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 hover:shadow-lg transition-shadow"> <Card
v-for="budget in budgets"
:key="budget.id"
class="relative transition-shadow hover:shadow-lg"
>
<template #title> <template #title>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
@@ -60,9 +378,18 @@
<template #overlay> <template #overlay>
<Menu> <Menu>
<Menu.Item @click="editBudget(budget)"> 编辑</Menu.Item> <Menu.Item @click="editBudget(budget)"> 编辑</Menu.Item>
<Menu.Item @click="adjustBudget(budget)">📊 调整额度</Menu.Item> <Menu.Item @click="adjustBudget(budget)">
<Menu.Item @click="viewHistory(budget)">📈 历史记录</Menu.Item> 📊 调整额度
<Menu.Item @click="deleteBudget(budget)" class="text-red-600">🗑 删除</Menu.Item> </Menu.Item>
<Menu.Item @click="viewHistory(budget)">
📈 历史记录
</Menu.Item>
<Menu.Item
@click="deleteBudget(budget)"
class="text-red-600"
>
🗑 删除
</Menu.Item>
</Menu> </Menu>
</template> </template>
<Button type="text" size="small"></Button> <Button type="text" size="small"></Button>
@@ -73,22 +400,38 @@
<!-- 预算进度 --> <!-- 预算进度 -->
<div class="space-y-4"> <div class="space-y-4">
<div class="text-center"> <div class="text-center">
<p class="text-2xl font-bold" :class="getAmountColor(budget.percentage)"> <p
{{ formatCurrencyWithCode(budget.spent, budget.currency) }} / {{ formatCurrencyWithCode(budget.limit, budget.currency) }} class="text-2xl font-bold"
:class="getAmountColor(budget.percentage)"
>
{{ formatCurrencyWithCode(budget.spent, budget.currency) }} /
{{ formatCurrencyWithCode(budget.limit, budget.currency) }}
</p> </p>
<p class="text-sm text-gray-500">已用 / 预算</p> <p class="text-sm text-gray-500">已用 / 预算</p>
</div> </div>
<Progress <Progress
:percent="budget.percentage" :percent="budget.percentage"
:stroke-color="getProgressColor(budget.percentage)" :stroke-color="getProgressColor(budget.percentage)"
/> />
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span :class="budget.remaining >= 0 ? 'text-green-600' : 'text-red-600'"> <span
{{ budget.remaining >= 0 ? '剩余' : '超支' }}: {{ formatCurrencyWithCode(Math.abs(budget.remaining), budget.currency) }} :class="
budget.remaining >= 0 ? 'text-green-600' : 'text-red-600'
"
>
{{ budget.remaining >= 0 ? '剩余' : '超支' }}:
{{
formatCurrencyWithCode(
Math.abs(budget.remaining),
budget.currency,
)
}}
</span> </span>
<span class="text-gray-500">{{ budget.percentage.toFixed(1) }}%</span> <span class="text-gray-500"
>{{ budget.percentage.toFixed(1) }}%</span
>
</div> </div>
<!-- 预算状态标签 --> <!-- 预算状态标签 -->
@@ -102,25 +445,32 @@
<Tag v-else-if="budget.percentage > 75" color="blue"> <Tag v-else-if="budget.percentage > 75" color="blue">
使用正常 使用正常
</Tag> </Tag>
<Tag v-else color="green"> <Tag v-else color="green"> 控制良好 </Tag>
控制良好
</Tag>
</div> </div>
<!-- 月度趋势 --> <!-- 月度趋势 -->
<div v-if="budget.monthlyTrend" class="text-center"> <div v-if="budget.monthlyTrend" class="text-center">
<p class="text-xs text-gray-500">相比上月</p> <p class="text-xs text-gray-500">相比上月</p>
<p :class="budget.monthlyTrend >= 0 ? 'text-red-500' : 'text-green-500'" class="font-medium"> <p
{{ budget.monthlyTrend >= 0 ? '↗️' : '↘️' }} {{ Math.abs(budget.monthlyTrend).toFixed(1) }}% :class="
budget.monthlyTrend >= 0 ? 'text-red-500' : 'text-green-500'
"
class="font-medium"
>
{{ budget.monthlyTrend >= 0 ? '↗️' : '↘️' }}
{{ Math.abs(budget.monthlyTrend).toFixed(1) }}%
</p> </p>
</div> </div>
</div> </div>
</Card> </Card>
<!-- 添加预算卡片 --> <!-- 添加预算卡片 -->
<Card class="border-2 border-dashed border-gray-300 hover:border-blue-400 cursor-pointer transition-all" @click="openAddBudgetModal"> <Card
<div class="text-center py-12"> class="cursor-pointer border-2 border-dashed border-gray-300 transition-all hover:border-blue-400"
<div class="text-6xl mb-4"></div> @click="openAddBudgetModal"
>
<div class="py-12 text-center">
<div class="mb-4 text-6xl"></div>
<h3 class="font-medium text-gray-800">添加新预算</h3> <h3 class="font-medium text-gray-800">添加新预算</h3>
<p class="text-sm text-gray-500">为分类设置预算控制</p> <p class="text-sm text-gray-500">为分类设置预算控制</p>
</div> </div>
@@ -129,16 +479,21 @@
</div> </div>
<!-- 添加预算模态框 --> <!-- 添加预算模态框 -->
<Modal <Modal
v-model:open="showAddModal" v-model:open="showAddModal"
title=" 设置新预算" title=" 设置新预算"
@ok="submitBudget" @ok="submitBudget"
@cancel="cancelAdd" @cancel="cancelAdd"
width="500px" width="500px"
> >
<Form ref="formRef" :model="budgetForm" :rules="rules" layout="vertical"> <Form ref="formRef" :model="budgetForm" :rules="rules" layout="vertical">
<Form.Item label="预算分类" name="category" required> <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="food">🍽 餐饮</Select.Option>
<Select.Option value="transport">🚗 交通</Select.Option> <Select.Option value="transport">🚗 交通</Select.Option>
<Select.Option value="shopping">🛒 购物</Select.Option> <Select.Option value="shopping">🛒 购物</Select.Option>
@@ -156,17 +511,23 @@
<Row :gutter="16"> <Row :gutter="16">
<Col :span="12"> <Col :span="12">
<Form.Item label="分类名称" required> <Form.Item label="分类名称" required>
<Input v-model:value="budgetForm.customCategoryName" placeholder="请输入分类名称,如: 宝贝用品、理财等" /> <Input
v-model:value="budgetForm.customCategoryName"
placeholder="请输入分类名称,如: 宝贝用品、理财等"
/>
</Form.Item> </Form.Item>
</Col> </Col>
<Col :span="12"> <Col :span="12">
<Form.Item label="分类图标" required> <Form.Item label="分类图标" required>
<Input v-model:value="budgetForm.customCategoryIcon" placeholder="请输入图标,如: 👶, 💹 等" /> <Input
v-model:value="budgetForm.customCategoryIcon"
placeholder="请输入图标,如: 👶, 💹 等"
/>
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
</div> </div>
<Row :gutter="16"> <Row :gutter="16">
<Col :span="8"> <Col :span="8">
<Form.Item label="预算金额" name="limit" required> <Form.Item label="预算金额" name="limit" required>
@@ -182,7 +543,12 @@
</Col> </Col>
<Col :span="8"> <Col :span="8">
<Form.Item label="金额币种" name="currency" required> <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="CNY">🇨🇳 人民币</Select.Option>
<Select.Option value="USD">🇺🇸 美元</Select.Option> <Select.Option value="USD">🇺🇸 美元</Select.Option>
<Select.Option value="EUR">🇪🇺 欧元</Select.Option> <Select.Option value="EUR">🇪🇺 欧元</Select.Option>
@@ -211,12 +577,19 @@
<Row :gutter="16"> <Row :gutter="16">
<Col :span="12"> <Col :span="12">
<Form.Item label="币种代码" required> <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> </Form.Item>
</Col> </Col>
<Col :span="12"> <Col :span="12">
<Form.Item label="币种名称" required> <Form.Item label="币种名称" required>
<Input v-model:value="budgetForm.customCurrencyName" placeholder="如: 泰铢, 澳元 等" /> <Input
v-model:value="budgetForm.customCurrencyName"
placeholder="如: 泰铢, 澳元 等"
/>
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
@@ -231,29 +604,31 @@
:step="5" :step="5"
:marks="{ 50: '50%', 75: '75%', 90: '90%', 100: '100%' }" :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> </div>
</Form.Item> </Form.Item>
<Form.Item label="预算描述"> <Form.Item label="预算描述">
<Input.TextArea <Input.TextArea
v-model:value="budgetForm.description" v-model:value="budgetForm.description"
:rows="3" :rows="3"
placeholder="预算用途和目标..." placeholder="预算用途和目标..."
/> />
</Form.Item> </Form.Item>
<Form.Item label="预算设置"> <Form.Item label="预算设置">
<div class="space-y-3"> <div class="space-y-3">
<div class="flex justify-between items-center"> <div class="flex items-center justify-between">
<span>自动续期</span> <span>自动续期</span>
<Switch v-model:checked="budgetForm.autoRenew" /> <Switch v-model:checked="budgetForm.autoRenew" />
</div> </div>
<div class="flex justify-between items-center"> <div class="flex items-center justify-between">
<span>超支提醒</span> <span>超支提醒</span>
<Switch v-model:checked="budgetForm.overspendAlert" /> <Switch v-model:checked="budgetForm.overspendAlert" />
</div> </div>
<div class="flex justify-between items-center"> <div class="flex items-center justify-between">
<span>每日提醒</span> <span>每日提醒</span>
<Switch v-model:checked="budgetForm.dailyReminder" /> <Switch v-model:checked="budgetForm.dailyReminder" />
</div> </div>
@@ -264,289 +639,8 @@
</div> </div>
</template> </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> <style scoped>
.grid { display: grid; } .grid {
</style> display: grid;
}
</style>

View File

@@ -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"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { import {
Card, Tag, Button, Modal, Form, Input, Select, Row, Col, Button,
InputNumber, notification Card,
Col,
Form,
Input,
InputNumber,
Modal,
notification,
Row,
Select,
Tag,
} from 'ant-design-vue'; } from 'ant-design-vue';
import { useFinanceStore } from '#/store/finance'; import { useFinanceStore } from '#/store/finance';
@@ -329,7 +47,7 @@ const categoryForm = ref({
customCurrencyCode: '', customCurrencyCode: '',
customCurrencyName: '', customCurrencyName: '',
description: '', description: '',
color: '#1890ff' color: '#1890ff',
}); });
// 编辑表单数据 // 编辑表单数据
@@ -337,46 +55,56 @@ const editForm = ref({
name: '', name: '',
icon: '🏷️', icon: '🏷️',
customIcon: '', customIcon: '',
color: '#1890ff' color: '#1890ff',
}); });
// 分类颜色选项 // 分类颜色选项
const categoryColors = ref([ const categoryColors = ref([
'#1890ff', '#52c41a', '#fa541c', '#722ed1', '#eb2f96', '#13c2c2', '#1890ff',
'#f5222d', '#fa8c16', '#fadb14', '#a0d911', '#36cfc9', '#b37feb' '#52c41a',
'#fa541c',
'#722ed1',
'#eb2f96',
'#13c2c2',
'#f5222d',
'#fa8c16',
'#fadb14',
'#a0d911',
'#36cfc9',
'#b37feb',
]); ]);
// 表单验证规则 // 表单验证规则
const rules = { const rules = {
name: [ name: [
{ required: true, message: '请输入分类名称', trigger: 'blur' }, { required: true, message: '请输入分类名称', trigger: 'blur' },
{ min: 2, max: 20, message: '分类名称长度在2-20个字符', trigger: 'blur' } { min: 2, max: 20, message: '分类名称长度在2-20个字符', trigger: 'blur' },
], ],
type: [ type: [{ required: true, message: '请选择分类类型', trigger: 'change' }],
{ required: true, message: '请选择分类类型', trigger: 'change' }
]
}; };
// 计算统计 // 计算统计
const categoryStats = computed(() => { const categoryStats = computed(() => {
const incomeCategories = categories.value.filter(c => c.type === 'income'); const incomeCategories = categories.value.filter((c) => c.type === 'income');
const expenseCategories = categories.value.filter(c => c.type === 'expense'); const expenseCategories = categories.value.filter(
(c) => c.type === 'expense',
);
return { return {
total: categories.value.length, total: categories.value.length,
income: incomeCategories.length, income: incomeCategories.length,
expense: expenseCategories.length, expense: expenseCategories.length,
budgetTotal: 0 // 预算功能待实现 budgetTotal: 0, // 预算功能待实现
}; };
}); });
// 分类分组 // 分类分组
const incomeCategories = computed(() => { const incomeCategories = computed(() => {
return categories.value.filter(c => c.type === 'income'); return categories.value.filter((c) => c.type === 'income');
}); });
const expenseCategories = computed(() => { 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(); await formRef.value.validate();
// 处理自定义图标 // 处理自定义图标
const finalIcon = categoryForm.value.icon === 'CUSTOM' const finalIcon =
? categoryForm.value.customIcon categoryForm.value.icon === 'CUSTOM'
: categoryForm.value.icon; ? categoryForm.value.customIcon
: categoryForm.value.icon;
// 调用 store 创建分类 // 调用 store 创建分类
await financeStore.createCategory({ await financeStore.createCategory({
@@ -405,18 +134,17 @@ const submitCategory = async () => {
notification.success({ notification.success({
message: '分类添加成功', message: '分类添加成功',
description: `分类 "${categoryForm.value.name}" 已成功创建` description: `分类 "${categoryForm.value.name}" 已成功创建`,
}); });
// 关闭模态框 // 关闭模态框
showAddModal.value = false; showAddModal.value = false;
resetForm(); resetForm();
} catch (error) { } catch (error) {
console.error('创建分类失败:', error); console.error('创建分类失败:', error);
notification.error({ notification.error({
message: '添加失败', message: '添加失败',
description: '请检查表单信息是否正确' description: '请检查表单信息是否正确',
}); });
} }
}; };
@@ -437,7 +165,7 @@ const resetForm = () => {
customCurrencyCode: '', customCurrencyCode: '',
customCurrencyName: '', customCurrencyName: '',
description: '', description: '',
color: '#1890ff' color: '#1890ff',
}; };
}; };
@@ -472,9 +200,10 @@ const submitEditCategory = async () => {
await editFormRef.value?.validate(); await editFormRef.value?.validate();
// 处理自定义图标 // 处理自定义图标
const finalIcon = editForm.value.icon === 'CUSTOM' const finalIcon =
? editForm.value.customIcon editForm.value.icon === 'CUSTOM'
: editForm.value.icon; ? editForm.value.customIcon
: editForm.value.icon;
// 调用 store 更新分类 // 调用 store 更新分类
await financeStore.updateCategory(editingCategory.value.id, { await financeStore.updateCategory(editingCategory.value.id, {
@@ -485,17 +214,16 @@ const submitEditCategory = async () => {
notification.success({ notification.success({
message: '分类更新成功', message: '分类更新成功',
description: `分类 "${editForm.value.name}" 已更新` description: `分类 "${editForm.value.name}" 已更新`,
}); });
showEditModal.value = false; showEditModal.value = false;
editingCategory.value = null; editingCategory.value = null;
} catch (error) { } catch (error) {
console.error('更新分类失败:', error); console.error('更新分类失败:', error);
notification.error({ notification.error({
message: '更新失败', message: '更新失败',
description: '请检查表单信息是否正确' description: '请检查表单信息是否正确',
}); });
} }
}; };
@@ -505,7 +233,7 @@ const deleteCategory = (category: any) => {
if (category.isSystem) { if (category.isSystem) {
notification.warning({ notification.warning({
message: '无法删除', message: '无法删除',
description: '系统分类不允许删除' description: '系统分类不允许删除',
}); });
return; return;
} }
@@ -521,16 +249,16 @@ const deleteCategory = (category: any) => {
await financeStore.deleteCategory(category.id); await financeStore.deleteCategory(category.id);
notification.success({ notification.success({
message: '分类已删除', message: '分类已删除',
description: `分类 "${category.name}" 已删除` description: `分类 "${category.name}" 已删除`,
}); });
} catch (error) { } catch (error) {
console.error('删除分类失败:', error); console.error('删除分类失败:', error);
notification.error({ notification.error({
message: '删除失败', message: '删除失败',
description: '删除分类时出错,请稍后重试' description: '删除分类时出错,请稍后重试',
}); });
} }
} },
}); });
}; };
@@ -538,11 +266,406 @@ const setBudget = (category: any) => {
console.log('设置预算:', category); console.log('设置预算:', category);
notification.info({ notification.info({
message: '预算设置', message: '预算设置',
description: `为分类 "${category.name}" 设置预算` description: `为分类 "${category.name}" 设置预算`,
}); });
}; };
</script> </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> <style scoped>
.grid { display: grid; } .grid {
</style> display: grid;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,71 +1,406 @@
<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> <template>
<div class="p-6"> <div class="p-6">
<div class="mb-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> <p class="text-gray-600">财务系统的个性化配置和偏好设置</p>
</div> </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="🔧 基本设置"> <Card title="🔧 基本设置">
<Form :model="settings" layout="vertical"> <Form :model="settings" layout="vertical">
<Divider>通知设置</Divider> <Divider>通知设置</Divider>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex justify-between items-center"> <div class="flex items-center justify-between">
<div> <div>
<span class="font-medium">💰 预算提醒</span> <span class="font-medium">💰 预算提醒</span>
<p class="text-sm text-gray-500">预算接近或超支时提醒</p> <p class="text-sm text-gray-500">预算接近或超支时提醒</p>
</div> </div>
<Switch v-model:checked="settings.notifications.budget" @change="saveNotificationSettings" /> <Switch
v-model:checked="settings.notifications.budget"
@change="saveNotificationSettings"
/>
</div> </div>
<div class="flex justify-between items-center"> <div class="flex items-center justify-between">
<div> <div>
<span class="font-medium">🔔 账单提醒</span> <span class="font-medium">🔔 账单提醒</span>
<p class="text-sm text-gray-500">账单到期前提醒缴费</p> <p class="text-sm text-gray-500">账单到期前提醒缴费</p>
</div> </div>
<Switch v-model:checked="settings.notifications.bills" @change="saveNotificationSettings" /> <Switch
v-model:checked="settings.notifications.bills"
@change="saveNotificationSettings"
/>
</div> </div>
<div class="flex justify-between items-center"> <div class="flex items-center justify-between">
<div> <div>
<span class="font-medium">📊 投资更新</span> <span class="font-medium">📊 投资更新</span>
<p class="text-sm text-gray-500">投资收益变化通知</p> <p class="text-sm text-gray-500">投资收益变化通知</p>
</div> </div>
<Switch v-model:checked="settings.notifications.investment" @change="saveNotificationSettings" /> <Switch
v-model:checked="settings.notifications.investment"
@change="saveNotificationSettings"
/>
</div> </div>
<div class="flex justify-between items-center"> <div class="flex items-center justify-between">
<div> <div>
<span class="font-medium">💾 自动备份</span> <span class="font-medium">💾 自动备份</span>
<p class="text-sm text-gray-500">定期自动备份数据</p> <p class="text-sm text-gray-500">定期自动备份数据</p>
</div> </div>
<Switch v-model:checked="settings.autoBackup" @change="toggleAutoBackup" /> <Switch
v-model:checked="settings.autoBackup"
@change="toggleAutoBackup"
/>
</div> </div>
</div> </div>
<Divider>高级设置</Divider> <Divider>高级设置</Divider>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex justify-between items-center"> <div class="flex items-center justify-between">
<span>🎨 紧凑模式</span> <span>🎨 紧凑模式</span>
<Switch v-model:checked="settings.compactMode" @change="toggleCompactMode" /> <Switch
v-model:checked="settings.compactMode"
@change="toggleCompactMode"
/>
</div> </div>
<div class="flex justify-between items-center"> <div class="flex items-center justify-between">
<span>🔒 自动锁屏</span> <span>🔒 自动锁屏</span>
<Switch v-model:checked="settings.autoLock" @change="toggleAutoLock" /> <Switch
v-model:checked="settings.autoLock"
@change="toggleAutoLock"
/>
</div> </div>
<div class="flex justify-between items-center"> <div class="flex items-center justify-between">
<span>📈 数据统计</span> <span>📈 数据统计</span>
<Switch v-model:checked="settings.analytics" @change="toggleAnalytics" /> <Switch
v-model:checked="settings.analytics"
@change="toggleAnalytics"
/>
</div> </div>
</div> </div>
<div class="mt-6 space-x-4"> <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="resetAllSettings">🔄 恢复默认</Button>
<Button @click="exportAllSettings">📤 导出配置</Button> <Button @click="exportAllSettings">📤 导出配置</Button>
</div> </div>
</Form> </Form>
</Card> </Card>
<Card title="📊 系统状态"> <Card title="📊 系统状态">
<div class="space-y-3"> <div class="space-y-3">
<div class="flex justify-between"> <div class="flex justify-between">
@@ -95,7 +430,12 @@
<Button block @click="clearCache" :loading="operationLoading.cache"> <Button block @click="clearCache" :loading="operationLoading.cache">
🧹 清除缓存 🧹 清除缓存
</Button> </Button>
<Button block danger @click="resetSystem" :loading="operationLoading.reset"> <Button
block
danger
@click="resetSystem"
:loading="operationLoading.reset"
>
🗑 重置系统 🗑 重置系统
</Button> </Button>
</div> </div>
@@ -104,306 +444,8 @@
</div> </div>
</template> </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> <style scoped>
.grid { display: grid; } .grid {
</style> display: grid;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1398,7 +1398,9 @@ const _handleAccountChange = (account: string) => {
<div class="flex space-x-2"> <div class="flex space-x-2">
<Button @click="openRecycleBin"> <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>
<Button type="primary" @click="quickAddIncome"> 💰 添加收入 </Button> <Button type="primary" @click="quickAddIncome"> 💰 添加收入 </Button>
<Button @click="quickAddExpense"> 💸 添加支出 </Button> <Button @click="quickAddExpense"> 💸 添加支出 </Button>
@@ -1526,7 +1528,9 @@ const _handleAccountChange = (account: string) => {
<Row :gutter="16"> <Row :gutter="16">
<Col :span="12"> <Col :span="12">
<div class="mb-4"> <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 <Radio.Group
v-model:value="quickIncomeForm.currency" v-model:value="quickIncomeForm.currency"
size="large" size="large"
@@ -1581,7 +1585,9 @@ const _handleAccountChange = (account: string) => {
</InputNumber> </InputNumber>
</Col> </Col>
<Col :span="8"> <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 <InputNumber
v-model:value="quickIncomeForm.amount" v-model:value="quickIncomeForm.amount"
:min="0" :min="0"
@@ -1599,7 +1605,9 @@ const _handleAccountChange = (account: string) => {
<!-- 直接输入金额模式 --> <!-- 直接输入金额模式 -->
<Row v-else :gutter="16" class="mb-4"> <Row v-else :gutter="16" class="mb-4">
<Col :span="24"> <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 <InputNumber
v-model:value="quickIncomeForm.amount" v-model:value="quickIncomeForm.amount"
:min="0" :min="0"
@@ -1640,7 +1648,9 @@ const _handleAccountChange = (account: string) => {
</Row> </Row>
<div> <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 <Radio.Group
v-model:value="quickIncomeForm.accountId" v-model:value="quickIncomeForm.accountId"
size="large" size="large"
@@ -2382,7 +2392,9 @@ const _handleAccountChange = (account: string) => {
</Select> </Select>
</div> </div>
<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 <Select
v-model:value="importMapping.currency" v-model:value="importMapping.currency"
placeholder="选择对应列" placeholder="选择对应列"

View File

@@ -9,7 +9,7 @@ export default defineConfig(async () => {
'/api': { '/api': {
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''), rewrite: (path) => path.replace(/^\/api/, ''),
target: 'http://localhost:3000/api', target: 'http://localhost:5320/api',
ws: true, ws: true,
}, },
}, },