Files
2026-08-13 14:29:10 +08:00

1404 lines
62 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const { v4: uuidv4 } = require('uuid');
const db = require('./database');
const app = express();
const PORT = process.env.PORT || 3001;
// 聊天附件存储目录
const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// multer 磁盘存储:文件名用 uuid + 安全扩展名,单文件上限 100MB
const MIME_EXT = {
'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp', 'image/bmp': '.bmp',
'video/mp4': '.mp4', 'video/webm': '.webm', 'video/quicktime': '.mov', 'video/x-msvideo': '.avi', 'video/x-matroska': '.mkv'
};
const upload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadsDir),
filename: (req, file, cb) => {
const rawExt = path.extname(file.originalname || '').toLowerCase();
const ext = MIME_EXT[file.mimetype] || (/^\.[a-z0-9]{1,8}$/.test(rawExt) ? rawExt : '.bin');
cb(null, uuidv4() + ext);
}
}),
limits: { fileSize: 100 * 1024 * 1024 }
});
// 内存会话存储
const sessions = new Map();
// 会话最近活跃时间(token -> 时间戳),用于判断在线状态
const sessionActivity = new Map();
// 最近活跃时间在此窗口内视为在线
const ONLINE_WINDOW_MS = 10 * 60 * 1000;
function touchSession(token) {
sessionActivity.set(token, Date.now());
}
function dropSession(token) {
sessions.delete(token);
sessionActivity.delete(token);
}
// 下线时间列(兼容旧库:已存在则忽略)
try {
db.prepare("ALTER TABLE users ADD COLUMN last_logout TEXT DEFAULT ''").run();
} catch (e) { /* 列已存在 */ }
// 收集当前在线用户(用户ID -> 最近活跃时间戳,取最新)
function getOnlineUserMap() {
const now = Date.now();
const map = new Map();
for (const [token, sessionUser] of sessions.entries()) {
const active = sessionActivity.get(token) || 0;
if (now - active <= ONLINE_WINDOW_MS) {
if (!map.has(sessionUser.id) || active > map.get(sessionUser.id)) {
map.set(sessionUser.id, active);
}
}
}
return map;
}
function formatActive(ts) {
const d = new Date(ts);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
// 登录验证中间件(仅对 /api 路由生效)
function authMiddleware(req, res, next) {
const publicPaths = ['/auth/login', '/auth/logout', '/weather'];
if (publicPaths.some(path => req.path.startsWith(path))) {
return next();
}
// 优先取请求头 token;img/video/下载链接无法携带 Authorization 头,允许查询参数回退
const token = req.headers.authorization?.replace('Bearer ', '') || req.query.token;
if (!token || !sessions.has(token)) {
return res.status(401).json({ success: false, message: '未登录或登录已过期' });
}
req.user = sessions.get(token);
touchSession(token);
next();
}
// 管理员权限中间件
function requireAdmin(req, res, next) {
if (req.user && req.user.role === 'admin') {
return next();
}
return res.status(403).json({ success: false, message: '权限不足,该操作仅管理员可用' });
}
app.use(cors());
app.use(express.json());
// API 认证中间件
app.use('/api', authMiddleware);
// 生产环境提供前端静态文件
const clientBuildPath = path.join(__dirname, 'client', 'dist');
app.use(express.static(clientBuildPath));
// ==================== 设备管理 API ====================
// 记录设备操作日志
function logDeviceAction(deviceId, deviceName, action, detail, operator) {
db.prepare(`
INSERT INTO device_logs (id, device_id, device_name, action, detail, operator)
VALUES (?, ?, ?, ?, ?, ?)
`).run(uuidv4(), deviceId, deviceName || '', action, detail || '', operator || '');
}
// ==================== 推送事件(客户端轮询拉取的原生推送源) ====================
// 定期清理 7 天前的推送事件
function cleanPushEvents() {
db.prepare("DELETE FROM push_events WHERE created_at < datetime('now','localtime','-7 day')").run();
}
cleanPushEvents();
setInterval(cleanPushEvents, 6 * 60 * 60 * 1000);
// 向指定用户写入一条推送事件
function pushTo(userId, kind, title, body) {
if (!userId) return;
db.prepare('INSERT INTO push_events (user_id, kind, title, body) VALUES (?, ?, ?, ?)')
.run(userId, kind, String(title || ''), String(body || '').slice(0, 200));
}
// 向多个用户推送(自动去重、跳过发送者)
function pushToMany(userIds, kind, title, body, excludeId) {
const seen = new Set();
for (const uid of userIds) {
if (!uid || uid === excludeId || seen.has(uid)) continue;
seen.add(uid);
pushTo(uid, kind, title, body);
}
}
// 向指定角色的全部用户推送
function pushToRole(role, kind, title, body, excludeId) {
const rows = db.prepare('SELECT id FROM users WHERE role = ?').all(role);
pushToMany(rows.map(r => r.id), kind, title, body, excludeId);
}
// 向全部用户推送
function pushToAll(kind, title, body, excludeId) {
const rows = db.prepare('SELECT id FROM users').all();
pushToMany(rows.map(r => r.id), kind, title, body, excludeId);
}
// 客户端拉取自己的新推送事件(after 游标为上次拉到的最大 id)
app.get('/api/push/events', (req, res) => {
const after = parseInt(req.query.after) || 0;
const rows = db.prepare('SELECT * FROM push_events WHERE user_id = ? AND id > ? ORDER BY id ASC LIMIT 50')
.all(req.user.id, after);
res.json({ success: true, data: rows });
});
// 写入维修记录
function addMaintenanceRecord(deviceId, deviceName, type, remark, operator) {
db.prepare(`
INSERT INTO maintenance_records (id, device_id, device_name, type, remark, operator)
VALUES (?, ?, ?, ?, ?, ?)
`).run(uuidv4(), deviceId, deviceName || '', type, remark || '', operator || '');
}
const getOperator = (req) => (req.user ? (req.user.display_name || req.user.username) : '');
// 获取所有设备
app.get('/api/devices', (req, res) => {
const { keyword, type, status } = req.query;
let sql = 'SELECT * FROM devices WHERE 1=1';
const params = [];
if (keyword) {
sql += ' AND (name LIKE ? OR serial_number LIKE ? OR location LIKE ?)';
params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
}
if (type) {
sql += ' AND type = ?';
params.push(type);
}
if (status) {
sql += ' AND status = ?';
params.push(status);
}
sql += ' ORDER BY created_at DESC';
const devices = db.prepare(sql).all(...params);
res.json({ success: true, data: devices });
});
// 报废设备列表(须定义在 /api/devices/:id 之前,避免路由遮蔽)
app.get('/api/devices/scrapped', (req, res) => {
const scrapped = db.prepare(
"SELECT * FROM devices WHERE status = '已报废' ORDER BY scrap_date DESC, updated_at DESC"
).all();
res.json({ success: true, data: scrapped });
});
// 设备操作日志(管理员)
app.get('/api/devices/logs', requireAdmin, (req, res) => {
const { device_id, keyword } = req.query;
let sql = 'SELECT * FROM device_logs WHERE 1=1';
const params = [];
if (device_id) {
sql += ' AND device_id = ?';
params.push(device_id);
}
if (keyword) {
sql += ' AND (device_name LIKE ? OR action LIKE ? OR detail LIKE ? OR operator LIKE ?)';
params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
}
sql += ' ORDER BY created_at DESC LIMIT 500';
res.json({ success: true, data: db.prepare(sql).all(...params) });
});
// 设备统计
app.get('/api/devices/stats/summary', (req, res) => {
const total = db.prepare('SELECT COUNT(*) as count FROM devices').get().count;
const normal = db.prepare("SELECT COUNT(*) as count FROM devices WHERE status='正常'").get().count;
const repairing = db.prepare("SELECT COUNT(*) as count FROM devices WHERE status='维修中'").get().count;
const scrapped = db.prepare("SELECT COUNT(*) as count FROM devices WHERE status='已报废'").get().count;
const idle = db.prepare("SELECT COUNT(*) as count FROM devices WHERE status='闲置'").get().count;
const scrappedThisMonth = db.prepare(
"SELECT COUNT(*) as count FROM devices WHERE status='已报废' AND substr(scrap_date,1,7) = substr(date('now','localtime'),1,7)"
).get().count;
const types = db.prepare('SELECT type, COUNT(*) as count FROM devices GROUP BY type').all();
res.json({ success: true, data: { total, normal, repairing, scrapped, idle, scrappedThisMonth, types } });
});
// 获取单个设备
app.get('/api/devices/:id', (req, res) => {
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (device) {
res.json({ success: true, data: device });
} else {
res.status(404).json({ success: false, message: '设备不存在' });
}
});
// 获取设备维修/保养记录
app.get('/api/devices/:id/records', (req, res) => {
const records = db.prepare(
'SELECT * FROM maintenance_records WHERE device_id = ? ORDER BY created_at DESC'
).all(req.params.id);
res.json({ success: true, data: records });
});
// 添加设备
app.post('/api/devices', (req, res) => {
const { name, type, model, serial_number, status, location, purchase_date, warranty_date, responsible_person, description } = req.body;
const id = uuidv4();
db.prepare(`
INSERT INTO devices (id, name, type, model, serial_number, status, location, purchase_date, warranty_date, responsible_person, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, name, type, model || '', serial_number || '', status || '正常', location || '', purchase_date || '', warranty_date || '', responsible_person || '', description || '');
logDeviceAction(id, name, '添加', '新设备入库', getOperator(req));
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id);
res.json({ success: true, data: device, message: '设备添加成功' });
});
// 更新设备
app.put('/api/devices/:id', requireAdmin, (req, res) => {
const { name, type, model, serial_number, status, location, purchase_date, warranty_date, responsible_person, description } = req.body;
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (!device) {
return res.status(404).json({ success: false, message: '设备不存在' });
}
db.prepare(`
UPDATE devices SET name=?, type=?, model=?, serial_number=?, status=?, location=?, purchase_date=?, warranty_date=?, responsible_person=?, description=?, updated_at=datetime('now','localtime')
WHERE id=?
`).run(name, type, model || '', serial_number || '', status, location || '', purchase_date || '', warranty_date || '', responsible_person || '', description || '', req.params.id);
logDeviceAction(req.params.id, name, '编辑', '设备信息已更新', getOperator(req));
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
res.json({ success: true, data: updated, message: '设备更新成功' });
});
// 删除设备(管理员)
app.delete('/api/devices/:id', requireAdmin, (req, res) => {
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (!device) {
return res.status(404).json({ success: false, message: '设备不存在' });
}
db.prepare('DELETE FROM devices WHERE id = ?').run(req.params.id);
db.prepare('DELETE FROM maintenance_records WHERE device_id = ?').run(req.params.id);
db.prepare('DELETE FROM device_logs WHERE device_id = ?').run(req.params.id);
res.json({ success: true, message: '设备删除成功' });
});
// 设备报修(普通用户+管理员):将状态置为维修中
app.patch('/api/devices/:id/repair', (req, res) => {
const { remark } = req.body || {};
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (!device) {
return res.status(404).json({ success: false, message: '设备不存在' });
}
if (device.status === '已报废') {
return res.status(400).json({ success: false, message: '已报废设备不可报修' });
}
db.prepare(`
UPDATE devices SET status='维修中', updated_at=datetime('now','localtime') WHERE id=?
`).run(req.params.id);
addMaintenanceRecord(req.params.id, device.name, '报修', remark || '', getOperator(req));
logDeviceAction(req.params.id, device.name, '报修', remark || '设备已标记为维修中', getOperator(req));
// 推送给全部管理员处理
pushToRole('admin', 'repair_request', '设备报修', `${getOperator(req)} 报修:${device.name}${remark ? '(' + remark + ')' : ''}`, req.user.id);
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
res.json({ success: true, data: updated, message: '报修成功,设备已标记为维修中' });
});
// 修改设备维修状态(管理员)
app.patch('/api/devices/:id/status', requireAdmin, (req, res) => {
const { status } = req.body || {};
const allowed = ['正常', '维修中', '已报废', '闲置'];
if (!status || !allowed.includes(status)) {
return res.status(400).json({ success: false, message: '无效的状态值' });
}
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (!device) {
return res.status(404).json({ success: false, message: '设备不存在' });
}
if (status === device.status) {
return res.json({ success: true, data: device, message: '状态未发生变化' });
}
if (status !== '已报废') {
// 离开报废状态时清空报废信息
db.prepare("UPDATE devices SET status=?, scrap_reason='', scrap_date='', scrap_operator='', updated_at=datetime('now','localtime') WHERE id=?").run(status, req.params.id);
} else {
db.prepare("UPDATE devices SET status=?, updated_at=datetime('now','localtime') WHERE id=?").run(status, req.params.id);
}
if (device.status === '维修中' && status === '正常') {
addMaintenanceRecord(req.params.id, device.name, '维修完成', '维修完成,状态恢复正常', getOperator(req));
// 维修完成推送给全部用户
pushToAll('repair_done', '设备维修完成', `设备「${device.name}」已维修好,状态恢复正常`, req.user.id);
}
logDeviceAction(req.params.id, device.name, '修改状态', `${device.status} → ${status}`, getOperator(req));
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
res.json({ success: true, data: updated, message: `维修状态已修改为:${status}` });
});
// 设备报废(管理员)
app.post('/api/devices/:id/scrap', requireAdmin, (req, res) => {
const { reason } = req.body || {};
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (!device) {
return res.status(404).json({ success: false, message: '设备不存在' });
}
if (device.status === '已报废') {
return res.status(400).json({ success: false, message: '该设备已处于报废状态' });
}
const operator = getOperator(req);
db.prepare(`
UPDATE devices SET status='已报废', scrap_reason=?, scrap_date=date('now','localtime'), scrap_operator=?, updated_at=datetime('now','localtime')
WHERE id=?
`).run(reason || '', operator, req.params.id);
addMaintenanceRecord(req.params.id, device.name, '报废', reason || '', operator);
logDeviceAction(req.params.id, device.name, '报废', reason || '设备已报废', operator);
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
res.json({ success: true, data: updated, message: '设备已报废' });
});
// 恢复报废设备(管理员):撤销报废,状态恢复为闲置
app.post('/api/devices/:id/restore', requireAdmin, (req, res) => {
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
if (!device) {
return res.status(404).json({ success: false, message: '设备不存在' });
}
if (device.status !== '已报废') {
return res.status(400).json({ success: false, message: '仅报废状态的设备可恢复' });
}
db.prepare(`
UPDATE devices SET status='闲置', scrap_reason='', scrap_date='', scrap_operator='', updated_at=datetime('now','localtime')
WHERE id=?
`).run(req.params.id);
logDeviceAction(req.params.id, device.name, '恢复', '报废已撤销,状态恢复为闲置', getOperator(req));
const updated = db.prepare('SELECT * FROM devices WHERE id = ?').get(req.params.id);
res.json({ success: true, data: updated, message: '报废已撤销,设备状态恢复为闲置' });
});
// ==================== 人员管理 API ====================
// 获取所有人员(LEFT JOIN 账户信息)
app.get('/api/personnel', (req, res) => {
const { keyword, department, status } = req.query;
let sql = `
SELECT p.*, u.id AS user_id, u.username AS account_username, u.role AS account_role
FROM personnel p LEFT JOIN users u ON u.personnel_id = p.id
WHERE 1=1
`;
const params = [];
if (keyword) {
sql += ' AND (p.name LIKE ? OR p.employee_id LIKE ? OR p.phone LIKE ?)';
params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`);
}
if (department) {
sql += ' AND p.department = ?';
params.push(department);
}
if (status) {
sql += ' AND p.status = ?';
params.push(status);
}
sql += ' ORDER BY p.created_at DESC';
const personnel = db.prepare(sql).all(...params);
res.json({ success: true, data: personnel });
});
// 获取单个人员
app.get('/api/personnel/:id', (req, res) => {
const person = db.prepare('SELECT * FROM personnel WHERE id = ?').get(req.params.id);
if (person) {
res.json({ success: true, data: person });
} else {
res.status(404).json({ success: false, message: '人员不存在' });
}
});
// 添加人员(管理员),可同时创建登录账户
app.post('/api/personnel', requireAdmin, (req, res) => {
const { name, employee_id, department, position, phone, email, status, entry_date, description, account } = req.body;
const id = uuidv4();
// 校验账户用户名唯一
if (account && account.createAccount) {
if (!account.username || !account.password) {
return res.status(400).json({ success: false, message: '创建账户需填写用户名和密码' });
}
const exist = db.prepare('SELECT id FROM users WHERE username = ?').get(account.username);
if (exist) {
return res.status(400).json({ success: false, message: `用户名 ${account.username} 已存在` });
}
}
db.prepare(`
INSERT INTO personnel (id, name, employee_id, department, position, phone, email, status, entry_date, description)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, name, employee_id || '', department || '', position || '', phone || '', email || '', status || '在职', entry_date || '', description || '');
// 同步创建账户
if (account && account.createAccount) {
db.prepare(`
INSERT INTO users (id, username, password, role, display_name, personnel_id)
VALUES (?, ?, ?, ?, ?, ?)
`).run(uuidv4(), account.username, account.password, account.role || 'user', name, id);
}
const person = db.prepare(`
SELECT p.*, u.id AS user_id, u.username AS account_username, u.role AS account_role
FROM personnel p LEFT JOIN users u ON u.personnel_id = p.id WHERE p.id = ?
`).get(id);
res.json({ success: true, data: person, message: account && account.createAccount ? '人员及账户创建成功' : '人员添加成功' });
});
// 更新人员(管理员),同步维护关联账户
app.put('/api/personnel/:id', requireAdmin, (req, res) => {
const { name, employee_id, department, position, phone, email, status, entry_date, description, account } = req.body;
db.prepare(`
UPDATE personnel SET name=?, employee_id=?, department=?, position=?, phone=?, email=?, status=?, entry_date=?, description=?, updated_at=datetime('now','localtime')
WHERE id=?
`).run(name, employee_id, department || '', position || '', phone || '', email || '', status, entry_date || '', description || '', req.params.id);
const existUser = db.prepare('SELECT * FROM users WHERE personnel_id = ?').get(req.params.id);
if (account && account.createAccount) {
// 校验用户名唯一(排除自身)
const dup = db.prepare('SELECT id FROM users WHERE username = ? AND personnel_id != ?').get(account.username, req.params.id);
if (dup) {
return res.status(400).json({ success: false, message: `用户名 ${account.username} 已存在` });
}
if (existUser) {
db.prepare('UPDATE users SET username=?, role=?, display_name=? WHERE personnel_id=?')
.run(account.username, account.role || 'user', name, req.params.id);
// 仅当提供新密码时更新密码
if (account.password) {
db.prepare('UPDATE users SET password=? WHERE personnel_id=?').run(account.password, req.params.id);
}
} else {
if (!account.username || !account.password) {
return res.status(400).json({ success: false, message: '创建账户需填写用户名和密码' });
}
db.prepare(`
INSERT INTO users (id, username, password, role, display_name, personnel_id)
VALUES (?, ?, ?, ?, ?, ?)
`).run(uuidv4(), account.username, account.password, account.role || 'user', name, req.params.id);
}
} else if (existUser) {
// 关闭账户:删除关联用户
db.prepare('DELETE FROM users WHERE personnel_id = ?').run(req.params.id);
}
const person = db.prepare(`
SELECT p.*, u.id AS user_id, u.username AS account_username, u.role AS account_role
FROM personnel p LEFT JOIN users u ON u.personnel_id = p.id WHERE p.id = ?
`).get(req.params.id);
res.json({ success: true, data: person, message: '人员更新成功' });
});
// 删除人员(管理员),同时删除关联账户
app.delete('/api/personnel/:id', requireAdmin, (req, res) => {
db.prepare('DELETE FROM users WHERE personnel_id = ?').run(req.params.id);
db.prepare('DELETE FROM personnel WHERE id = ?').run(req.params.id);
res.json({ success: true, message: '人员及关联账户删除成功' });
});
// 人员统计
app.get('/api/personnel/stats/summary', (req, res) => {
const total = db.prepare('SELECT COUNT(*) as count FROM personnel').get().count;
const active = db.prepare("SELECT COUNT(*) as count FROM personnel WHERE status='在职'").get().count;
const withAccount = db.prepare('SELECT COUNT(*) as count FROM personnel WHERE id IN (SELECT personnel_id FROM users WHERE personnel_id IS NOT NULL)').get().count;
const departments = db.prepare('SELECT department, COUNT(*) as count FROM personnel GROUP BY department').all();
res.json({ success: true, data: { total, active, withAccount, departments } });
});
// ==================== 设备借用/归还 API ====================
// 借用记录列表(可按状态/设备筛选,借用时间倒序)
app.get('/api/loans', (req, res) => {
const { status, device_id } = req.query;
const conds = [];
const params = [];
if (status) { conds.push('status = ?'); params.push(status); }
if (device_id) { conds.push('device_id = ?'); params.push(device_id); }
const where = conds.length ? ' WHERE ' + conds.join(' AND ') : '';
const rows = db.prepare(`SELECT * FROM device_loans${where} ORDER BY loan_time DESC, rowid DESC LIMIT 200`).all(...params);
res.json({ success: true, data: rows });
});
// 创建借用(服务器自动记录借用时间)
app.post('/api/loans', (req, res) => {
const { device_id, borrower, borrower_id, photo, remark } = req.body || {};
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(device_id || '');
if (!device) {
return res.status(400).json({ success: false, message: '请选择要借用的设备' });
}
if (!borrower || !String(borrower).trim()) {
return res.status(400).json({ success: false, message: '请选择借用人' });
}
const id = uuidv4();
db.prepare(`
INSERT INTO device_loans (id, device_id, device_name, borrower_id, borrower, loan_photo, remark, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, device.id, device.name, borrower_id || '', String(borrower).trim(), photo || '', remark || '', getOperator(req));
const row = db.prepare('SELECT * FROM device_loans WHERE id = ?').get(id);
logDeviceAction(device.id, device.name, '借用', `${row.borrower} 借走设备`, getOperator(req));
pushToRole('admin', 'device_loan', '设备借用', `${row.borrower} 借用了设备「${device.name}」`, req.user.id);
res.json({ success: true, data: row, message: '借用成功' });
});
// 归还设备(服务器自动记录归还时间;设备/借用人可重新选择)
app.post('/api/loans/:id/return', (req, res) => {
const loan = db.prepare('SELECT * FROM device_loans WHERE id = ?').get(req.params.id);
if (!loan) {
return res.status(404).json({ success: false, message: '借用记录不存在' });
}
if (loan.status === '已归还') {
return res.status(400).json({ success: false, message: '该设备已归还,请勿重复操作' });
}
const { device_id, borrower, borrower_id, photo } = req.body || {};
// 归还时可重新选择设备(从已有设备中选择)
let deviceId = loan.device_id;
let deviceName = loan.device_name;
if (device_id && device_id !== loan.device_id) {
const d = db.prepare('SELECT id, name FROM devices WHERE id = ?').get(device_id);
if (d) { deviceId = d.id; deviceName = d.name; }
}
const borrowerName = (borrower && String(borrower).trim()) || loan.borrower;
db.prepare(`
UPDATE device_loans
SET device_id = ?, device_name = ?, borrower = ?, borrower_id = ?,
return_photo = ?, status = '已归还', return_time = datetime('now', 'localtime')
WHERE id = ?
`).run(deviceId, deviceName, borrowerName, borrower_id || loan.borrower_id, photo || '', loan.id);
const row = db.prepare('SELECT * FROM device_loans WHERE id = ?').get(loan.id);
logDeviceAction(deviceId, deviceName, '归还', `${borrowerName} 归还设备`, getOperator(req));
pushToRole('admin', 'device_return', '设备归还', `${borrowerName} 已归还设备「${deviceName}」`, req.user.id);
res.json({ success: true, data: row, message: '归还成功' });
});
// 查看借用/归还照片(所有登录用户可见)
app.get('/api/loans/photo/:fileId', (req, res) => {
const file = db.prepare('SELECT * FROM chat_files WHERE id = ?').get(req.params.fileId);
if (!file) {
return res.status(404).json({ success: false, message: '照片不存在' });
}
const filePath = path.join(uploadsDir, file.stored_name);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ success: false, message: '照片文件已丢失' });
}
res.setHeader('Content-Type', file.file_mime || 'image/jpeg');
res.sendFile(filePath);
});
// ==================== 通知管理 API ====================
// 获取所有通知
app.get('/api/notifications', (req, res) => {
const { keyword, type, priority, is_read } = req.query;
let sql = 'SELECT * FROM notifications WHERE 1=1';
const params = [];
if (keyword) {
sql += ' AND (title LIKE ? OR content LIKE ?)';
params.push(`%${keyword}%`, `%${keyword}%`);
}
if (type) {
sql += ' AND type = ?';
params.push(type);
}
if (priority) {
sql += ' AND priority = ?';
params.push(priority);
}
if (is_read !== undefined && is_read !== '') {
sql += ' AND is_read = ?';
params.push(parseInt(is_read));
}
sql += ' ORDER BY created_at DESC';
const notifications = db.prepare(sql).all(...params);
res.json({ success: true, data: notifications });
});
// 获取单个通知
app.get('/api/notifications/:id', (req, res) => {
const notif = db.prepare('SELECT * FROM notifications WHERE id = ?').get(req.params.id);
if (notif) {
res.json({ success: true, data: notif });
} else {
res.status(404).json({ success: false, message: '通知不存在' });
}
});
// 添加通知(管理员)
app.post('/api/notifications', requireAdmin, (req, res) => {
const { title, content, type, priority, target } = req.body;
const id = uuidv4();
db.prepare(`
INSERT INTO notifications (id, title, content, type, priority, is_read, target)
VALUES (?, ?, ?, ?, ?, 0, ?)
`).run(id, title, content, type || '普通', priority || '普通', target || '全部');
const notif = db.prepare('SELECT * FROM notifications WHERE id = ?').get(id);
res.json({ success: true, data: notif, message: '通知发布成功' });
});
// 更新通知(管理员)
app.put('/api/notifications/:id', requireAdmin, (req, res) => {
const { title, content, type, priority, is_read, target } = req.body;
db.prepare(`
UPDATE notifications SET title=?, content=?, type=?, priority=?, is_read=?, target=?, updated_at=datetime('now','localtime')
WHERE id=?
`).run(title, content, type, priority, is_read ? 1 : 0, target, req.params.id);
const notif = db.prepare('SELECT * FROM notifications WHERE id = ?').get(req.params.id);
res.json({ success: true, data: notif, message: '通知更新成功' });
});
// 标记已读
app.patch('/api/notifications/:id/read', (req, res) => {
db.prepare("UPDATE notifications SET is_read=1, updated_at=datetime('now','localtime') WHERE id=?").run(req.params.id);
const notif = db.prepare('SELECT * FROM notifications WHERE id = ?').get(req.params.id);
res.json({ success: true, data: notif, message: '已标记为已读' });
});
// 全部标记已读
app.patch('/api/notifications/read-all', (req, res) => {
db.prepare("UPDATE notifications SET is_read=1, updated_at=datetime('now','localtime') WHERE is_read=0").run();
res.json({ success: true, message: '全部已标记为已读' });
});
// 删除通知(管理员)
app.delete('/api/notifications/:id', requireAdmin, (req, res) => {
db.prepare('DELETE FROM notifications WHERE id = ?').run(req.params.id);
res.json({ success: true, message: '通知删除成功' });
});
// 通知统计
app.get('/api/notifications/stats/summary', (req, res) => {
const total = db.prepare('SELECT COUNT(*) as count FROM notifications').get().count;
const unread = db.prepare('SELECT COUNT(*) as count FROM notifications WHERE is_read=0').get().count;
const high = db.prepare("SELECT COUNT(*) as count FROM notifications WHERE priority='高' AND is_read=0").get().count;
res.json({ success: true, data: { total, unread, high } });
});
// 清空已读通知(管理员)
app.delete('/api/notifications/read/clear', requireAdmin, (req, res) => {
const result = db.prepare('DELETE FROM notifications WHERE is_read = 1').run();
res.json({ success: true, message: `已清理 ${result.changes} 条已读通知` });
});
// ==================== 账户管理 API ====================
// 记录账户操作日志
function logUserAction(username, action, detail, operator) {
db.prepare(`
INSERT INTO user_logs (id, username, action, detail, operator)
VALUES (?, ?, ?, ?, ?)
`).run(uuidv4(), username || '', action, detail || '', operator || '');
}
// 获取所有账户(管理员,含关联人员)
app.get('/api/users', requireAdmin, (req, res) => {
const { keyword, role } = req.query;
let sql = `
SELECT u.id, u.username, u.role, u.display_name, u.created_at, u.last_login, u.personnel_id, p.name AS personnel_name, p.department
FROM users u LEFT JOIN personnel p ON u.personnel_id = p.id
WHERE 1=1
`;
const params = [];
if (keyword) {
sql += ' AND (u.username LIKE ? OR u.display_name LIKE ?)';
params.push(`%${keyword}%`, `%${keyword}%`);
}
if (role) {
sql += ' AND u.role = ?';
params.push(role);
}
sql += ' ORDER BY u.created_at ASC';
const onlineMap = getOnlineUserMap();
const data = db.prepare(sql).all(...params).map(u => {
const active = onlineMap.get(u.id);
return { ...u, is_online: !!active, last_active: active ? formatActive(active) : null };
});
res.json({ success: true, data });
});
// 账户操作日志(管理员)
app.get('/api/users/logs', requireAdmin, (req, res) => {
const logs = db.prepare('SELECT * FROM user_logs ORDER BY created_at DESC LIMIT 500').all();
res.json({ success: true, data: logs });
});
// 账户统计(管理员)
app.get('/api/users/stats/summary', requireAdmin, (req, res) => {
const total = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const admins = db.prepare("SELECT COUNT(*) as count FROM users WHERE role='admin'").get().count;
const users = total - admins;
const online = getOnlineUserMap().size;
res.json({ success: true, data: { total, admins, users, online } });
});
// 创建独立账户(管理员)
app.post('/api/users', requireAdmin, (req, res) => {
const { username, password, role, display_name } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, message: '用户名和密码不能为空' });
}
const exist = db.prepare('SELECT id FROM users WHERE username = ?').get(username);
if (exist) {
return res.status(400).json({ success: false, message: `用户名 ${username} 已存在` });
}
const id = uuidv4();
db.prepare(`
INSERT INTO users (id, username, password, role, display_name)
VALUES (?, ?, ?, ?, ?)
`).run(id, username, password, role === 'admin' ? 'admin' : 'user', display_name || username);
logUserAction(username, '创建', `创建账户,角色:${role === 'admin' ? '管理员' : '普通用户'}`, getOperator(req));
const user = db.prepare('SELECT id, username, role, display_name, created_at FROM users WHERE id = ?').get(id);
res.json({ success: true, data: user, message: '账户创建成功' });
});
// 更新账户信息(管理员:用户名/角色/显示名)
app.put('/api/users/:id', requireAdmin, (req, res) => {
const { username, role, display_name } = req.body;
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) {
return res.status(404).json({ success: false, message: '账户不存在' });
}
// 保护最后一个管理员
if (target.role === 'admin' && role === 'user') {
const adminCount = db.prepare("SELECT COUNT(*) as count FROM users WHERE role='admin'").get().count;
if (adminCount <= 1) {
return res.status(400).json({ success: false, message: '不能降级最后一个管理员账户' });
}
}
const dup = db.prepare('SELECT id FROM users WHERE username = ? AND id != ?').get(username, req.params.id);
if (dup) {
return res.status(400).json({ success: false, message: `用户名 ${username} 已存在` });
}
db.prepare('UPDATE users SET username=?, role=?, display_name=? WHERE id=?')
.run(username, role === 'admin' ? 'admin' : 'user', display_name || username, req.params.id);
// 同步会话中的用户信息
for (const [token, sessionUser] of sessions.entries()) {
if (sessionUser.id === req.params.id) {
sessions.set(token, { id: req.params.id, username, role: role === 'admin' ? 'admin' : 'user', display_name: display_name || username });
}
}
logUserAction(username, '修改', '账户信息已更新', getOperator(req));
const user = db.prepare('SELECT id, username, role, display_name, created_at FROM users WHERE id = ?').get(req.params.id);
res.json({ success: true, data: user, message: '账户更新成功' });
});
// 重置账户密码(管理员)
app.patch('/api/users/:id/reset-password', requireAdmin, (req, res) => {
const { password } = req.body;
if (!password || password.length < 6) {
return res.status(400).json({ success: false, message: '新密码不能为空且不少于6位' });
}
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) {
return res.status(404).json({ success: false, message: '账户不存在' });
}
db.prepare('UPDATE users SET password=? WHERE id=?').run(password, req.params.id);
logUserAction(target.username, '重置密码', '密码已被管理员重置', getOperator(req));
res.json({ success: true, message: `已重置 ${target.username} 的密码` });
});
// 删除账户(管理员)
app.delete('/api/users/:id', requireAdmin, (req, res) => {
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
if (!target) {
return res.status(404).json({ success: false, message: '账户不存在' });
}
if (req.user && req.user.id === target.id) {
return res.status(400).json({ success: false, message: '不能删除当前登录的账户' });
}
if (target.role === 'admin') {
const adminCount = db.prepare("SELECT COUNT(*) as count FROM users WHERE role='admin'").get().count;
if (adminCount <= 1) {
return res.status(400).json({ success: false, message: '不能删除最后一个管理员账户' });
}
}
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id);
// 使关联的会话失效
for (const [token, sessionUser] of sessions.entries()) {
if (sessionUser.id === target.id) dropSession(token);
}
logUserAction(target.username, '删除', '账户已删除', getOperator(req));
res.json({ success: true, message: '账户删除成功' });
});
// 账户操作日志清理(管理员)
app.delete('/api/users/logs/clear', requireAdmin, (req, res) => {
db.prepare('DELETE FROM user_logs').run();
res.json({ success: true, message: '账户日志已清空' });
});
// ==================== 消息对话 API ====================
// 校验当前用户是否为会话成员
function isConversationMember(convId, userId) {
return !!db.prepare('SELECT 1 FROM conversation_members WHERE conversation_id = ? AND user_id = ?').get(convId, userId);
}
function getUserDisplayName(userId) {
const u = db.prepare('SELECT display_name, username FROM users WHERE id = ?').get(userId);
return u ? (u.display_name || u.username) : '';
}
// 媒体消息在会话列表的预览文案
function messagePreview(m) {
if (m.msg_type === 'image') return '[图片]';
if (m.msg_type === 'video') return '[视频]';
if (m.msg_type === 'file') return '[文件] ' + (m.content || '');
return m.content;
}
// 上传聊天附件(图片/视频/文件),返回 fileId 供发送消息时引用
app.post('/api/chat/upload', upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ success: false, message: '未接收到文件' });
}
const id = uuidv4();
db.prepare('INSERT INTO chat_files (id, uploader_id, file_name, stored_name, file_size, file_mime) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, req.user.id, req.file.originalname, req.file.filename, req.file.size, req.file.mimetype || '');
res.json({
success: true,
data: { fileId: id, fileName: req.file.originalname, fileSize: req.file.size, fileMime: req.file.mimetype || '' }
});
});
// multer 错误处理(超大文件等)
app.use('/api/chat/upload', (err, req, res, next) => {
if (err) {
const msg = err.code === 'LIMIT_FILE_SIZE' ? '文件超过 100MB 限制' : '上传失败';
return res.status(400).json({ success: false, message: msg });
}
next();
});
// 下载/预览聊天附件:仅上传者可访问,或所在会话的成员可访问
app.get('/api/chat/files/:id', (req, res) => {
const file = db.prepare('SELECT * FROM chat_files WHERE id = ?').get(req.params.id);
if (!file) {
return res.status(404).json({ success: false, message: '文件不存在' });
}
if (file.uploader_id !== req.user.id) {
const inConv = db.prepare(`
SELECT 1 FROM conversation_members m
WHERE m.user_id = ? AND EXISTS (
SELECT 1 FROM messages msg WHERE msg.file_id = ? AND msg.conversation_id = m.conversation_id
)
`).get(req.user.id, file.id);
if (!inConv) {
return res.status(403).json({ success: false, message: '无权访问该文件' });
}
}
const filePath = path.join(uploadsDir, file.stored_name);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ success: false, message: '文件已丢失' });
}
const inline = (file.file_mime || '').startsWith('image/') || (file.file_mime || '').startsWith('video/');
const encoded = encodeURIComponent(file.file_name);
res.setHeader('Content-Type', file.file_mime || 'application/octet-stream');
res.setHeader('Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encoded}`);
fs.createReadStream(filePath).pipe(res);
});
// 全部用户简表(选择聊天对象/建群成员,含在线状态与登录/下线时间)
app.get('/api/chat/users', (req, res) => {
const rows = db.prepare('SELECT id, username, display_name, role, last_login, last_logout FROM users ORDER BY created_at ASC').all();
const onlineMap = getOnlineUserMap();
const data = rows.map(u => ({
...u,
is_online: onlineMap.has(u.id),
last_active: onlineMap.has(u.id) ? formatActive(onlineMap.get(u.id)) : null
}));
res.json({ success: true, data });
});
// 当前用户的会话列表(含最后一条消息与未读数)
app.get('/api/chat/conversations', (req, res) => {
const me = req.user.id;
const convs = db.prepare(`
SELECT c.id, c.type, c.name, c.creator_id, c.created_at, m.last_read_message_id
FROM conversations c JOIN conversation_members m ON m.conversation_id = c.id
WHERE m.user_id = ?
`).all(me);
const data = convs.map(c => {
const members = db.prepare(`
SELECT u.id, u.display_name, u.username
FROM conversation_members m JOIN users u ON u.id = m.user_id
WHERE m.conversation_id = ?
`).all(c.id);
const last = db.prepare('SELECT * FROM messages WHERE conversation_id = ? ORDER BY id DESC LIMIT 1').get(c.id);
const unread = db.prepare('SELECT COUNT(*) as count FROM messages WHERE conversation_id = ? AND id > ? AND sender_id != ?')
.get(c.id, c.last_read_message_id || 0, me).count;
return {
id: c.id,
type: c.type,
name: c.type === 'group' ? c.name : ((members.find(x => x.id !== me) || {}).display_name || ''),
creator_id: c.creator_id,
member_count: members.length,
members,
last_message: last ? { content: messagePreview(last), created_at: last.created_at, sender_name: getUserDisplayName(last.sender_id) } : null,
unread
};
});
// 按最后消息时间倒序(无消息的按创建时间)
data.sort((a, b) => (b.last_message?.created_at || b.created_at || '').localeCompare(a.last_message?.created_at || a.created_at || ''));
res.json({ success: true, data });
});
// 创建会话:单聊(type=direct + userId,已有则复用)或群聊(type=group + name + memberIds)
app.post('/api/chat/conversations', (req, res) => {
const { type, userId, name, memberIds } = req.body;
const me = req.user;
if (type === 'direct') {
if (!userId || userId === me.id) {
return res.status(400).json({ success: false, message: '请选择对话对象' });
}
const target = db.prepare('SELECT id FROM users WHERE id = ?').get(userId);
if (!target) {
return res.status(404).json({ success: false, message: '用户不存在' });
}
// 复用已有的单聊会话
const existing = db.prepare(`
SELECT c.id FROM conversations c WHERE c.type = 'direct'
AND EXISTS (SELECT 1 FROM conversation_members m WHERE m.conversation_id = c.id AND m.user_id = ?)
AND EXISTS (SELECT 1 FROM conversation_members m WHERE m.conversation_id = c.id AND m.user_id = ?)
`).get(me.id, userId);
if (existing) {
return res.json({ success: true, data: { id: existing.id }, message: '会话已存在' });
}
const id = uuidv4();
db.prepare("INSERT INTO conversations (id, type, creator_id) VALUES (?, 'direct', ?)").run(id, me.id);
const insMember = db.prepare('INSERT INTO conversation_members (conversation_id, user_id, role) VALUES (?, ?, ?)');
insMember.run(id, me.id, 'member');
insMember.run(id, userId, 'member');
return res.json({ success: true, data: { id }, message: '会话创建成功' });
}
if (type === 'group') {
if (!name || !String(name).trim()) {
return res.status(400).json({ success: false, message: '请输入群名称' });
}
if (!Array.isArray(memberIds) || memberIds.filter(id => id !== me.id).length < 1) {
return res.status(400).json({ success: false, message: '请至少选择1名其他群成员' });
}
const id = uuidv4();
db.prepare("INSERT INTO conversations (id, type, name, creator_id) VALUES (?, 'group', ?, ?)").run(id, String(name).trim(), me.id);
const insMember = db.prepare('INSERT OR IGNORE INTO conversation_members (conversation_id, user_id, role) VALUES (?, ?, ?)');
insMember.run(id, me.id, 'owner');
for (const uid of memberIds) {
if (uid !== me.id && db.prepare('SELECT id FROM users WHERE id = ?').get(uid)) {
insMember.run(id, uid, 'member');
}
}
logUserAction(me.username, '创建群组', `创建群组:${String(name).trim()}`, me.display_name || me.username);
return res.json({ success: true, data: { id }, message: '群组创建成功' });
}
return res.status(400).json({ success: false, message: '不支持的会话类型' });
});
// 获取会话消息(默认最新 100 条;after 游标增量拉取),同时标记已读
app.get('/api/chat/conversations/:id/messages', (req, res) => {
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
if (!conv || !isConversationMember(req.params.id, req.user.id)) {
return res.status(404).json({ success: false, message: '会话不存在' });
}
// 标记已读到会话最新一条(先于返回,保证本次返回的消息自带已读回执)
const latest = db.prepare('SELECT MAX(id) as maxId FROM messages WHERE conversation_id = ?').get(req.params.id);
if (latest && latest.maxId) {
db.prepare('UPDATE conversation_members SET last_read_message_id = MAX(last_read_message_id, ?) WHERE conversation_id = ? AND user_id = ?')
.run(latest.maxId, req.params.id, req.user.id);
}
const after = parseInt(req.query.after) || 0;
const limit = Math.min(parseInt(req.query.limit) || 100, 200);
let rows;
if (after > 0) {
rows = db.prepare('SELECT * FROM messages WHERE conversation_id = ? AND id > ? ORDER BY id ASC LIMIT ?').all(req.params.id, after, limit);
} else {
rows = db.prepare('SELECT * FROM messages WHERE conversation_id = ? ORDER BY id DESC LIMIT ?').all(req.params.id, limit).reverse();
}
const readMap = buildReadStatusMap(req.params.id, req.user.id);
const fileCache = {};
const senderCache = {};
const data = rows.map(m => {
if (!senderCache[m.sender_id]) senderCache[m.sender_id] = getUserDisplayName(m.sender_id);
const item = { ...m, sender_name: senderCache[m.sender_id], read_status: readMap[m.id] || null };
if (m.file_id) {
if (!fileCache[m.file_id]) fileCache[m.file_id] = db.prepare('SELECT * FROM chat_files WHERE id = ?').get(m.file_id);
const f = fileCache[m.file_id];
if (f) {
item.file_name = f.file_name;
item.file_size = f.file_size;
item.file_mime = f.file_mime;
item.file_url = `/api/chat/files/${f.id}`;
}
}
return item;
});
res.json({ success: true, data });
});
// 计算会话内各消息的已读回执:read_count=已读的其他成员数,total_others=其他成员总数,unread_users=未读成员名单
function buildReadStatusMap(convId, meId) {
const members = db.prepare('SELECT user_id, last_read_message_id FROM conversation_members WHERE conversation_id = ?').all(convId);
const others = members.filter(m => m.user_id !== meId);
const msgs = db.prepare('SELECT id, sender_id FROM messages WHERE conversation_id = ?').all(convId);
const nameCache = {};
const map = {};
for (const m of msgs) {
const unreadUsers = others
.filter(o => (o.last_read_message_id || 0) < m.id)
.map(o => {
if (!nameCache[o.user_id]) nameCache[o.user_id] = getUserDisplayName(o.user_id);
return { id: o.user_id, name: nameCache[o.user_id] };
});
map[m.id] = {
read_count: others.length - unreadUsers.length,
total_others: others.length,
unread_users: unreadUsers
};
}
return map;
}
// 轻量已读回执轮询(不标记已读,仅返回各消息的已读进度)
app.get('/api/chat/conversations/:id/read-status', (req, res) => {
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
if (!conv || !isConversationMember(req.params.id, req.user.id)) {
return res.status(404).json({ success: false, message: '会话不存在' });
}
res.json({ success: true, data: buildReadStatusMap(req.params.id, req.user.id) });
});
// 发送消息:文本(content)或媒体(type=image/video/file + fileId,引用已上传附件)
app.post('/api/chat/conversations/:id/messages', (req, res) => {
const msgType = ['text', 'image', 'video', 'file'].includes(req.body.type) ? req.body.type : 'text';
const content = String(req.body.content || '').trim();
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
if (!conv || !isConversationMember(req.params.id, req.user.id)) {
return res.status(404).json({ success: false, message: '会话不存在' });
}
let fileId = '';
let finalContent = content;
if (msgType !== 'text') {
const file = db.prepare('SELECT * FROM chat_files WHERE id = ?').get(req.body.fileId);
if (!file || file.uploader_id !== req.user.id) {
return res.status(400).json({ success: false, message: '附件不存在,请重新上传' });
}
fileId = file.id;
if (!finalContent) finalContent = file.file_name;
} else if (!content) {
return res.status(400).json({ success: false, message: '消息不能为空' });
}
if (finalContent.length > 2000) {
return res.status(400).json({ success: false, message: '消息过长(最多 2000 字)' });
}
const result = db.prepare('INSERT INTO messages (conversation_id, sender_id, content, msg_type, file_id) VALUES (?, ?, ?, ?, ?)')
.run(req.params.id, req.user.id, finalContent, msgType, fileId);
const msg = db.prepare('SELECT * FROM messages WHERE id = ?').get(result.lastInsertRowid);
// 自己发送的消息直接标记已读
db.prepare('UPDATE conversation_members SET last_read_message_id = ? WHERE conversation_id = ? AND user_id = ?')
.run(msg.id, req.params.id, req.user.id);
const data = { ...msg, sender_name: req.user.display_name || req.user.username };
if (fileId) {
const f = db.prepare('SELECT * FROM chat_files WHERE id = ?').get(fileId);
data.file_name = f.file_name;
data.file_size = f.file_size;
data.file_mime = f.file_mime;
data.file_url = `/api/chat/files/${f.id}`;
}
// 推送给会话其他成员(群聊标题=群名,单聊标题=发送者)
const memberRows = db.prepare('SELECT user_id FROM conversation_members WHERE conversation_id = ?').all(req.params.id);
const senderName = req.user.display_name || req.user.username;
const pushTitle = conv.type === 'group' ? (conv.name || '群聊消息') : senderName;
pushToMany(memberRows.map(m => m.user_id), 'chat', pushTitle, `${senderName}:${messagePreview(msg)}`, req.user.id);
res.json({ success: true, data });
});
// 删除/解散会话(群聊仅群主可解散,单聊任一方可删除)
app.delete('/api/chat/conversations/:id', (req, res) => {
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
if (!conv) {
return res.status(404).json({ success: false, message: '会话不存在' });
}
if (!isConversationMember(req.params.id, req.user.id)) {
return res.status(403).json({ success: false, message: '您不是该会话的成员' });
}
if (conv.type === 'group' && conv.creator_id !== req.user.id) {
return res.status(403).json({ success: false, message: '只有群主可以解散群组' });
}
// 清理会话内附件的磁盘文件与记录
const fileRows = db.prepare(`
SELECT f.id, f.stored_name FROM chat_files f
WHERE f.id IN (SELECT file_id FROM messages WHERE conversation_id = ? AND file_id != '')
AND NOT EXISTS (SELECT 1 FROM messages m2 WHERE m2.file_id = f.id AND m2.conversation_id != ?)
`).all(req.params.id, req.params.id);
for (const f of fileRows) {
const p = path.join(uploadsDir, f.stored_name);
if (fs.existsSync(p)) fs.unlinkSync(p);
}
if (fileRows.length) {
db.prepare(`DELETE FROM chat_files WHERE id IN (SELECT file_id FROM messages WHERE conversation_id = ? AND file_id != '')`).run(req.params.id);
}
db.prepare('DELETE FROM messages WHERE conversation_id = ?').run(req.params.id);
db.prepare('DELETE FROM conversation_members WHERE conversation_id = ?').run(req.params.id);
db.prepare('DELETE FROM conversations WHERE id = ?').run(req.params.id);
res.json({ success: true, message: conv.type === 'group' ? '群组已解散' : '会话已删除' });
});
// 移除群成员(群主可移除任意成员,群主本人不可被移除)
app.delete('/api/chat/conversations/:id/members/:userId', (req, res) => {
const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
if (!conv || conv.type !== 'group') {
return res.status(404).json({ success: false, message: '群聊不存在' });
}
if (!isConversationMember(conv.id, req.user.id)) {
return res.status(403).json({ success: false, message: '您不是该群聊的成员' });
}
const targetId = req.params.userId;
if (targetId === conv.creator_id) {
return res.status(400).json({ success: false, message: '群主不能被移除' });
}
if (!isConversationMember(conv.id, targetId)) {
return res.status(404).json({ success: false, message: '该用户不在群聊中' });
}
if (req.user.id !== conv.creator_id) {
return res.status(403).json({ success: false, message: '只有群主可以移除群成员' });
}
db.prepare('DELETE FROM conversation_members WHERE conversation_id = ? AND user_id = ?').run(conv.id, targetId);
res.json({ success: true, message: '已将该成员移出群聊' });
});
// ==================== 认证 API ====================
// 登录
app.post('/api/auth/login', (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, message: '用户名和密码不能为空' });
}
const user = db.prepare('SELECT id, username, role, display_name FROM users WHERE username = ? AND password = ?').get(username, password);
if (!user) {
return res.status(401).json({ success: false, message: '用户名或密码错误' });
}
const token = uuidv4();
sessions.set(token, user);
touchSession(token);
// 记录最后登录时间
db.prepare("UPDATE users SET last_login=datetime('now','localtime') WHERE id=?").run(user.id);
res.json({
success: true,
message: '登录成功',
data: { ...user, token }
});
});
// 获取当前登录用户
app.get('/api/auth/me', (req, res) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token || !sessions.has(token)) {
return res.status(401).json({ success: false, message: '未登录' });
}
res.json({ success: true, data: sessions.get(token) });
});
// 登出
app.post('/api/auth/logout', (req, res) => {
const token = req.headers.authorization?.replace('Bearer ', '');
const user = token ? sessions.get(token) : null;
if (user) {
// 记录下线时间
db.prepare("UPDATE users SET last_logout=datetime('now','localtime') WHERE id=?").run(user.id);
}
if (token) dropSession(token);
res.json({ success: true, message: '登出成功' });
});
// 修改自己的密码(已登录用户)
app.patch('/api/auth/password', (req, res) => {
const { oldPassword, newPassword } = req.body;
if (!oldPassword || !newPassword || newPassword.length < 6) {
return res.status(400).json({ success: false, message: '请输入原密码,且新密码不少于6位' });
}
const dbUser = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
if (!dbUser || dbUser.password !== oldPassword) {
return res.status(400).json({ success: false, message: '原密码不正确' });
}
db.prepare('UPDATE users SET password=? WHERE id=?').run(newPassword, req.user.id);
logUserAction(dbUser.username, '修改密码', '用户自行修改密码', dbUser.display_name || dbUser.username);
// 修改成功后使当前会话失效,要求用新密码重新登录
const token = req.headers.authorization?.replace('Bearer ', '');
if (token) dropSession(token);
res.json({ success: true, message: '密码修改成功,请使用新密码重新登录' });
});
// ==================== App 版本检查与更新 ====================
db.exec(`CREATE TABLE IF NOT EXISTS app_versions (
platform TEXT PRIMARY KEY,
version TEXT NOT NULL,
url TEXT DEFAULT '',
size INTEGER DEFAULT 0,
notes TEXT DEFAULT '',
released_at TEXT
)`);
// 首次启动播种当前四端 v4.0.0 版本信息(仅空表时写入)
['windows', 'android', 'harmony', 'ios'].forEach(p => {
db.prepare("INSERT OR IGNORE INTO app_versions(platform, version, url, size, notes, released_at) VALUES(?, '4.0.0', '', 0, '影视星河设备管理系统 v4.0.0', datetime('now','localtime'))").run(p);
});
// 查询各端最新版本(客户端登录后检查更新用)
app.get('/api/app/version/latest', (req, res) => {
const rows = db.prepare('SELECT * FROM app_versions').all();
const map = {};
rows.forEach(r => { map[r.platform] = r; });
res.json({ success: true, data: map });
});
// 管理员发布新版本:{ platform: windows|android|harmony|ios, version, url, size, notes }
app.post('/api/app/version', requireAdmin, (req, res) => {
const { platform, version, url, size, notes } = req.body || {};
const platforms = ['windows', 'android', 'harmony', 'ios'];
if (!platforms.includes(platform)) {
return res.status(400).json({ success: false, message: '平台取值必须为 windows/android/harmony/ios' });
}
if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
return res.status(400).json({ success: false, message: '版本号格式必须为 x.y.z' });
}
db.prepare(`INSERT INTO app_versions(platform, version, url, size, notes, released_at)
VALUES(?,?,?,?,?,datetime('now','localtime'))
ON CONFLICT(platform) DO UPDATE SET version=excluded.version, url=excluded.url, size=excluded.size, notes=excluded.notes, released_at=excluded.released_at`)
.run(platform, version, url || '', size || 0, notes || '');
res.json({ success: true, message: platform + ' 端 v' + version + ' 已发布' });
});
// ==================== 天气 API ====================
function getWeatherDesc(code) {
const map = {
0: '晴朗', 1: '多云', 2: '多云', 3: '阴天',
45: '雾', 48: '雾凇',
51: '毛毛雨', 53: '毛毛雨', 55: '毛毛雨',
56: '冻雨', 57: '冻雨',
61: '小雨', 63: '中雨', 65: '大雨',
66: '冻雨', 67: '冻雨',
71: '小雪', 73: '中雪', 75: '大雪', 77: '雪粒',
80: '阵雨', 81: '阵雨', 82: '强阵雨',
85: '阵雪', 86: '强阵雪',
95: '雷雨', 96: '雷雨伴冰雹', 99: '强雷雨伴冰雹'
};
return map[code] || '未知';
}
app.get('/api/weather/current', async (req, res) => {
try {
const city = req.query.city || 'beijing';
// 地理编码
const geoRes = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=zh&format=json`);
const geoData = await geoRes.json();
if (!geoData.results || geoData.results.length === 0) {
return res.status(404).json({ success: false, message: '未找到该城市' });
}
const { latitude, longitude, name, country } = geoData.results[0];
// 获取天气
const weatherRes = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m&daily=temperature_2m_max,temperature_2m_min,weather_code&timezone=Asia/Shanghai&forecast_days=1`
);
const weatherData = await weatherRes.json();
const current = weatherData.current;
const daily = weatherData.daily;
res.json({
success: true,
data: {
city: name,
country,
current: {
temp: current.temperature_2m,
humidity: current.relative_humidity_2m,
windSpeed: current.wind_speed_10m,
weather: getWeatherDesc(current.weather_code),
code: current.weather_code
},
daily: {
maxTemp: daily.temperature_2m_max[0],
minTemp: daily.temperature_2m_min[0],
weather: getWeatherDesc(daily.weather_code[0]),
code: daily.weather_code[0]
}
}
});
} catch (err) {
console.error('获取天气失败:', err);
res.status(500).json({ success: false, message: '获取天气失败' });
}
});
// ==================== 全局统计 API ====================
app.get('/api/dashboard/stats', (req, res) => {
const deviceTotal = db.prepare('SELECT COUNT(*) as count FROM devices').get().count;
const deviceNormal = db.prepare("SELECT COUNT(*) as count FROM devices WHERE status='正常'").get().count;
const deviceRepairing = db.prepare("SELECT COUNT(*) as count FROM devices WHERE status='维修中'").get().count;
const deviceScrapped = db.prepare("SELECT COUNT(*) as count FROM devices WHERE status='已报废'").get().count;
const personnelTotal = db.prepare('SELECT COUNT(*) as count FROM personnel').get().count;
const personnelActive = db.prepare("SELECT COUNT(*) as count FROM personnel WHERE status='在职'").get().count;
const notifTotal = db.prepare('SELECT COUNT(*) as count FROM notifications').get().count;
const notifUnread = db.prepare('SELECT COUNT(*) as count FROM notifications WHERE is_read=0').get().count;
res.json({
success: true,
data: {
deviceTotal, deviceNormal, deviceRepairing, deviceScrapped,
personnelTotal, personnelActive,
notifTotal, notifUnread
}
});
});
// SPA 路由回退 - 所有未匹配的请求返回前端
app.get('*', (req, res) => {
res.sendFile(path.join(clientBuildPath, 'index.html'));
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`\n╔══════════════════════════════════════════╗`);
console.log(`║ 设备管理系统 已启动 ║`);
console.log(`║ 地址: http://localhost:${PORT} ║`);
console.log(`║ 按 Ctrl+C 停止服务 ║`);
console.log(`╚══════════════════════════════════════════╝\n`);
});