const express = require('express'); const bcrypt = require('bcryptjs'); const XLSX = require('xlsx'); const { requireRole } = require('../middleware/auth'); const router = express.Router(); // All admin routes require admin role router.use(requireRole('admin')); // Helper to get db from app.locals function getDB(req) { return req.app.locals.db; } // ======================== // User Management // ======================== router.get('/users', (req, res) => { const db = getDB(req); const users = db.all('SELECT id, username, realname, role, status, created_at FROM users ORDER BY id'); res.json({ code: 200, data: users }); }); router.post('/users', (req, res) => { const db = getDB(req); let { username, password, realname, role } = req.body; if (!realname || !role) return res.status(400).json({ code: 400, message: '姓名和角色不能为空' }); if (!['admin', 'sales', 'rd'].includes(role)) return res.status(400).json({ code: 400, message: '角色类型无效' }); // Auto-generate for sales if (role === 'sales') { if (!username) username = `sales_${Date.now()}`; if (!password) password = `sales_${Date.now()}`; } else { if (!username || !password) return res.status(400).json({ code: 400, message: '用户名和密码不能为空' }); } const existing = db.get('SELECT id FROM users WHERE username = ?', [username]); if (existing) return res.status(400).json({ code: 400, message: '用户名已存在' }); const hash = bcrypt.hashSync(password, 10); const result = db.run('INSERT INTO users (username, password, realname, role, status) VALUES (?, ?, ?, ?, 1)', [username, hash, realname, role]); res.json({ code: 200, message: '创建成功', data: { id: result.lastInsertRowid } }); }); router.put('/users/:id', (req, res) => { const db = getDB(req); const { id } = req.params; const { realname, role, status, password } = req.body; const user = db.get('SELECT id FROM users WHERE id = ?', [parseInt(id)]); if (!user) return res.status(404).json({ code: 404, message: '用户不存在' }); if (role && !['admin', 'sales', 'rd'].includes(role)) return res.status(400).json({ code: 400, message: '角色类型无效' }); const updates = []; const params = []; if (realname !== undefined) { updates.push('realname = ?'); params.push(realname); } if (role !== undefined) { updates.push('role = ?'); params.push(role); } if (status !== undefined) { updates.push('status = ?'); params.push(status); } if (password) { updates.push('password = ?'); params.push(bcrypt.hashSync(password, 10)); } if (updates.length === 0) return res.status(400).json({ code: 400, message: '没有可更新的字段' }); params.push(parseInt(id)); db.run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params); res.json({ code: 200, message: '更新成功' }); }); router.delete('/users/:id', (req, res) => { const db = getDB(req); const { id } = req.params; if (parseInt(id) === req.user.id) return res.status(400).json({ code: 400, message: '不能删除当前登录的管理员账号' }); const user = db.get('SELECT id FROM users WHERE id = ?', [parseInt(id)]); if (!user) return res.status(404).json({ code: 404, message: '用户不存在' }); db.run('DELETE FROM users WHERE id = ?', [parseInt(id)]); res.json({ code: 200, message: '删除成功' }); }); // ======================== // Client Management // ======================== router.get('/clients', (req, res) => { const db = getDB(req); const clients = db.all(` SELECT c.id, c.name, c.description, c.sales_id, c.service_types, u.realname as sales_name, c.status, c.created_at, COALESCE(SUM(t.hours), 0) as total_hours, COUNT(DISTINCT t.user_id) as rd_count FROM clients c LEFT JOIN users u ON c.sales_id = u.id LEFT JOIN timesheets t ON c.id = t.client_id GROUP BY c.id ORDER BY c.created_at DESC `); // Parse service_types JSON for each client const result = clients.map(c => ({ ...c, service_types: JSON.parse(c.service_types || '[]') })); res.json({ code: 200, data: result }); }); router.post('/clients', (req, res) => { const db = getDB(req); const { name, description, sales_id, service_types } = req.body; if (!name) return res.status(400).json({ code: 400, message: '客户名称不能为空' }); if (!sales_id) return res.status(400).json({ code: 400, message: '必须选择所属销售' }); const dup = db.get('SELECT id FROM clients WHERE name = ?', [name]); if (dup) return res.status(400).json({ code: 400, message: `项目名称「${name}」已存在,请更换一个名称` }); const stJson = JSON.stringify(Array.isArray(service_types) ? service_types : []); const result = db.run('INSERT INTO clients (name, description, sales_id, status, service_types) VALUES (?, ?, ?, 1, ?)', [name, description || '', parseInt(sales_id), stJson]); res.json({ code: 200, message: '创建成功', data: { id: result.lastInsertRowid } }); }); router.put('/clients/:id', (req, res) => { const db = getDB(req); const { id } = req.params; const { name, description, status, sales_id, service_types } = req.body; const client = db.get('SELECT * FROM clients WHERE id = ?', [parseInt(id)]); if (!client) return res.status(404).json({ code: 404, message: '客户不存在' }); // Check name uniqueness (exclude self) if (name !== undefined) { const dup = db.get('SELECT id FROM clients WHERE name = ? AND id != ?', [name, parseInt(id)]); if (dup) return res.status(400).json({ code: 400, message: `项目名称「${name}」已存在,请更换一个名称` }); } const updates = [], params = []; if (name !== undefined) { updates.push('name = ?'); params.push(name); } if (description !== undefined) { updates.push('description = ?'); params.push(description); } if (status !== undefined) { updates.push('status = ?'); params.push(status); } if (sales_id !== undefined) { updates.push('sales_id = ?'); params.push(parseInt(sales_id)); } if (service_types !== undefined) { updates.push('service_types = ?'); params.push(JSON.stringify(Array.isArray(service_types) ? service_types : [])); } if (updates.length === 0) return res.status(400).json({ code: 400, message: '没有可更新的字段' }); params.push(parseInt(id)); db.run(`UPDATE clients SET ${updates.join(', ')} WHERE id = ?`, params); res.json({ code: 200, message: '更新成功' }); }); router.delete('/clients/:id', (req, res) => { const db = getDB(req); const { id } = req.params; const client = db.get('SELECT id FROM clients WHERE id = ?', [parseInt(id)]); if (!client) return res.status(404).json({ code: 404, message: '客户/项目不存在' }); // Check if any timesheets reference this client const tsCount = db.get('SELECT COUNT(*) as cnt FROM timesheets WHERE client_id = ?', [parseInt(id)]); if (tsCount && tsCount.cnt > 0) { return res.status(400).json({ code: 400, message: `该项目已有 ${tsCount.cnt} 条工时记录,无法删除。如需停用请使用"结算"操作。` }); } db.run('DELETE FROM clients WHERE id = ?', [parseInt(id)]); res.json({ code: 200, message: '删除成功' }); }); // ======================== // Workday Settings // ======================== router.get('/workdays', async (req, res) => { const db = getDB(req); const year = parseInt(req.query.year) || new Date().getFullYear(); let workdays = db.all('SELECT * FROM workdays WHERE year = ? ORDER BY month', [year]); // Auto-calculate and seed if database is empty for this year if (workdays.length === 0) { try { const { getHolidays, calculateMonthlyWorkdays } = require('../utils/holiday'); const holidayMap = await getHolidays(db, year); const computed = calculateMonthlyWorkdays(year, holidayMap); for (const item of computed) { db.run('INSERT OR IGNORE INTO workdays (year, month, days) VALUES (?, ?, ?)', [year, item.month, item.days]); } workdays = db.all('SELECT * FROM workdays WHERE year = ? ORDER BY month', [year]); } catch (e) { console.error('[Admin] Error auto-seeding workdays:', e.message); } } res.json({ code: 200, data: workdays }); }); router.post('/workdays', (req, res) => { const db = getDB(req); const { year, months } = req.body; if (!year || !Array.isArray(months)) return res.status(400).json({ code: 400, message: '参数格式错误' }); for (const item of months) { const existing = db.get('SELECT id FROM workdays WHERE year = ? AND month = ?', [year, item.month]); if (existing) { db.run('UPDATE workdays SET days = ? WHERE year = ? AND month = ?', [item.days, year, item.month]); } else { db.run('INSERT INTO workdays (year, month, days) VALUES (?, ?, ?)', [year, item.month, item.days]); } } res.json({ code: 200, message: '设置成功' }); }); // POST /api/admin/workdays/sync — manually trigger network sync router.post('/workdays/sync', async (req, res) => { const db = getDB(req); const { year } = req.body; if (!year) return res.status(400).json({ code: 400, message: '请指定要同步的年份' }); try { const { syncYearWorkdays } = require('../utils/holiday'); const workdaysList = await syncYearWorkdays(db, parseInt(year)); res.json({ code: 200, message: '自动同步成功', data: workdaysList }); } catch (e) { console.error('[Admin] Sync error:', e.message); res.status(500).json({ code: 500, message: e.message }); } }); // GET /api/admin/holidays — retrieve holiday data for a specific year router.get('/holidays', async (req, res) => { const db = getDB(req); const year = parseInt(req.query.year) || new Date().getFullYear(); const { getHolidays } = require('../utils/holiday'); const holidayMap = await getHolidays(db, year); res.json({ code: 200, data: holidayMap || {} }); }); // ======================== // Global Stats // ======================== router.get('/stats/sales', (req, res) => { const db = getDB(req); const { start_date, end_date, sales_id } = req.query; let sql = `SELECT u.id as sales_id, u.realname as sales_name, c.id as client_id, c.name as client_name, t.work_date, SUM(t.hours) as total_hours FROM timesheets t JOIN clients c ON t.client_id = c.id JOIN users u ON c.sales_id = u.id WHERE 1=1`; const params = []; if (start_date) { sql += ' AND t.work_date >= ?'; params.push(start_date); } if (end_date) { sql += ' AND t.work_date <= ?'; params.push(end_date); } if (sales_id) { sql += ' AND u.id = ?'; params.push(parseInt(sales_id)); } sql += ' GROUP BY u.id, c.id, t.work_date ORDER BY t.work_date DESC, u.realname'; res.json({ code: 200, data: db.all(sql, params) }); }); // Sales detail: sales → RD person breakdown router.get('/stats/sales-detail', (req, res) => { const db = getDB(req); const { start_date, end_date, sales_id } = req.query; let sql = `SELECT s.id as sales_id, s.realname as sales_name, rd.id as rd_id, rd.realname as rd_name, SUM(t.hours) as total_hours, COUNT(DISTINCT t.work_date) as work_days FROM timesheets t JOIN clients c ON t.client_id = c.id JOIN users s ON c.sales_id = s.id JOIN users rd ON t.user_id = rd.id WHERE 1=1`; const params = []; if (start_date) { sql += ' AND t.work_date >= ?'; params.push(start_date); } if (end_date) { sql += ' AND t.work_date <= ?'; params.push(end_date); } if (sales_id) { sql += ' AND s.id = ?'; params.push(parseInt(sales_id)); } sql += ' GROUP BY s.id, rd.id ORDER BY s.realname, total_hours DESC'; res.json({ code: 200, data: db.all(sql, params) }); }); router.get('/stats/rd', (req, res) => { const db = getDB(req); const { start_date, end_date, user_id } = req.query; let sql = `SELECT u.id as user_id, u.realname as user_name, c.id as client_id, c.name || CASE WHEN IFNULL(t.project_type, '') != '' THEN '-' || t.project_type ELSE '' END as client_name, t.work_date, t.hours, t.remark FROM timesheets t JOIN users u ON t.user_id = u.id JOIN clients c ON t.client_id = c.id WHERE 1=1`; const params = []; if (start_date) { sql += ' AND t.work_date >= ?'; params.push(start_date); } if (end_date) { sql += ' AND t.work_date <= ?'; params.push(end_date); } if (user_id) { sql += ' AND u.id = ?'; params.push(parseInt(user_id)); } sql += ' ORDER BY t.work_date DESC, u.realname'; res.json({ code: 200, data: db.all(sql, params) }); }); router.get('/stats/project', (req, res) => { const db = getDB(req); const { start_date, end_date, sales_id } = req.query; let sql = `SELECT c.id as client_id, c.name as client_name, c.status as client_status, s.realname as sales_name, COALESCE(SUM(t.hours), 0) as total_hours, COUNT(DISTINCT t.user_id) as rd_count FROM clients c LEFT JOIN timesheets t ON c.id = t.client_id LEFT JOIN users s ON c.sales_id = s.id WHERE 1=1`; const params = []; if (sales_id) { sql += ' AND s.id = ?'; params.push(parseInt(sales_id)); } if (start_date) { sql += ' AND (t.work_date >= ? OR t.work_date IS NULL)'; params.push(start_date); } if (end_date) { sql += ' AND (t.work_date <= ? OR t.work_date IS NULL)'; params.push(end_date); } sql += ' GROUP BY c.id ORDER BY total_hours DESC'; res.json({ code: 200, data: db.all(sql, params) }); }); // Project detail: project → RD person breakdown router.get('/stats/project-detail', (req, res) => { const db = getDB(req); const { start_date, end_date, client_id, sales_id } = req.query; let sql = `SELECT c.id as client_id, c.name as client_name, c.status as client_status, s.realname as sales_name, rd.id as rd_id, rd.realname as rd_name, SUM(t.hours) as total_hours, COUNT(DISTINCT t.work_date) as work_days, MIN(t.work_date) as first_date, MAX(t.work_date) as last_date FROM timesheets t JOIN clients c ON t.client_id = c.id JOIN users s ON c.sales_id = s.id JOIN users rd ON t.user_id = rd.id WHERE 1=1`; const params = []; if (start_date) { sql += ' AND t.work_date >= ?'; params.push(start_date); } if (end_date) { sql += ' AND t.work_date <= ?'; params.push(end_date); } if (client_id) { sql += ' AND c.id = ?'; params.push(parseInt(client_id)); } if (sales_id) { sql += ' AND s.id = ?'; params.push(parseInt(sales_id)); } sql += ' GROUP BY c.id, rd.id ORDER BY c.name, total_hours DESC'; res.json({ code: 200, data: db.all(sql, params) }); }); // ======================== // Excel Export // ======================== // Helper: build matrix (研发工时汇总) sheet data function buildMatrixSheetData(db, start_date, end_date) { let sql = `SELECT u.realname as user_name, c.name as client_name, COALESCE(t.project_type, '') as sub_project, SUM(t.hours) as hours FROM timesheets t JOIN users u ON t.user_id = u.id JOIN clients c ON t.client_id = c.id WHERE 1=1`; const params = []; if (start_date) { sql += ' AND t.work_date >= ?'; params.push(start_date); } if (end_date) { sql += ' AND t.work_date <= ?'; params.push(end_date); } sql += ' GROUP BY u.realname, c.name, t.project_type'; const rows = db.all(sql, params); // cols = unique 项目-子项目 labels const colKeys = [...new Set(rows.map(r => r.sub_project ? `${r.client_name}-${r.sub_project}` : r.client_name))].sort(); // rows = unique user names const rowKeys = [...new Set(rows.map(r => r.user_name))].sort(); const cellMap = {}; rows.forEach(r => { const ck = r.sub_project ? `${r.client_name}-${r.sub_project}` : r.client_name; if (!cellMap[r.user_name]) cellMap[r.user_name] = {}; cellMap[r.user_name][ck] = (cellMap[r.user_name][ck] || 0) + r.hours; }); const headers = ['\u7814\u53d1\u4eba\u5458', ...colKeys, '\u5408\u8ba1']; const data = rowKeys.map(rk => { const rowTotal = colKeys.reduce((s, c) => s + (cellMap[rk]?.[c] || 0), 0); return [rk, ...colKeys.map(c => cellMap[rk]?.[c] || 0), Math.round(rowTotal * 10) / 10]; }); const colTotals = ['\u5408\u8ba1', ...colKeys.map(c => Math.round(data.reduce((s, r) => s + r[colKeys.indexOf(c) + 1], 0) * 10) / 10), Math.round(data.reduce((s, r) => s + r[r.length - 1], 0) * 10) / 10]; data.push(colTotals); return { headers, data }; } router.get('/export/all', (req, res) => { const db = getDB(req); const { start_date, end_date, sales_id } = req.query; const wb = XLSX.utils.book_new(); // Sales, Project, R&D sheets const types = ['sales', 'project', 'rd']; types.forEach(type => { const { sheets } = getExportData(db, type, start_date, end_date, sales_id); sheets.forEach(s => { const ws = createSheet(s.headers, s.data); XLSX.utils.book_append_sheet(wb, ws, s.sheetName); }); }); // Matrix sheet const { headers: mh, data: md } = buildMatrixSheetData(db, start_date, end_date); if (md.length > 1) XLSX.utils.book_append_sheet(wb, createSheet(mh, md), '研发工时汇总'); const filename = `总报表_${start_date || '开始'}至${end_date || '至今'}`; const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Disposition', `attachment; filename=${encodeURIComponent(filename)}.xlsx`); res.send(Buffer.from(buf)); }); // Matrix export — must be BEFORE /export/:type to avoid route conflict router.get('/export/matrix', (req, res) => { const db = getDB(req); const { start_date, end_date } = req.query; const { headers, data } = buildMatrixSheetData(db, start_date, end_date); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, createSheet(headers, data), '研发工时汇总'); const filename = `研发工时汇总_${start_date || '开始'}至${end_date || '至今'}`; const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Disposition', `attachment; filename=${encodeURIComponent(filename)}.xlsx`); res.send(Buffer.from(buf)); }); router.get('/export/:type', (req, res) => { const db = getDB(req); const { type } = req.params; const { start_date, end_date, sales_id } = req.query; const { sheets, filename } = getExportData(db, type, start_date, end_date, sales_id); if (!sheets.length) return res.status(400).json({ code: 400, message: '无效的导出类型' }); const wb = XLSX.utils.book_new(); sheets.forEach(s => { const ws = createSheet(s.headers, s.data); XLSX.utils.book_append_sheet(wb, ws, s.sheetName); }); const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Disposition', `attachment; filename=${encodeURIComponent(filename)}.xlsx`); res.send(Buffer.from(buf)); }); function getExportData(db, type, start_date, end_date, sales_id) { let sheets = [], filename = ''; const conditionBuilder = () => { let cond = ''; let params = []; if (start_date) { cond += ' AND t.work_date >= ?'; params.push(start_date); } if (end_date) { cond += ' AND t.work_date <= ?'; params.push(end_date); } return { cond, params }; }; const { cond: tCond, params: tParams } = conditionBuilder(); if (type === 'sales') { let sql1 = `SELECT s.realname as sales_name, c.name as client_name, COALESCE(SUM(t.hours), 0) as total_hours, COUNT(DISTINCT t.user_id) as rd_count FROM clients c JOIN users s ON c.sales_id = s.id LEFT JOIN timesheets t ON c.id = t.client_id`; let cond1 = '', params1 = []; if (start_date || end_date) { cond1 += ' AND (t.work_date IS NULL OR (1=1'; if (start_date) { cond1 += ' AND t.work_date >= ?'; params1.push(start_date); } if (end_date) { cond1 += ' AND t.work_date <= ?'; params1.push(end_date); } cond1 += '))'; } sql1 += cond1 + ' GROUP BY s.id, c.id ORDER BY s.realname, total_hours DESC'; const data1 = db.all(sql1, params1).map(r => [r.sales_name, r.client_name, r.total_hours, r.rd_count]); sheets.push({ sheetName: '销售汇总', headers: ['销售人员', '客户/项目', '研发总耗时(h)', '参与研发人数'], data: data1 }); let sql2 = `SELECT s.realname as sales_name, rd.realname as rd_name, SUM(t.hours) as total_hours, COUNT(DISTINCT t.work_date) as work_days FROM timesheets t JOIN clients c ON t.client_id = c.id JOIN users s ON c.sales_id = s.id JOIN users rd ON t.user_id = rd.id WHERE 1=1 ${tCond} GROUP BY s.id, rd.id ORDER BY s.realname, total_hours DESC`; const data2 = db.all(sql2, tParams).map(r => [r.sales_name, r.rd_name, r.total_hours, r.work_days]); sheets.push({ sheetName: '销售占用研发明细', headers: ['销售人员', '研发人员', '消耗总工时(h)', '工作天数'], data: data2 }); filename = `销售视角_${start_date || '开始'}至${end_date || '至今'}`; } else if (type === 'rd') { let sql = `SELECT u.realname as user_name, c.name || CASE WHEN IFNULL(t.project_type, '') != '' THEN '-' || t.project_type ELSE '' END as client_name, t.work_date, t.hours, t.remark FROM timesheets t JOIN users u ON t.user_id = u.id JOIN clients c ON t.client_id = c.id WHERE 1=1 ${tCond} ORDER BY u.realname, t.work_date DESC`; const data = db.all(sql, tParams).map(r => [r.user_name, r.client_name, r.work_date, r.hours, r.remark]); sheets.push({ sheetName: '研发填报明细', headers: ['研发人员', '客户/项目', '日期', '工时(h)', '备注'], data: data }); filename = `研发视角_${start_date || '开始'}至${end_date || '至今'}`; } else if (type === 'project') { let sql1 = `SELECT c.name as client_name, s.realname as sales_name, c.status, COALESCE(SUM(t.hours), 0) as total_hours, COUNT(DISTINCT t.user_id) as rd_count FROM clients c LEFT JOIN timesheets t ON c.id = t.client_id LEFT JOIN users s ON c.sales_id = s.id WHERE 1=1`; let cond1 = '', params1 = []; if (sales_id) { cond1 += ' AND s.id = ?'; params1.push(parseInt(sales_id)); } if (start_date || end_date) { cond1 += ' AND (t.work_date IS NULL OR (1=1'; if (start_date) { cond1 += ' AND t.work_date >= ?'; params1.push(start_date); } if (end_date) { cond1 += ' AND t.work_date <= ?'; params1.push(end_date); } cond1 += '))'; } sql1 += cond1 + ' GROUP BY c.id ORDER BY total_hours DESC'; const data1 = db.all(sql1, params1).map(r => [r.client_name, r.sales_name, r.status === 1 ? '进行中' : '已结算', r.total_hours, r.rd_count]); sheets.push({ sheetName: '项目总览', headers: ['客户/项目', '所属销售', '状态', '总工时(h)', '参与研发人数'], data: data1 }); let sql2 = `SELECT c.name as client_name, s.realname as sales_name, rd.realname as rd_name, SUM(t.hours) as total_hours, COUNT(DISTINCT t.work_date) as work_days, MIN(t.work_date) as first_date, MAX(t.work_date) as last_date FROM timesheets t JOIN clients c ON t.client_id = c.id JOIN users s ON c.sales_id = s.id JOIN users rd ON t.user_id = rd.id WHERE 1=1 ${tCond}`; let params2 = [...tParams]; if (sales_id) { sql2 += ' AND s.id = ?'; params2.push(parseInt(sales_id)); } sql2 += ' GROUP BY c.id, rd.id ORDER BY c.name, total_hours DESC'; const data2 = db.all(sql2, params2).map(r => [r.client_name, r.sales_name, r.rd_name, r.total_hours, r.work_days, r.first_date, r.last_date]); sheets.push({ sheetName: '项目占用研发明细', headers: ['客户/项目', '所属销售', '研发人员', '消耗总工时(h)', '工作天数', '首次介入', '最近介入'], data: data2 }); filename = `项目视角_${start_date || '开始'}至${end_date || '至今'}`; } return { sheets, filename }; } function createSheet(headers, data) { const wsData = [headers, ...data]; const ws = XLSX.utils.aoa_to_sheet(wsData); const colWidths = headers.map((h, i) => { let max = h.length * 2; data.forEach(row => { max = Math.max(max, String(row[i] || '').length * 1.5); }); return { wch: Math.min(max + 2, 40) }; }); ws['!cols'] = colWidths; return ws; } // Helper endpoints for dropdowns router.get('/salesusers', (req, res) => { const db = getDB(req); res.json({ code: 200, data: db.all("SELECT id, realname FROM users WHERE role = 'sales' AND status = 1") }); }); router.get('/rdusers', (req, res) => { const db = getDB(req); res.json({ code: 200, data: db.all("SELECT id, realname FROM users WHERE role = 'rd' AND status = 1") }); }); // ======================== // Project Type Tags // ======================== router.get('/project-types', (req, res) => { const db = getDB(req); res.json({ code: 200, data: db.all('SELECT id, name FROM project_types ORDER BY id') }); }); router.post('/project-types', (req, res) => { const db = getDB(req); const { name } = req.body; if (!name || !name.trim()) return res.status(400).json({ code: 400, message: '标签名不能为空' }); const existing = db.get('SELECT id FROM project_types WHERE name = ?', [name.trim()]); if (existing) return res.status(400).json({ code: 400, message: '标签已存在' }); const result = db.run('INSERT INTO project_types (name) VALUES (?)', [name.trim()]); res.json({ code: 200, message: '创建成功', data: { id: result.lastInsertRowid, name: name.trim() } }); }); router.delete('/project-types/:id', (req, res) => { const db = getDB(req); const { id } = req.params; const pt = db.get('SELECT id FROM project_types WHERE id = ?', [parseInt(id)]); if (!pt) return res.status(404).json({ code: 404, message: '标签不存在' }); db.run('DELETE FROM project_types WHERE id = ?', [parseInt(id)]); res.json({ code: 200, message: '删除成功' }); }); // ======================== // Matrix Summary (研发工时汇总) // rows = 项目-子项目, cols = 研发人员 // ======================== router.get('/summary-matrix', (req, res) => { const db = getDB(req); const { start_date, end_date } = req.query; let sql = `SELECT u.id as user_id, u.realname as user_name, c.name as client_name, COALESCE(t.project_type, '') as sub_project, SUM(t.hours) as hours FROM timesheets t JOIN users u ON t.user_id = u.id JOIN clients c ON t.client_id = c.id WHERE 1=1`; const params = []; if (start_date) { sql += ' AND t.work_date >= ?'; params.push(start_date); } if (end_date) { sql += ' AND t.work_date <= ?'; params.push(end_date); } sql += ' GROUP BY u.id, c.id, t.project_type ORDER BY c.name, t.project_type, u.realname'; const rows = db.all(sql, params); res.json({ code: 200, data: rows }); }); router.get('/export/matrix', (req, res) => { const db = getDB(req); const { start_date, end_date } = req.query; let sql = `SELECT u.realname as user_name, c.name as client_name, COALESCE(t.project_type, '') as sub_project, SUM(t.hours) as hours FROM timesheets t JOIN users u ON t.user_id = u.id JOIN clients c ON t.client_id = c.id WHERE 1=1`; const params = []; if (start_date) { sql += ' AND t.work_date >= ?'; params.push(start_date); } if (end_date) { sql += ' AND t.work_date <= ?'; params.push(end_date); } sql += ' GROUP BY u.realname, c.name, t.project_type'; const rows = db.all(sql, params); // Build matrix const rowKeys = [...new Set(rows.map(r => r.sub_project ? `${r.client_name}-${r.sub_project}` : r.client_name))]; const colKeys = [...new Set(rows.map(r => r.user_name))].sort(); const cellMap = {}; rows.forEach(r => { const rk = r.sub_project ? `${r.client_name}-${r.sub_project}` : r.client_name; if (!cellMap[rk]) cellMap[rk] = {}; cellMap[rk][r.user_name] = (cellMap[rk][r.user_name] || 0) + r.hours; }); const headers = ['项目/子项目', ...colKeys, '合计']; const data = rowKeys.map(rk => { const rowTotal = colKeys.reduce((s, c) => s + (cellMap[rk]?.[c] || 0), 0); return [rk, ...colKeys.map(c => cellMap[rk]?.[c] || 0), rowTotal]; }); const colTotals = ['合计', ...colKeys.map(c => data.reduce((s, r) => s + r[colKeys.indexOf(c) + 1], 0)), data.reduce((s, r) => s + r[r.length - 1], 0)]; data.push(colTotals); const wb = XLSX.utils.book_new(); const ws = createSheet(headers, data); XLSX.utils.book_append_sheet(wb, ws, '研发工时汇总'); const filename = `研发工时汇总_${start_date || '开始'}至${end_date || '至今'}`; const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Disposition', `attachment; filename=${encodeURIComponent(filename)}.xlsx`); res.send(Buffer.from(buf)); }); // ======================== // Admin: Reset Data // ======================== router.post('/reset', (req, res) => { const db = getDB(req); db.run('PRAGMA foreign_keys = OFF'); db.run('DELETE FROM timesheets'); db.run('DELETE FROM clients'); db.run('PRAGMA foreign_keys = ON'); res.json({ code: 200, message: '所有项目及工时数据已清除,人员账号保留。' }); }); // ======================== // Admin: Backup & Restore // ======================== const path = require('path'); const fs = require('fs'); const BACKUP_DIR = path.join(__dirname, '..', 'backups'); function ensureBackupDir() { if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true }); } function buildBackupJson(db) { const users = db.all('SELECT * FROM users'); const clients = db.all('SELECT * FROM clients'); const timesheets = db.all('SELECT * FROM timesheets'); return JSON.stringify({ version: 1, created_at: new Date().toISOString(), users, clients, timesheets }, null, 2); } // Manual backup download router.get('/backup/download', (req, res) => { const db = getDB(req); const json = buildBackupJson(db); const date = new Date().toISOString().slice(0, 10); res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Disposition', `attachment; filename=backup-${date}.json`); res.send(json); }); // List auto-backup files router.get('/backups', (req, res) => { ensureBackupDir(); const files = fs.readdirSync(BACKUP_DIR) .filter(f => f.endsWith('.json')) .map(f => { const stat = fs.statSync(path.join(BACKUP_DIR, f)); return { name: f, size: stat.size, created_at: stat.mtime.toISOString() }; }) .sort((a, b) => b.name.localeCompare(a.name)); res.json({ code: 200, data: files }); }); // Download a specific auto-backup router.get('/backups/:filename', (req, res) => { ensureBackupDir(); const filePath = path.join(BACKUP_DIR, req.params.filename); if (!fs.existsSync(filePath)) return res.status(404).json({ code: 404, message: '文件不存在' }); res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Disposition', `attachment; filename=${req.params.filename}`); res.sendFile(filePath); }); // Restore from JSON router.post('/restore', (req, res) => { const db = getDB(req); const { users, clients, timesheets } = req.body; if (!users || !clients || !timesheets) { return res.status(400).json({ code: 400, message: '备份文件格式不正确,缺少必要字段' }); } try { db.run('PRAGMA foreign_keys = OFF'); db.run('DELETE FROM timesheets'); db.run('DELETE FROM clients'); db.run('DELETE FROM users'); for (const u of users) { db.run('INSERT OR IGNORE INTO users (id, username, password, realname, role, status, created_at) VALUES (?,?,?,?,?,?,?)', [u.id, u.username, u.password, u.realname, u.role, u.status, u.created_at]); } for (const c of clients) { db.run('INSERT OR IGNORE INTO clients (id, name, description, sales_id, status, service_types, created_at) VALUES (?,?,?,?,?,?,?)', [c.id, c.name, c.description || '', c.sales_id, c.status, c.service_types || '[]', c.created_at]); } for (const t of timesheets) { db.run('INSERT OR IGNORE INTO timesheets (id, user_id, client_id, work_date, hours, remark, service_type, project_type, created_at) VALUES (?,?,?,?,?,?,?,?,?)', [t.id, t.user_id, t.client_id, t.work_date, t.hours, t.remark || '', t.service_type || '售前', t.project_type || '', t.created_at]); } db.run('PRAGMA foreign_keys = ON'); res.json({ code: 200, message: `恢复成功:${users.length} 名用户、${clients.length} 个项目、${timesheets.length} 条工时记录` }); } catch (e) { db.run('PRAGMA foreign_keys = ON'); res.status(500).json({ code: 500, message: '恢复失败:' + e.message }); } }); module.exports = router; module.exports.buildBackupJson = buildBackupJson; module.exports.BACKUP_DIR = BACKUP_DIR; module.exports.ensureBackupDir = ensureBackupDir;