137 lines
5.2 KiB
JavaScript
137 lines
5.2 KiB
JavaScript
const express = require('express');
|
|||
|
|
const db = require('../db');
|
||
|
|
const { parseCSVLine } = require('../utils/helpers');
|
||
|
|
|
||
|
|
const router = express.Router();
|
||
|
|
|
||
|
|
// 导出数据
|
||
|
|
router.get('/export', (req, res) => {
|
||
|
|
const format = req.query.format;
|
||
|
|
if (!format || !['json', 'csv'].includes(format)) {
|
||
|
|
return res.status(400).json({ error: '请指定格式: json 或 csv' });
|
||
|
|
}
|
||
|
|
|
||
|
|
const records = db.prepare(
|
||
|
|
'SELECT type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at FROM records WHERE user_id = ? ORDER BY created_at DESC'
|
||
|
|
).all(req.user.id);
|
||
|
|
|
||
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||
|
|
const filename = `gamer-export-${timestamp}`;
|
||
|
|
|
||
|
|
if (format === 'json') {
|
||
|
|
const exportData = {
|
||
|
|
exportVersion: 1,
|
||
|
|
exportedAt: new Date().toISOString(),
|
||
|
|
username: req.user.username,
|
||
|
|
recordCount: records.length,
|
||
|
|
records
|
||
|
|
};
|
||
|
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||
|
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}.json"`);
|
||
|
|
return res.send(JSON.stringify(exportData, null, 2));
|
||
|
|
}
|
||
|
|
|
||
|
|
// CSV with UTF-8 BOM
|
||
|
|
const BOM = '\uFEFF';
|
||
|
|
const cols = ['type', 'category', 'amount', 'quantity', 'unit_price', 'rebate', 'boss', 'partner', 'source', 'destination', 'note', 'created_at'];
|
||
|
|
const csvRows = records.map(r => {
|
||
|
|
return cols.map(col => {
|
||
|
|
const val = r[col];
|
||
|
|
if (val === null || val === undefined) return '';
|
||
|
|
const str = String(val);
|
||
|
|
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
||
|
|
return '"' + str.replace(/"/g, '""') + '"';
|
||
|
|
}
|
||
|
|
return str;
|
||
|
|
}).join(',');
|
||
|
|
});
|
||
|
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||
|
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}.csv"`);
|
||
|
|
res.send(BOM + cols.join(',') + '\n' + csvRows.join('\n'));
|
||
|
|
});
|
||
|
|
|
||
|
|
// 导入数据
|
||
|
|
router.post('/import', express.text({ type: '*/*', limit: '10mb' }), (req, res) => {
|
||
|
|
try {
|
||
|
|
const raw = req.body;
|
||
|
|
if (!raw || typeof raw !== 'string') {
|
||
|
|
return res.status(400).json({ error: '未收到文件内容' });
|
||
|
|
}
|
||
|
|
|
||
|
|
let records = [];
|
||
|
|
const requiredFields = ['type', 'category', 'amount', 'created_at'];
|
||
|
|
const allFields = ['type', 'category', 'amount', 'quantity', 'unit_price', 'rebate', 'boss', 'partner', 'source', 'destination', 'note', 'created_at'];
|
||
|
|
const trimmed = raw.replace(/^\uFEFF/, '').trim();
|
||
|
|
|
||
|
|
if (trimmed.startsWith('{')) {
|
||
|
|
const data = JSON.parse(trimmed);
|
||
|
|
if (!data.exportVersion || !Array.isArray(data.records)) {
|
||
|
|
return res.status(400).json({ error: '无效的JSON导出文件格式' });
|
||
|
|
}
|
||
|
|
records = data.records;
|
||
|
|
} else {
|
||
|
|
const lines = trimmed.split('\n').map(l => l.replace(/\r$/, ''));
|
||
|
|
if (lines.length < 2) {
|
||
|
|
return res.status(400).json({ error: 'CSV文件为空或格式不正确' });
|
||
|
|
}
|
||
|
|
if (lines[0] !== allFields.join(',')) {
|
||
|
|
return res.status(400).json({ error: 'CSV列头不匹配,请使用本系统导出的文件' });
|
||
|
|
}
|
||
|
|
const csvHeaders = lines[0].split(',');
|
||
|
|
for (let i = 1; i < lines.length; i++) {
|
||
|
|
if (!lines[i].trim()) continue;
|
||
|
|
const values = parseCSVLine(lines[i]);
|
||
|
|
const record = {};
|
||
|
|
csvHeaders.forEach((h, idx) => { record[h] = values[idx] || null; });
|
||
|
|
if (record.amount) record.amount = parseFloat(record.amount);
|
||
|
|
if (record.quantity) record.quantity = parseInt(record.quantity) || null;
|
||
|
|
if (record.unit_price) record.unit_price = parseFloat(record.unit_price) || null;
|
||
|
|
if (record.rebate) record.rebate = parseFloat(record.rebate) || null;
|
||
|
|
records.push(record);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const r of records) {
|
||
|
|
for (const f of requiredFields) {
|
||
|
|
if (!r[f] && r[f] !== 0) {
|
||
|
|
return res.status(400).json({ error: `记录缺少必填字段: ${f}` });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (!['income', 'expense'].includes(r.type)) {
|
||
|
|
return res.status(400).json({ error: `无效的类型: ${r.type}` });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const existingCheck = db.prepare(
|
||
|
|
'SELECT id FROM records WHERE user_id = ? AND created_at = ? AND category = ? AND amount = ?'
|
||
|
|
);
|
||
|
|
const insertStmt = db.prepare(`
|
||
|
|
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
|
||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
|
|
`);
|
||
|
|
|
||
|
|
let imported = 0, skipped = 0;
|
||
|
|
const insertMany = db.transaction((recs) => {
|
||
|
|
for (const r of recs) {
|
||
|
|
if (existingCheck.get(req.user.id, r.created_at, r.category, r.amount)) {
|
||
|
|
skipped++; continue;
|
||
|
|
}
|
||
|
|
insertStmt.run(req.user.id, r.type, r.category, r.amount,
|
||
|
|
r.quantity || null, r.unit_price || null, r.rebate || null,
|
||
|
|
r.boss || null, r.partner || null,
|
||
|
|
r.source || null, r.destination || null,
|
||
|
|
r.note || null, r.created_at);
|
||
|
|
imported++;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
insertMany(records);
|
||
|
|
|
||
|
|
res.json({ imported, skipped, total: records.length });
|
||
|
|
} catch (e) {
|
||
|
|
console.error('Import error:', e);
|
||
|
|
res.status(400).json({ error: '文件解析失败: ' + e.message });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
module.exports = router;
|