Files
CrmSystem/server.js

1045 lines
38 KiB
JavaScript
Raw Permalink Normal View History

2026-01-23 21:56:02 +08:00
const path = require('path');
const express = require('express');
const cors = require('cors');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const sqlite3 = require('sqlite3').verbose();
const PORT = process.env.PORT || 3000;
const JWT_SECRET = process.env.JWT_SECRET || 'CHANGE_ME_TO_A_RANDOM_SECRET';
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data.sqlite');
const app = express();
app.use(cors());
app.use(express.json());
// ===== DB =====
const db = new sqlite3.Database(DB_PATH);
function run(sql, params = []) {
return new Promise((resolve, reject) => {
db.run(sql, params, function (err) {
if (err) return reject(err);
resolve({ lastID: this.lastID, changes: this.changes });
});
});
}
function get(sql, params = []) {
return new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => {
if (err) return reject(err);
resolve(row);
});
});
}
function all(sql, params = []) {
return new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) return reject(err);
resolve(rows);
});
});
}
async function initDb() {
await run('PRAGMA foreign_keys = ON;');
await run(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
name TEXT,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','leader','staff')),
phone TEXT,
email TEXT,
status TEXT NOT NULL DEFAULT 'active',
creator_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(creator_id) REFERENCES users(id)
);
`);
await run(`
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
staff_id INTEGER NOT NULL,
regDate TEXT, -- YYYY-MM-DD
customerName TEXT NOT NULL,
phone TEXT,
expireDate TEXT, -- YYYY-MM-DD
amount REAL NOT NULL DEFAULT 0,
serviceType TEXT,
remark TEXT,
lastRenewAt TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY(staff_id) REFERENCES users(id)
);
`);
const admin = await get('SELECT * FROM users WHERE username = ?', ['admin']);
if (!admin) {
const hash = await bcrypt.hash('admin123456', 10);
await run(
'INSERT INTO users(username, name, password_hash, role, status) VALUES(?,?,?,?,?)',
['admin', '老板', hash, 'admin', 'active']
);
console.log('已创建默认管理员账号xs / xs123456...');
}
const staff = await get('SELECT * FROM users WHERE username = ?', ['staff1']);
if (!staff) {
const hash = await bcrypt.hash('staff123456', 10);
await run(
'INSERT INTO users(username, name, password_hash, role, status) VALUES(?,?,?,?,?)',
['staff1', '员工1', hash, 'staff', 'active']
);
console.log('已创建默认员工账号staff1 / staff123456');
}
// 创建默认组长账号
const leader = await get('SELECT * FROM users WHERE username = ?', ['leader1']);
if (!leader) {
const hash = await bcrypt.hash('leader123456', 10);
await run(
'INSERT INTO users(username, name, password_hash, role, status) VALUES(?,?,?,?,?)',
['leader1', '组长1', hash, 'leader', 'active']
);
console.log('已创建默认组长账号leader1 / leader123456');
}
}
function asyncHandler(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}
// ===== Auth middleware =====
function authRequired(req, res, next) {
const auth = req.headers.authorization || '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (!token) return res.status(401).json({ message: '未登录' });
try {
const payload = jwt.verify(token, JWT_SECRET);
req.user = payload; // {id, username, role, name}
next();
} catch (e) {
return res.status(401).json({ message: '登录已失效,请重新登录' });
}
}
function roleRequired(roles) {
return (req, res, next) => {
if (!req.user) return res.status(401).json({ message: '未登录' });
if (!roles.includes(req.user.role)) return res.status(403).json({ message: '无权限' });
next();
};
}
// ===== date helpers =====
function parseDateValue(value) {
if (!value) return null;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return d;
}
function isExpired(expireDate) {
const d = parseDateValue(expireDate);
if (!d) return false;
return d.getTime() < Date.now();
}
function isExpiringSoon(expireDate, days = 7) {
const d = parseDateValue(expireDate);
if (!d) return false;
const diff = d.getTime() - Date.now();
return diff >= 0 && diff <= days * 24 * 60 * 60 * 1000;
}
// ===== Common: build client status (for response) =====
function getClientStatus(expireDate, days = 7) {
if (isExpired(expireDate)) return 'expired';
if (isExpiringSoon(expireDate, days)) return 'expiring';
return 'active';
}
// ===== API: Auth =====
app.post('/api/auth/login', asyncHandler(async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) return res.status(400).json({ message: '缺少账号或密码' });
const user = await get('SELECT * FROM users WHERE username = ?', [username]);
if (!user) return res.status(401).json({ message: '账号或密码错误' });
if (user.status === 'inactive') return res.status(403).json({ message: '该账号已被禁用,请联系管理员' });
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) return res.status(401).json({ message: '账号或密码错误' });
const token = jwt.sign(
{ id: user.id, username: user.username, role: user.role, name: user.name },
JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({
token,
user: { id: user.id, username: user.username, name: user.name, role: user.role }
});
}));
app.get('/api/auth/me', authRequired, asyncHandler(async (req, res) => {
const u = await get(
'SELECT id, username, name, role, phone, email, status FROM users WHERE id=?',
[req.user.id]
);
res.json({ user: u });
}));
// ======================================================================
// ✅ 新增Dashboard Summary总览卡片 + 员工统计)
// - admin返回全局 stats + staffStats 列表
// - staff返回自己的 stats不返回全部员工
// ======================================================================
app.get('/api/dashboard/summary', authRequired, asyncHandler(async (req, res) => {
const days = Number(req.query.days || 7);
// 取所有客户admin or 取自己的客户staff
let rows;
if (req.user.role === 'admin') {
rows = await all(`SELECT staff_id, expireDate, amount FROM clients`);
} else {
rows = await all(`SELECT staff_id, expireDate, amount FROM clients WHERE staff_id = ?`, [req.user.id]);
}
let totalAmount = 0;
let totalClients = rows.length;
let expiringCount = 0;
let expiredCount = 0;
for (const r of rows) {
totalAmount += Number(r.amount || 0);
if (isExpired(r.expireDate)) expiredCount += 1;
else if (isExpiringSoon(r.expireDate, days)) expiringCount += 1;
}
const stats = { totalAmount, totalClients, expiringCount, expiredCount };
if (req.user.role !== 'admin') {
return res.json({ stats });
}
// admin补员工统计
const staffRows = await all(`
SELECT u.id, u.username, u.name, u.phone, u.email, u.status, u.created_at, u.creator_id, c.name as creator_name, c.username as creator_username
FROM users u
LEFT JOIN users c ON u.creator_id = c.id
WHERE u.role='staff'
ORDER BY u.id ASC
`);
const map = new Map();
staffRows.forEach(s => {
map.set(s.id, {
id: s.id,
username: s.username,
name: s.name,
phone: s.phone,
email: s.email,
status: s.status,
created_at: s.created_at,
creator_id: s.creator_id,
creator_name: s.creator_name || s.creator_username || 'Admin',
clients: 0,
total: 0,
expired: 0,
expiring: 0
});
});
rows.forEach(c => {
const t = map.get(c.staff_id);
if (!t) return;
t.clients += 1;
t.total += Number(c.amount || 0);
if (isExpired(c.expireDate)) t.expired += 1;
else if (isExpiringSoon(c.expireDate, days)) t.expiring += 1;
});
res.json({ stats, staffStats: Array.from(map.values()) });
}));
// ======================================================================
// ✅ 新增Staff 通用接口(更贴近前端)
// - GET /api/staff admin:员工列表(含聚合) leader:员工列表(不含聚合) staff:返回自己
// - POST /api/staff admin/leader:新增员工
// - PUT /api/staff/:id admin/leader:编辑员工
// - DELETE /api/staff/:id admin/leader:禁用员工
// ======================================================================
app.get('/api/staff', authRequired, asyncHandler(async (req, res) => {
// staff 只能看自己
if (req.user.role === 'staff') {
const me = await get(`
SELECT id, username, name, phone, email, status, created_at
FROM users
WHERE id=? AND status != 'inactive'
`, [req.user.id]);
if (!me) {
return res.json({ staff: [] });
}
return res.json({ staff: [me] });
}
// admin 和 leader 都可以查看员工列表
// admin 可以看到所有 staff 和 leaderleader 只能看自己创建的 staff
const includeInactive = req.query.includeInactive === 'true';
const statusFilter = includeInactive ? '' : "AND u.status != 'inactive'";
let staffRows;
if (req.user.role === 'admin') {
staffRows = await all(`
SELECT u.id, u.username, u.name, u.phone, u.email, u.status, u.role, u.creator_id, u.created_at
FROM users u
WHERE u.role IN ('staff', 'leader') ${statusFilter}
ORDER BY u.status ASC, u.id ASC
`);
} else {
// leader 只能看自己创建的 staff
staffRows = await all(`
SELECT u.id, u.username, u.name, u.phone, u.email, u.status, u.role, u.creator_id, u.created_at
FROM users u
WHERE u.role = 'staff' AND u.creator_id = ? ${statusFilter}
ORDER BY u.status ASC, u.id ASC
`, [req.user.id]);
}
// leader 也返回员工的客户统计数据
if (req.user.role === 'leader') {
const staffIds = staffRows.map(s => s.id);
if (staffIds.length === 0) {
return res.json({ staff: [] });
}
const clientRows = await all(`SELECT staff_id, expireDate, amount FROM clients WHERE staff_id IN (${staffIds.join(',')})`);
const byStaff = new Map();
staffRows.forEach((s) => {
byStaff.set(s.id, {
...s,
clientsCount: 0,
totalAmount: 0,
expiredCount: 0,
expiringCount: 0
});
});
const now = new Date();
const expireThreshold = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
clientRows.forEach((c) => {
const staff = byStaff.get(c.staff_id);
if (!staff) return;
staff.clientsCount++;
staff.totalAmount += c.amount || 0;
if (c.expireDate) {
const exp = new Date(c.expireDate);
if (exp < now) staff.expiredCount++;
else if (exp <= expireThreshold) staff.expiringCount++;
}
});
return res.json({ staff: Array.from(byStaff.values()) });
}
// admin 返回完整统计数据
const clientRows = await all('SELECT staff_id, expireDate, amount FROM clients');
const byStaff = new Map();
staffRows.forEach((s) => {
byStaff.set(s.id, {
...s,
clientsCount: 0,
totalAmount: 0,
expiredCount: 0,
expiringCount: 0
});
});
clientRows.forEach((c) => {
const target = byStaff.get(c.staff_id);
if (!target) return;
target.clientsCount += 1;
target.totalAmount += Number(c.amount || 0);
if (isExpired(c.expireDate)) target.expiredCount += 1;
if (isExpiringSoon(c.expireDate)) target.expiringCount += 1;
});
res.json({ staff: Array.from(byStaff.values()) });
}));
app.post('/api/staff', authRequired, roleRequired(['admin', 'leader']), asyncHandler(async (req, res) => {
const b = req.body || {};
if (!b.username || !b.password || !b.name) {
return res.status(400).json({ message: '用户名、姓名和密码不能为空' });
}
const existing = await get('SELECT id FROM users WHERE username = ?', [b.username]);
if (existing) return res.status(400).json({ message: '用户名已存在' });
// 只有 admin 可以创建 leaderleader 只能创建 staff
let role = 'staff';
if (b.role === 'leader' && req.user.role === 'admin') {
role = 'leader';
}
// 记录创建者 ID
const creatorId = req.user.id;
const hash = await bcrypt.hash(b.password, 10);
const r = await run(
'INSERT INTO users(username, name, password_hash, role, phone, email, status, creator_id) VALUES(?,?,?,?,?,?,?,?)',
[b.username, b.name, hash, role, b.phone || null, b.email || null, b.status || 'active', creatorId]
);
const row = await get('SELECT id, username, name, phone, email, status, role, creator_id, created_at FROM users WHERE id=?', [r.lastID]);
res.json({ staff: row });
}));
app.put('/api/staff/:id', authRequired, roleRequired(['admin', 'leader']), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!id) return res.status(400).json({ message: '参数错误' });
const existing = await get('SELECT * FROM users WHERE id=?', [id]);
if (!existing) return res.status(404).json({ message: '用户不存在' });
// 权限检查
if (existing.role === 'admin') return res.status(403).json({ message: '不能操作管理员账号' });
if (existing.role === 'leader' && req.user.role !== 'admin') return res.status(403).json({ message: '无权操作组长' });
// 组长只能操作自己创建的员工
if (req.user.role === 'leader' && existing.creator_id !== req.user.id) {
return res.status(403).json({ message: '无权操作此员工' });
}
const b = req.body || {};
const updates = [];
const params = [];
const fields = ['name', 'phone', 'email', 'username']; // 允许修改用户名
for (const f of fields) {
if (Object.prototype.hasOwnProperty.call(b, f)) {
// 如果修改用户名,检查唯一性
if (f === 'username' && b.username !== existing.username) {
const uCheck = await get('SELECT id FROM users WHERE username = ?', [b.username]);
if (uCheck) return res.status(400).json({ message: '用户名已存在' });
}
updates.push(`${f} = ?`);
params.push(b[f]);
}
}
// 只有 admin 可以修改角色
if (req.user.role === 'admin' && b.role && ['leader', 'staff'].includes(b.role)) {
updates.push('role = ?');
params.push(b.role);
}
// 只有 admin 可以修改所属组长 (creator_id)
// 传入 id 为 leader id如果为 'admin' 或空则设为当前 admin id
if (req.user.role === 'admin' && Object.prototype.hasOwnProperty.call(b, 'creator_id')) {
let newCreatorId = Number(b.creator_id);
if (!newCreatorId) newCreatorId = req.user.id; // 默认为当前操作者admin
// 验证一下是否存在且是 leader/admin
if (newCreatorId !== req.user.id) {
const leaderCheck = await get('SELECT id, role FROM users WHERE id=?', [newCreatorId]);
if (!leaderCheck) return res.status(400).json({ message: '指定的组长不存在' });
}
updates.push('creator_id = ?');
params.push(newCreatorId);
}
if (b.password) {
const hash = await bcrypt.hash(b.password, 10);
updates.push('password_hash = ?');
params.push(hash);
}
if (updates.length > 0) {
params.push(id);
await run(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params);
}
const row = await get('SELECT id, username, name, phone, email, status, role, creator_id, created_at FROM users WHERE id=?', [id]);
res.json({ staff: row });
}));
app.post('/api/staff/:id/reset_password', authRequired, roleRequired(['admin', 'leader']), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!id) return res.status(400).json({ message: '参数错误' });
const b = req.body || {};
if (!b.password) return res.status(400).json({ message: '新密码不能为空' });
const existing = await get('SELECT id, role, creator_id FROM users WHERE id=?', [id]);
if (!existing) return res.status(404).json({ message: '用户不存在' });
if (existing.role === 'admin') return res.status(403).json({ message: '不能操作管理员账号' });
if (existing.role === 'leader' && req.user.role !== 'admin') return res.status(403).json({ message: '无权操作组长' });
// 组长只能操作自己创建的员工
if (req.user.role === 'leader' && existing.creator_id !== req.user.id) {
return res.status(403).json({ message: '无权操作此员工' });
}
const hash = await bcrypt.hash(b.password, 10);
await run('UPDATE users SET password_hash = ? WHERE id = ?', [hash, id]);
res.json({ success: true });
}));
app.delete('/api/staff/:id', authRequired, roleRequired(['admin', 'leader']), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!id) return res.status(400).json({ message: '参数错误' });
const existing = await get('SELECT * FROM users WHERE id=?', [id]);
if (!existing) return res.status(404).json({ message: '用户不存在' });
if (existing.role === 'admin') return res.status(403).json({ message: '不能操作管理员账号' });
if (existing.role === 'leader' && req.user.role !== 'admin') return res.status(403).json({ message: '无权操作组长' });
// 组长只能操作自己创建的员工
if (req.user.role === 'leader' && existing.creator_id !== req.user.id) {
return res.status(403).json({ message: '无权操作此员工' });
}
await run('UPDATE users SET status = \'inactive\' WHERE id = ?', [id]);
res.json({ success: true });
}));
// 启用/禁用用户状态切换
app.post('/api/staff/:id/toggle_status', authRequired, roleRequired(['admin', 'leader']), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!id) return res.status(400).json({ message: '参数错误' });
const existing = await get('SELECT * FROM users WHERE id=?', [id]);
if (!existing) return res.status(404).json({ message: '用户不存在' });
if (existing.role === 'admin') return res.status(403).json({ message: '不能操作管理员账号' });
if (existing.role === 'leader' && req.user.role !== 'admin') return res.status(403).json({ message: '无权操作组长' });
// 组长只能操作自己创建的员工
if (req.user.role === 'leader' && existing.creator_id !== req.user.id) {
return res.status(403).json({ message: '无权操作此员工' });
}
const newStatus = existing.status === 'active' ? 'inactive' : 'active';
await run('UPDATE users SET status = ? WHERE id = ?', [newStatus, id]);
res.json({ success: true, status: newStatus });
}));
// ======================================================================
// ✅ 改造Clients 列表支持筛选/搜索/分页(你前端筛选栏 & 客户页需要)
// GET /api/clients
// query:
// staffId / staffName(可选) / status(active|expiring|expired) / serviceType / q
// dateFrom / dateTo (按 regDate) / page / pageSize / days(默认7,expiring阈值)
// ======================================================================
app.get('/api/clients', authRequired, asyncHandler(async (req, res) => {
const {
staffId,
status = 'all',
serviceType = 'all',
q = '',
dateFrom,
dateTo,
page = 1,
pageSize = 20,
days = 7
} = req.query;
const p = Math.max(1, Number(page || 1));
const ps = Math.min(200, Math.max(1, Number(pageSize || 20)));
const expDays = Number(days || 7);
const where = [];
const params = [];
// 权限限制
if (req.user.role === 'staff') {
// 员工只能看自己的客户
where.push('c.staff_id = ?');
params.push(req.user.id);
} else if (req.user.role === 'leader') {
// 组长只能看自己创建的员工的客户
if (staffId && staffId !== 'all') {
// 验证该员工是否属于此组长
const staffUser = await get('SELECT id, creator_id FROM users WHERE id = ?', [Number(staffId)]);
if (!staffUser || staffUser.creator_id !== req.user.id) {
return res.status(403).json({ message: '无权查看此员工客户' });
}
where.push('c.staff_id = ?');
params.push(Number(staffId));
} else {
// 获取此组长创建的所有员工 ID
const ownStaff = await all('SELECT id FROM users WHERE creator_id = ? AND role = ?', [req.user.id, 'staff']);
const staffIds = ownStaff.map(s => s.id);
if (staffIds.length === 0) {
return res.json({ clients: [], pagination: { page: 1, pageSize: ps, total: 0, totalPages: 0 } });
}
where.push(`c.staff_id IN (${staffIds.join(',')})`);
}
} else if (staffId && staffId !== 'all') {
// admin 可以看任何员工
where.push('c.staff_id = ?');
params.push(Number(staffId));
}
if (serviceType && serviceType !== 'all') {
where.push('c.serviceType = ?');
params.push(serviceType);
}
if (q && String(q).trim()) {
const like = `%${String(q).trim()}%`;
where.push('(c.customerName LIKE ? OR c.phone LIKE ? OR c.remark LIKE ?)');
params.push(like, like, like);
}
// regDate 范围筛选
if (dateFrom) {
where.push('date(c.regDate) >= date(?)');
params.push(dateFrom);
}
if (dateTo) {
where.push('date(c.regDate) <= date(?)');
params.push(dateTo);
}
// 状态筛选expireDate
// 注意expireDate 允许为空,空则默认当 active
if (status === 'expired') {
where.push("c.expireDate IS NOT NULL AND date(c.expireDate) < date('now')");
} else if (status === 'expiring') {
where.push(`c.expireDate IS NOT NULL AND date(c.expireDate) >= date('now') AND date(c.expireDate) <= date('now', ?)`);
params.push(`+${expDays} day`);
} else if (status === 'active') {
// active: expireDate 为空 或 expireDate > now+days 或 expireDate 在未来但>days
// 简化not expired and not expiring
where.push(`(
c.expireDate IS NULL
OR date(c.expireDate) > date('now', ?)
)`);
params.push(`+${expDays} day`);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
// 总数
const totalRow = await get(`SELECT COUNT(1) AS cnt FROM clients c ${whereSql}`, params);
const total = totalRow ? totalRow.cnt : 0;
// 分页数据
const offset = (p - 1) * ps;
const list = await all(
`
SELECT c.*, u.name AS staff_name, u.username AS staff_username
FROM clients c
LEFT JOIN users u ON u.id = c.staff_id
${whereSql}
ORDER BY c.id DESC
LIMIT ? OFFSET ?
`,
[...params, ps, offset]
);
// 补一个计算出来的 status 字段,前端更好用
const clients = list.map(row => ({
...row,
status: getClientStatus(row.expireDate, expDays)
}));
res.json({
page: p,
pageSize: ps,
total,
clients
});
}));
// ======================================================================
// Staff clients endpoints (for staff page)
// ======================================================================
app.get('/api/staff/clients', authRequired, roleRequired(['staff', 'admin']), asyncHandler(async (req, res) => {
const days = Number(req.query.days || 7);
const rows = await all(
`SELECT c.*
FROM clients c
WHERE c.staff_id = ?
ORDER BY c.id DESC`,
[req.user.id]
);
const clients = rows.map(row => ({ ...row, status: getClientStatus(row.expireDate, days) }));
res.json({ clients });
}));
app.post('/api/staff/clients', authRequired, roleRequired(['staff', 'admin']), asyncHandler(async (req, res) => {
const b = req.body || {};
if (!b.customerName) return res.status(400).json({ message: '客户姓名不能为空' });
const amount = Number(b.amount || 0);
const now = new Date().toISOString();
const r = await run(`
INSERT INTO clients(staff_id, regDate, customerName, phone, expireDate, amount, serviceType, remark, lastRenewAt, created_at, updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)
`, [
req.user.id,
b.regDate || null,
b.customerName,
b.phone || null,
b.expireDate || null,
amount,
b.serviceType || null,
b.remark || '',
b.lastRenewAt || null,
now,
now
]);
const row = await get('SELECT * FROM clients WHERE id=?', [r.lastID]);
res.json({ client: { ...row, status: getClientStatus(row.expireDate) } });
}));
app.put('/api/staff/clients/:id', authRequired, roleRequired(['staff', 'admin']), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!id) return res.status(400).json({ message: '参数错误' });
const existing = await get('SELECT * FROM clients WHERE id=?', [id]);
if (!existing) return res.status(404).json({ message: '客户不存在' });
if (req.user.role !== 'admin' && existing.staff_id !== req.user.id) {
return res.status(403).json({ message: '无权限修改该客户' });
}
const b = req.body || {};
const fields = ['regDate', 'customerName', 'phone', 'expireDate', 'amount', 'serviceType', 'remark', 'lastRenewAt'];
const updates = [];
const params = [];
for (const f of fields) {
if (!Object.prototype.hasOwnProperty.call(b, f)) continue;
updates.push(`${f} = ?`);
if (f === 'amount') params.push(Number(b[f] || 0));
else params.push(b[f]);
}
if (updates.length === 0) {
const row = await get('SELECT * FROM clients WHERE id=?', [id]);
return res.json({ client: { ...row, status: getClientStatus(row.expireDate) } });
}
updates.push('updated_at = ?');
params.push(new Date().toISOString());
params.push(id);
await run(`UPDATE clients SET ${updates.join(', ')} WHERE id = ?`, params);
const row = await get('SELECT * FROM clients WHERE id=?', [id]);
res.json({ client: { ...row, status: getClientStatus(row.expireDate) } });
}));
// New client
app.post('/api/clients', authRequired, roleRequired(['staff', 'admin']), asyncHandler(async (req, res) => {
const b = req.body || {};
const staffId = (req.user.role === 'admin' && b.staff_id) ? Number(b.staff_id) : req.user.id;
if (!b.customerName) return res.status(400).json({ message: '客户姓名不能为空' });
const amount = Number(b.amount || 0);
const now = new Date().toISOString();
const r = await run(`
INSERT INTO clients(staff_id, regDate, customerName, phone, expireDate, amount, serviceType, remark, lastRenewAt, created_at, updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)
`, [
staffId,
b.regDate || null,
b.customerName,
b.phone || null,
b.expireDate || null,
amount,
b.serviceType || null,
b.remark || '',
b.lastRenewAt || null,
now,
now
]);
const row = await get('SELECT * FROM clients WHERE id=?', [r.lastID]);
res.json({ client: { ...row, status: getClientStatus(row.expireDate) } });
}));
// 更新客户
app.put('/api/clients/:id', authRequired, asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!id) return res.status(400).json({ message: '参数错误' });
const existing = await get('SELECT * FROM clients WHERE id=?', [id]);
if (!existing) return res.status(404).json({ message: '客户不存在' });
if (req.user.role !== 'admin' && existing.staff_id !== req.user.id) {
return res.status(403).json({ message: '无权限修改该客户' });
}
const b = req.body || {};
const fields = ['regDate', 'customerName', 'phone', 'expireDate', 'amount', 'serviceType', 'remark', 'lastRenewAt', 'staff_id'];
const updates = [];
const params = [];
for (const f of fields) {
if (!Object.prototype.hasOwnProperty.call(b, f)) continue;
// staff_id 只有 admin 能改
if (f === 'staff_id' && req.user.role !== 'admin') continue;
updates.push(`${f} = ?`);
if (f === 'amount') params.push(Number(b[f] || 0));
else if (f === 'staff_id') params.push(Number(b[f]));
else params.push(b[f]);
}
if (updates.length === 0) return res.json({ client: { ...existing, status: getClientStatus(existing.expireDate) } });
updates.push('updated_at = ?');
params.push(new Date().toISOString());
params.push(id);
await run(`UPDATE clients SET ${updates.join(', ')} WHERE id = ?`, params);
const row = await get('SELECT * FROM clients WHERE id=?', [id]);
res.json({ client: { ...row, status: getClientStatus(row.expireDate) } });
}));
// ✅ 新增:删除客户(单个)
app.delete('/api/clients/:id', authRequired, asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!id) return res.status(400).json({ message: '参数错误' });
const existing = await get('SELECT * FROM clients WHERE id=?', [id]);
if (!existing) return res.status(404).json({ message: '客户不存在' });
if (req.user.role !== 'admin' && existing.staff_id !== req.user.id) {
return res.status(403).json({ message: '无权限删除该客户' });
}
await run('DELETE FROM clients WHERE id=?', [id]);
res.json({ success: true });
}));
// ======================================================================
// ✅ 新增:到期提醒列表(你左侧“到期提醒”页会用到)
// GET /api/reminders?type=expiring|expired&days=7&staffId=xxx
// ======================================================================
app.get('/api/reminders', authRequired, asyncHandler(async (req, res) => {
const type = req.query.type || 'expiring';
const days = Number(req.query.days || 7);
const staffId = req.query.staffId;
const where = [];
const params = [];
if (req.user.role !== 'admin') {
where.push('c.staff_id = ?');
params.push(req.user.id);
} else if (staffId && staffId !== 'all') {
where.push('c.staff_id = ?');
params.push(Number(staffId));
}
if (type === 'expired') {
where.push("c.expireDate IS NOT NULL AND date(c.expireDate) < date('now')");
} else {
where.push(`c.expireDate IS NOT NULL AND date(c.expireDate) >= date('now') AND date(c.expireDate) <= date('now', ?)`);
params.push(`+${days} day`);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const rows = await all(`
SELECT c.*, u.name AS staff_name, u.username AS staff_username
FROM clients c
LEFT JOIN users u ON u.id = c.staff_id
${whereSql}
ORDER BY date(c.expireDate) ASC, c.id DESC
`, params);
res.json({
type,
days,
clients: rows.map(r => ({ ...r, status: getClientStatus(r.expireDate, days) }))
});
}));
// ======================================================================
// ✅ 新增:导出客户 CSV简单实用Excel 也能打开)
// GET /api/export/clients?...(同 /api/clients 的筛选参数)
// ======================================================================
app.get('/api/export/clients', authRequired, asyncHandler(async (req, res) => {
// 复用 /api/clients 的筛选逻辑,但这里直接查全部,不分页
const {
staffId,
status = 'all',
serviceType = 'all',
q = '',
dateFrom,
dateTo,
days = 7
} = req.query;
const expDays = Number(days || 7);
const where = [];
const params = [];
if (req.user.role !== 'admin') {
where.push('c.staff_id = ?');
params.push(req.user.id);
} else if (staffId && staffId !== 'all') {
where.push('c.staff_id = ?');
params.push(Number(staffId));
}
if (serviceType && serviceType !== 'all') {
where.push('c.serviceType = ?');
params.push(serviceType);
}
if (q && String(q).trim()) {
const like = `%${String(q).trim()}%`;
where.push('(c.customerName LIKE ? OR c.phone LIKE ? OR c.remark LIKE ?)');
params.push(like, like, like);
}
if (dateFrom) {
where.push('date(c.regDate) >= date(?)');
params.push(dateFrom);
}
if (dateTo) {
where.push('date(c.regDate) <= date(?)');
params.push(dateTo);
}
if (status === 'expired') {
where.push("c.expireDate IS NOT NULL AND date(c.expireDate) < date('now')");
} else if (status === 'expiring') {
where.push(`c.expireDate IS NOT NULL AND date(c.expireDate) >= date('now') AND date(c.expireDate) <= date('now', ?)`);
params.push(`+${expDays} day`);
} else if (status === 'active') {
where.push(`(
c.expireDate IS NULL
OR date(c.expireDate) > date('now', ?)
)`);
params.push(`+${expDays} day`);
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const rows = await all(`
SELECT c.*, u.name AS staff_name, u.username AS staff_username
FROM clients c
LEFT JOIN users u ON u.id = c.staff_id
${whereSql}
ORDER BY c.id DESC
`, params);
// CSV
const headers = [
'id', 'staff_name', 'staff_username', 'customerName', 'phone', 'serviceType', 'regDate', 'expireDate', 'amount', 'status', 'remark'
];
const escapeCsv = (v) => {
if (v === null || v === undefined) return '';
const s = String(v);
if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
};
const lines = [];
lines.push(headers.join(','));
for (const r of rows) {
const statusText = getClientStatus(r.expireDate, expDays);
const line = [
r.id,
r.staff_name || '',
r.staff_username || '',
r.customerName || '',
r.phone || '',
r.serviceType || '',
r.regDate || '',
r.expireDate || '',
r.amount ?? 0,
statusText,
r.remark || ''
].map(escapeCsv).join(',');
lines.push(line);
}
const csv = '\ufeff' + lines.join('\n'); // 带 BOMExcel 打开中文不乱码
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="clients_${Date.now()}.csv"`);
res.send(csv);
}));
// ======================================================================
// 兼容保留:你原来的 admin 接口(不想改前端也能用)
// ======================================================================
app.get('/api/admin/staff', authRequired, roleRequired(['admin']), asyncHandler(async (req, res) => {
// 直接代理到 /api/staff
req.user.role = 'admin';
return app._router.handle(req, res, () => { }, 'get', '/api/staff');
}));
app.post('/api/admin/staff', authRequired, roleRequired(['admin']), asyncHandler(async (req, res) => {
return app._router.handle(req, res, () => { }, 'post', '/api/staff');
}));
app.put('/api/admin/staff/:id', authRequired, roleRequired(['admin']), asyncHandler(async (req, res) => {
return app._router.handle(req, res, () => { }, 'put', `/api/staff/${req.params.id}`);
}));
app.delete('/api/admin/staff/:id', authRequired, roleRequired(['admin']), asyncHandler(async (req, res) => {
return app._router.handle(req, res, () => { }, 'delete', `/api/staff/${req.params.id}`);
}));
app.get('/api/admin/stats', authRequired, roleRequired(['admin']), asyncHandler(async (req, res) => {
// 兼容:用新 summary 的 stats
const fakeReq = { ...req, query: { ...req.query } };
// 直接再算一次(避免 hack router
const rows = await all('SELECT expireDate, amount FROM clients');
let totalAmount = 0;
let totalClients = rows.length;
let activeClients = 0;
let expiringSoonClients = 0;
for (const r of rows) {
totalAmount += Number(r.amount || 0);
if (!isExpired(r.expireDate)) activeClients += 1;
if (isExpiringSoon(r.expireDate)) expiringSoonClients += 1;
}
res.json({ stats: { totalAmount, totalClients, activeClients, expiringSoonClients } });
}));
// ===== 静态前端文件 =====
const publicDir = path.join(__dirname, 'public');
app.use(express.static(publicDir));
app.get('/', (req, res) => res.redirect('/index.html'));
// ===== error handler =====
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ message: '服务器错误' });
});
async function main() {
if (process.argv.includes('--initdb')) {
await initDb();
process.exit(0);
}
await initDb();
app.listen(PORT, () => {
console.log(`CRM running on http://0.0.0.0:${PORT}`);
});
}
main().catch(err => {
console.error(err);
process.exit(1);
});