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:
179
apps/web-finance/src/utils/data-migration.ts
Normal file
179
apps/web-finance/src/utils/data-migration.ts
Normal 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;
|
||||
}
|
||||
324
apps/web-finance/src/utils/db.ts
Normal file
324
apps/web-finance/src/utils/db.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
199
apps/web-finance/src/utils/export.ts
Normal file
199
apps/web-finance/src/utils/export.ts
Normal 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;
|
||||
}
|
||||
266
apps/web-finance/src/utils/import.ts
Normal file
266
apps/web-finance/src/utils/import.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user