feat: Add TokenRecords finance management system

- Created new finance application based on Vue Vben Admin
- Implemented transaction management, category management, and loan tracking
- Added person management for tracking financial relationships
- Integrated budget management and financial analytics
- Added data import/export functionality
- Implemented responsive design for mobile support
- Added comprehensive testing with Playwright

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
你的用户名
2025-08-06 20:09:48 +08:00
parent b93e22c45a
commit 4b4616de1e
193 changed files with 17756 additions and 16 deletions

View File

@@ -0,0 +1,179 @@
// 数据迁移工具 - 从旧的 localStorage 迁移到 IndexedDB
import type {
Category,
Loan,
Person,
Transaction
} from '#/types/finance';
import { importDatabase } from './db';
// 旧系统的存储键
const OLD_STORAGE_KEYS = {
TRANSACTIONS: 'transactions',
CATEGORIES: 'categories',
PERSONS: 'persons',
LOANS: 'loans',
};
// 生成新的 ID
function generateNewId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
// 迁移分类数据
function migrateCategories(oldCategories: any[]): Category[] {
return oldCategories.map(cat => ({
id: cat.id || generateNewId(),
name: cat.name,
type: cat.type,
parentId: cat.parentId,
created_at: cat.created_at || new Date().toISOString(),
}));
}
// 迁移人员数据
function migratePersons(oldPersons: any[]): Person[] {
return oldPersons.map(person => ({
id: person.id || generateNewId(),
name: person.name,
roles: person.roles || [],
contact: person.contact,
description: person.description,
created_at: person.created_at || new Date().toISOString(),
}));
}
// 迁移交易数据
function migrateTransactions(oldTransactions: any[]): Transaction[] {
return oldTransactions.map(trans => ({
id: trans.id || generateNewId(),
amount: Number(trans.amount) || 0,
type: trans.type,
categoryId: trans.categoryId,
description: trans.description,
date: trans.date,
quantity: trans.quantity || 1,
project: trans.project,
payer: trans.payer,
payee: trans.payee,
recorder: trans.recorder || '管理员',
currency: trans.currency || 'CNY',
status: trans.status || 'completed',
created_at: trans.created_at || new Date().toISOString(),
}));
}
// 迁移贷款数据
function migrateLoans(oldLoans: any[]): Loan[] {
return oldLoans.map(loan => ({
id: loan.id || generateNewId(),
borrower: loan.borrower,
lender: loan.lender,
amount: Number(loan.amount) || 0,
currency: loan.currency || 'CNY',
startDate: loan.startDate,
dueDate: loan.dueDate,
description: loan.description,
status: loan.status || 'active',
repayments: loan.repayments || [],
created_at: loan.created_at || new Date().toISOString(),
}));
}
// 从 localStorage 读取旧数据
function readOldData<T>(key: string): T[] {
try {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : [];
} catch (error) {
console.error(`Error reading ${key} from localStorage:`, error);
return [];
}
}
// 执行数据迁移
export async function migrateData(): Promise<{
success: boolean;
message: string;
details?: any;
}> {
try {
console.log('开始数据迁移...');
// 读取旧数据
const oldCategories = readOldData<any>(OLD_STORAGE_KEYS.CATEGORIES);
const oldPersons = readOldData<any>(OLD_STORAGE_KEYS.PERSONS);
const oldTransactions = readOldData<any>(OLD_STORAGE_KEYS.TRANSACTIONS);
const oldLoans = readOldData<any>(OLD_STORAGE_KEYS.LOANS);
console.log('读取到的旧数据:', {
categories: oldCategories.length,
persons: oldPersons.length,
transactions: oldTransactions.length,
loans: oldLoans.length,
});
// 如果没有旧数据,则不需要迁移
if (
oldCategories.length === 0 &&
oldPersons.length === 0 &&
oldTransactions.length === 0 &&
oldLoans.length === 0
) {
return {
success: true,
message: '没有需要迁移的数据',
};
}
// 转换数据格式
const categories = migrateCategories(oldCategories);
const persons = migratePersons(oldPersons);
const transactions = migrateTransactions(oldTransactions);
const loans = migrateLoans(oldLoans);
// 导入到新系统
await importDatabase({
categories,
persons,
transactions,
loans,
});
// 迁移成功后,可以选择清除旧数据
// localStorage.removeItem(OLD_STORAGE_KEYS.CATEGORIES);
// localStorage.removeItem(OLD_STORAGE_KEYS.PERSONS);
// localStorage.removeItem(OLD_STORAGE_KEYS.TRANSACTIONS);
// localStorage.removeItem(OLD_STORAGE_KEYS.LOANS);
return {
success: true,
message: '数据迁移成功',
details: {
categories: categories.length,
persons: persons.length,
transactions: transactions.length,
loans: loans.length,
},
};
} catch (error) {
console.error('数据迁移失败:', error);
return {
success: false,
message: '数据迁移失败',
details: error,
};
}
}
// 检查是否需要迁移
export function needsMigration(): boolean {
const hasOldData =
localStorage.getItem(OLD_STORAGE_KEYS.CATEGORIES) ||
localStorage.getItem(OLD_STORAGE_KEYS.PERSONS) ||
localStorage.getItem(OLD_STORAGE_KEYS.TRANSACTIONS) ||
localStorage.getItem(OLD_STORAGE_KEYS.LOANS);
return !!hasOldData;
}

View File

@@ -0,0 +1,324 @@
// IndexedDB 工具类
import type {
Category,
Loan,
Person,
Transaction
} from '#/types/finance';
const DB_NAME = 'TokenRecordsDB';
const DB_VERSION = 2; // 升级版本号以添加新表
// 数据表名称
export const STORES = {
TRANSACTIONS: 'transactions',
CATEGORIES: 'categories',
PERSONS: 'persons',
LOANS: 'loans',
TAGS: 'tags',
BUDGETS: 'budgets',
} as const;
// IndexedDB 实例
let db: IDBDatabase | null = null;
// 初始化数据库
export function initDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
if (db) {
resolve(db);
return;
}
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => {
reject(new Error('Failed to open database'));
};
request.onsuccess = () => {
db = request.result;
resolve(db);
};
request.onupgradeneeded = (event) => {
const database = (event.target as IDBOpenDBRequest).result;
// 创建交易表
if (!database.objectStoreNames.contains(STORES.TRANSACTIONS)) {
const transactionStore = database.createObjectStore(STORES.TRANSACTIONS, {
keyPath: 'id',
});
transactionStore.createIndex('type', 'type', { unique: false });
transactionStore.createIndex('categoryId', 'categoryId', { unique: false });
transactionStore.createIndex('date', 'date', { unique: false });
transactionStore.createIndex('currency', 'currency', { unique: false });
transactionStore.createIndex('status', 'status', { unique: false });
}
// 创建分类表
if (!database.objectStoreNames.contains(STORES.CATEGORIES)) {
const categoryStore = database.createObjectStore(STORES.CATEGORIES, {
keyPath: 'id',
});
categoryStore.createIndex('type', 'type', { unique: false });
categoryStore.createIndex('parentId', 'parentId', { unique: false });
}
// 创建人员表
if (!database.objectStoreNames.contains(STORES.PERSONS)) {
const personStore = database.createObjectStore(STORES.PERSONS, {
keyPath: 'id',
});
personStore.createIndex('name', 'name', { unique: false });
}
// 创建贷款表
if (!database.objectStoreNames.contains(STORES.LOANS)) {
const loanStore = database.createObjectStore(STORES.LOANS, {
keyPath: 'id',
});
loanStore.createIndex('status', 'status', { unique: false });
loanStore.createIndex('borrower', 'borrower', { unique: false });
loanStore.createIndex('lender', 'lender', { unique: false });
}
// 创建标签表
if (!database.objectStoreNames.contains(STORES.TAGS)) {
const tagStore = database.createObjectStore(STORES.TAGS, {
keyPath: 'id',
});
tagStore.createIndex('name', 'name', { unique: false });
}
// 创建预算表
if (!database.objectStoreNames.contains(STORES.BUDGETS)) {
const budgetStore = database.createObjectStore(STORES.BUDGETS, {
keyPath: 'id',
});
budgetStore.createIndex('categoryId', 'categoryId', { unique: false });
budgetStore.createIndex('year', 'year', { unique: false });
budgetStore.createIndex('period', 'period', { unique: false });
}
};
});
}
// 获取数据库实例
export async function getDB(): Promise<IDBDatabase> {
if (!db) {
db = await initDB();
}
return db;
}
// 通用的添加数据方法
export async function add<T>(storeName: string, data: T): Promise<T> {
const database = await getDB();
return new Promise((resolve, reject) => {
const transaction = database.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
// 确保数据可以被IndexedDB存储深拷贝并序列化
const serializedData = JSON.parse(JSON.stringify(data));
const request = store.add(serializedData);
request.onsuccess = () => {
resolve(data);
};
request.onerror = () => {
console.error('IndexedDB add error:', request.error);
reject(new Error(`Failed to add data to ${storeName}: ${request.error?.message}`));
};
});
}
// 通用的更新数据方法
export async function update<T>(storeName: string, data: T): Promise<T> {
const database = await getDB();
return new Promise((resolve, reject) => {
const transaction = database.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
// 确保数据可以被IndexedDB存储深拷贝并序列化
const serializedData = JSON.parse(JSON.stringify(data));
const request = store.put(serializedData);
request.onsuccess = () => {
resolve(data);
};
request.onerror = () => {
console.error('IndexedDB update error:', request.error);
reject(new Error(`Failed to update data in ${storeName}: ${request.error?.message}`));
};
});
}
// 通用的删除数据方法
export async function remove(storeName: string, id: string): Promise<void> {
const database = await getDB();
return new Promise((resolve, reject) => {
const transaction = database.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.delete(id);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to delete data from ${storeName}`));
};
});
}
// 通用的获取单条数据方法
export async function get<T>(storeName: string, id: string): Promise<T | null> {
const database = await getDB();
return new Promise((resolve, reject) => {
const transaction = database.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const request = store.get(id);
request.onsuccess = () => {
resolve(request.result || null);
};
request.onerror = () => {
reject(new Error(`Failed to get data from ${storeName}`));
};
});
}
// 通用的获取所有数据方法
export async function getAll<T>(storeName: string): Promise<T[]> {
const database = await getDB();
return new Promise((resolve, reject) => {
const transaction = database.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const request = store.getAll();
request.onsuccess = () => {
resolve(request.result || []);
};
request.onerror = () => {
reject(new Error(`Failed to get all data from ${storeName}`));
};
});
}
// 按索引查询
export async function getByIndex<T>(
storeName: string,
indexName: string,
value: any,
): Promise<T[]> {
const database = await getDB();
return new Promise((resolve, reject) => {
const transaction = database.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const index = store.index(indexName);
const request = index.getAll(value);
request.onsuccess = () => {
resolve(request.result || []);
};
request.onerror = () => {
reject(new Error(`Failed to get data by index from ${storeName}`));
};
});
}
// 清空数据表
export async function clear(storeName: string): Promise<void> {
const database = await getDB();
return new Promise((resolve, reject) => {
const transaction = database.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.clear();
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to clear ${storeName}`));
};
});
}
// 批量添加数据
export async function addBatch<T>(storeName: string, dataList: T[]): Promise<void> {
const database = await getDB();
return new Promise((resolve, reject) => {
const transaction = database.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
dataList.forEach((data) => {
// 确保数据可以被IndexedDB存储深拷贝并序列化
const serializedData = JSON.parse(JSON.stringify(data));
store.add(serializedData);
});
transaction.oncomplete = () => {
resolve();
};
transaction.onerror = () => {
console.error('IndexedDB addBatch error:', transaction.error);
reject(new Error(`Failed to add batch data to ${storeName}: ${transaction.error?.message}`));
};
});
}
// 导出数据库
export async function exportDatabase(): Promise<{
transactions: Transaction[];
categories: Category[];
persons: Person[];
loans: Loan[];
}> {
const transactions = await getAll<Transaction>(STORES.TRANSACTIONS);
const categories = await getAll<Category>(STORES.CATEGORIES);
const persons = await getAll<Person>(STORES.PERSONS);
const loans = await getAll<Loan>(STORES.LOANS);
return {
transactions,
categories,
persons,
loans,
};
}
// 导入数据库
export async function importDatabase(data: {
transactions?: Transaction[];
categories?: Category[];
persons?: Person[];
loans?: Loan[];
}): Promise<void> {
if (data.categories) {
await clear(STORES.CATEGORIES);
await addBatch(STORES.CATEGORIES, data.categories);
}
if (data.persons) {
await clear(STORES.PERSONS);
await addBatch(STORES.PERSONS, data.persons);
}
if (data.transactions) {
await clear(STORES.TRANSACTIONS);
await addBatch(STORES.TRANSACTIONS, data.transactions);
}
if (data.loans) {
await clear(STORES.LOANS);
await addBatch(STORES.LOANS, data.loans);
}
}

View File

@@ -0,0 +1,199 @@
import type { Transaction, Category, Person } from '#/types/finance';
import dayjs from 'dayjs';
/**
* 导出数据为CSV格式
*/
export function exportToCSV(data: any[], filename: string) {
if (data.length === 0) {
return;
}
// 获取所有列名
const headers = Object.keys(data[0]);
// 创建CSV内容
let csvContent = '\uFEFF'; // UTF-8 BOM
// 添加表头
csvContent += headers.join(',') + '\n';
// 添加数据行
data.forEach(row => {
const values = headers.map(header => {
const value = row[header];
// 处理包含逗号或换行符的值
if (typeof value === 'string' && (value.includes(',') || value.includes('\n'))) {
return `"${value.replace(/"/g, '""')}"`;
}
return value ?? '';
});
csvContent += values.join(',') + '\n';
});
// 创建Blob并下载
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `${filename}_${dayjs().format('YYYYMMDD_HHmmss')}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* 导出交易数据
*/
export function exportTransactions(
transactions: Transaction[],
categories: Category[],
persons: Person[]
) {
// 创建分类和人员的映射
const categoryMap = new Map(categories.map(c => [c.id, c.name]));
const personMap = new Map(persons.map(p => [p.id, p.name]));
// 转换交易数据为导出格式
const exportData = transactions.map(t => ({
日期: t.date,
类型: t.type === 'income' ? '收入' : '支出',
分类: categoryMap.get(t.categoryId) || '',
金额: t.amount,
货币: t.currency,
项目: t.project || '',
付款人: t.payer || '',
收款人: t.payee || '',
数量: t.quantity,
单价: t.quantity > 1 ? (t.amount / t.quantity).toFixed(2) : t.amount,
状态: t.status === 'completed' ? '已完成' : t.status === 'pending' ? '待处理' : '已取消',
描述: t.description || '',
记录人: t.recorder || '',
创建时间: t.created_at,
更新时间: t.updated_at
}));
exportToCSV(exportData, '交易记录');
}
/**
* 导出数据为JSON格式
*/
export function exportToJSON(data: any, filename: string) {
const jsonContent = JSON.stringify(data, null, 2);
const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `${filename}_${dayjs().format('YYYYMMDD_HHmmss')}.json`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* 生成导入模板
*/
export function generateImportTemplate() {
const template = [
{
date: '2025-08-05',
type: 'expense',
category: '餐饮',
amount: 100.00,
currency: 'CNY',
description: '午餐',
project: '项目名称',
payer: '付款人',
payee: '收款人',
status: 'completed',
tags: '标签1,标签2',
},
{
date: '2025-08-05',
type: 'income',
category: '工资',
amount: 5000.00,
currency: 'CNY',
description: '月薪',
project: '',
payer: '公司',
payee: '自己',
status: 'completed',
tags: '',
},
];
exportToCSV(template, 'transaction_import_template');
}
/**
* 导出所有数据(完整备份)
*/
export function exportAllData(
transactions: Transaction[],
categories: Category[],
persons: Person[]
) {
const exportData = {
version: '1.0',
exportDate: dayjs().format('YYYY-MM-DD HH:mm:ss'),
data: {
transactions,
categories,
persons
}
};
exportToJSON(exportData, '财务数据备份');
}
/**
* 解析CSV文件
*/
export function parseCSV(text: string): Record<string, any>[] {
const lines = text.split('\n').filter(line => line.trim());
if (lines.length === 0) return [];
// 解析表头
const headers = lines[0].split(',').map(h => h.trim());
// 解析数据行
const data = [];
for (let i = 1; i < lines.length; i++) {
const values = [];
let current = '';
let inQuotes = false;
for (let j = 0; j < lines[i].length; j++) {
const char = lines[i][j];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
values.push(current.trim());
current = '';
} else {
current += char;
}
}
values.push(current.trim());
// 创建对象
const row: Record<string, any> = {};
headers.forEach((header, index) => {
row[header] = values[index] || '';
});
data.push(row);
}
return data;
}

View File

@@ -0,0 +1,266 @@
import type { Transaction, Category, Person } from '#/types/finance';
import dayjs from 'dayjs';
import { v4 as uuidv4 } from 'uuid';
/**
* 解析CSV文本
*/
export function parseCSV(text: string): Record<string, any>[] {
const lines = text.split('\n').filter(line => line.trim());
if (lines.length < 2) return [];
// 解析表头
const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
// 解析数据行
const data: Record<string, any>[] = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim().replace(/^"|"$/g, ''));
if (values.length === headers.length) {
const row: Record<string, any> = {};
headers.forEach((header, index) => {
row[header] = values[index];
});
data.push(row);
}
}
return data;
}
/**
* 导入交易数据从CSV
*/
export function importTransactionsFromCSV(
csvData: Record<string, any>[],
categories: Category[],
persons: Person[]
): {
transactions: Partial<Transaction>[],
errors: string[],
newCategories: string[],
newPersons: string[]
} {
const transactions: Partial<Transaction>[] = [];
const errors: string[] = [];
const newCategories = new Set<string>();
const newPersons = new Set<string>();
// 创建分类和人员的反向映射名称到ID
const categoryMap = new Map(categories.map(c => [c.name, c]));
csvData.forEach((row, index) => {
try {
// 解析类型
const type = row['类型'] === '收入' ? 'income' : 'expense';
// 查找或标记新分类
let categoryId = '';
const categoryName = row['分类'];
if (categoryName) {
const category = categoryMap.get(categoryName);
if (category && category.type === type) {
categoryId = category.id;
} else {
newCategories.add(categoryName);
}
}
// 标记新的人员
if (row['付款人'] && !persons.some(p => p.name === row['付款人'])) {
newPersons.add(row['付款人']);
}
if (row['收款人'] && !persons.some(p => p.name === row['收款人'])) {
newPersons.add(row['收款人']);
}
// 解析金额
const amount = parseFloat(row['金额']);
if (isNaN(amount)) {
errors.push(`${index + 2}行: 金额格式错误`);
return;
}
// 解析日期
const date = row['日期'] ? dayjs(row['日期']).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
if (!dayjs(date).isValid()) {
errors.push(`${index + 2}行: 日期格式错误`);
return;
}
// 解析状态
let status: 'pending' | 'completed' | 'cancelled' = 'completed';
if (row['状态'] === '待处理') status = 'pending';
else if (row['状态'] === '已取消') status = 'cancelled';
// 创建交易对象
const transaction: Partial<Transaction> = {
id: uuidv4(),
type,
categoryId,
amount,
currency: row['货币'] || 'CNY',
date,
project: row['项目'] || '',
payer: row['付款人'] || '',
payee: row['收款人'] || '',
quantity: parseInt(row['数量']) || 1,
status,
description: row['描述'] || '',
recorder: row['记录人'] || '导入',
created_at: dayjs().format('YYYY-MM-DD HH:mm:ss'),
updated_at: dayjs().format('YYYY-MM-DD HH:mm:ss')
};
transactions.push(transaction);
} catch (error) {
errors.push(`${index + 2}行: 数据解析错误`);
}
});
return {
transactions,
errors,
newCategories: Array.from(newCategories),
newPersons: Array.from(newPersons)
};
}
/**
* 导入JSON备份数据
*/
export function importFromJSON(jsonData: any): {
valid: boolean,
data?: {
transactions: Transaction[],
categories: Category[],
persons: Person[]
},
error?: string
} {
try {
// 验证数据格式
if (!jsonData.version || !jsonData.data) {
return { valid: false, error: '无效的备份文件格式' };
}
const { transactions, categories, persons } = jsonData.data;
// 验证必要字段
if (!Array.isArray(transactions) || !Array.isArray(categories) || !Array.isArray(persons)) {
return { valid: false, error: '备份数据不完整' };
}
// 为导入的数据生成新的ID避免冲突
const idMap = new Map<string, string>();
// 处理分类
const newCategories = categories.map(c => {
const newId = uuidv4();
idMap.set(c.id, newId);
return { ...c, id: newId };
});
// 处理人员
const newPersons = persons.map(p => {
const newId = uuidv4();
idMap.set(p.id, newId);
return { ...p, id: newId };
});
// 处理交易更新关联的ID
const newTransactions = transactions.map(t => {
const newId = uuidv4();
return {
...t,
id: newId,
categoryId: idMap.get(t.categoryId) || t.categoryId,
created_at: t.created_at || dayjs().format('YYYY-MM-DD HH:mm:ss'),
updated_at: dayjs().format('YYYY-MM-DD HH:mm:ss')
};
});
return {
valid: true,
data: {
transactions: newTransactions,
categories: newCategories,
persons: newPersons
}
};
} catch (error) {
return { valid: false, error: '解析备份文件失败' };
}
}
/**
* 读取文件内容
*/
export function readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target?.result as string);
reader.onerror = reject;
reader.readAsText(file);
});
}
/**
* 生成导入模板
*/
export function generateImportTemplate(): string {
const headers = [
'日期',
'类型',
'分类',
'金额',
'货币',
'项目',
'付款人',
'收款人',
'数量',
'状态',
'描述',
'记录人'
];
const examples = [
[
dayjs().format('YYYY-MM-DD'),
'支出',
'餐饮',
'50.00',
'CNY',
'项目A',
'张三',
'餐厅',
'1',
'已完成',
'午餐',
'管理员'
],
[
dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
'收入',
'工资',
'10000.00',
'CNY',
'',
'公司',
'李四',
'1',
'已完成',
'月薪',
'管理员'
]
];
let csvContent = '\uFEFF'; // UTF-8 BOM
csvContent += headers.join(',') + '\n';
examples.forEach(row => {
csvContent += row.join(',') + '\n';
});
return csvContent;
}