refactor: v2 重构 — 模块化拆分 + AI Prompt模板化 + 前端状态管理
P0 - AI交互层:
- ai/prompt.js: 模块化 Prompt 模板
- ai/validator.js: JSON Schema 校验 + normalize
- ai/client.js: DeepSeek API 封装
- 金额计算唯一化,时间格式归一化
P1 - 后端架构:
- routes/: auth/rooms/messages/purchases 独立路由
- middleware/auth.js: 认证授权中间件
- ws/index.js: WebSocket 连接管理
- db.js: 启用 foreign_keys
- server.js: 从588行精简到40行入口
P2 - 前端架构:
- public/css/style.css: CSS 独立
- public/js/store.js: 全局状态 Store
- public/js/{chat,ws,purchases,auth}.js: 功能模块拆分
- index.html: 纯 HTML 结构, v2.6
This commit is contained in:
148
docs/v2.md
Normal file
148
docs/v2.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# 小财记账系统重构计划
|
||||
|
||||
> **文档状态**:讨论稿
|
||||
> **创建日期**:2026-07-22
|
||||
> **目的**:识别当前系统的设计缺陷与脆弱点,规划结构化改进方案,从“能用”走向“好用”。
|
||||
|
||||
---
|
||||
|
||||
## 一、当前系统现状概述
|
||||
|
||||
经过多轮迭代,系统已具备以下核心功能:
|
||||
|
||||
- 多用户登录、群聊管理、白名单权限
|
||||
- 聊天消息发送(文本+图片)、WebSocket实时推送
|
||||
- AI自动识别采购/付款/发票意图,创建采购记录
|
||||
- 采购清单(按月汇总)、采购详情(含操作历史、附件)
|
||||
- 删除/清空采购记录(管理员确认流程)
|
||||
- 图片压缩上传、附件关联
|
||||
- 移动端滑动手势、双击全屏、输入中气泡
|
||||
- 导出CSV、全局采购汇总
|
||||
|
||||
**但所有功能都是“快速响应需求”的结果,缺乏统一设计,代码结构脆弱,修改一处容易引发多处问题。**
|
||||
|
||||
---
|
||||
|
||||
## 二、当前系统的主要问题与重构方向
|
||||
|
||||
### 2.1 数据库与领域模型
|
||||
|
||||
**存在问题**:
|
||||
|
||||
- `purchases` 表字段通过多次 `ALTER TABLE` 添加,历史数据可能格式不一致(如 `created_at` 有时间和无时间两种格式)。
|
||||
- 金额计算规则散落在多处代码中,存在“AI返回金额”、“系统自动计算金额”两套逻辑,容易出错。
|
||||
- 状态机不明确(待处理/已采购/发票已收/已完成),更新时未严格约束状态转换。
|
||||
- 删除/清空操作依赖业务层手动管理级联,SQLite外键未开启。
|
||||
|
||||
**改进方向**:
|
||||
|
||||
1. **统一时间格式**:所有 `created_at` 和 `updated_at` 强制使用 `yyyy/MM/dd HH:mm:ss` 格式,在服务端工具函数中生成,不再依赖数据库默认值。
|
||||
2. **金额计算唯一化**:`amount` 字段仅由系统计算(`quantity * unit_price + freight`),AI 不再提供该字段。删除所有 AI 返回 `amount` 的处理逻辑。
|
||||
3. **定义采购状态机**:`待采购 → 已采购 → 发票已收 → 已完成`,更新状态时检查合法性。
|
||||
4. **开启外键约束**:在 `db.js` 中启用 `PRAGMA foreign_keys = ON;`,确保级联删除安全。
|
||||
|
||||
---
|
||||
|
||||
### 2.2 AI 交互层
|
||||
|
||||
**存在问题**:
|
||||
|
||||
- Prompt 分散在 `analyzeWithDeepSeek` 函数中,规则越来越多,难以维护。
|
||||
- AI 返回的 JSON 格式不稳定,有时带 `reply` 有时不带,有时 `action` 与字段不匹配。
|
||||
- 上下文管理简陋:对话历史只保留最近 40 条,无摘要压缩,token 使用效率低。
|
||||
- 附件补充、删除确认、权限判断等场景,AI 表现不稳定,经常误判意图。
|
||||
|
||||
**改进方向**:
|
||||
|
||||
1. **Prompt 模板化**:将 system prompt 拆分为多个模块(角色定义、意图分类、采购规则、删除规则、附件规则、权限规则),动态拼接。
|
||||
2. **JSON Schema 校验**:定义每种 `action` 对应的必选/可选字段,服务端在解析 AI 返回后立即校验,不符合的驳回或修正。
|
||||
3. **上下文管理升级**:
|
||||
- 近期消息(30 条)保持完整 `messages` 数组。
|
||||
- 超过 30 条时,调用 AI 生成结构化摘要(当前采购清单),作为 `system` 消息注入。
|
||||
4. **附件与更新逻辑分离**:
|
||||
- 附件请求仅返回 `purchase_item`,不包含金额/数量字段,后端强制匹配已有记录并插入附件。
|
||||
- 更新请求必须明确给出变化字段,后端对比后仅更新真正变化的列。
|
||||
|
||||
---
|
||||
|
||||
### 2.3 后端架构
|
||||
|
||||
**存在问题**:
|
||||
|
||||
- 所有业务逻辑(AI 调用、数据库操作、权限判断)混在 `server.js` 单文件中,超过 600 行,难以维护。
|
||||
- 错误处理不统一:有的地方静默失败,有的地方只打印日志,用户得不到反馈。
|
||||
- 权限检查仅通过中间件 `adminOnly`,但删除/清空的权限还依赖 AI 判断,存在双重标准。
|
||||
|
||||
**改进方向**:
|
||||
|
||||
1. **模块化拆分**:
|
||||
- `routes/`:Express 路由(auth, rooms, messages, purchases, upload)
|
||||
- `ai/`:Prompt 模板、AI 调用、结果解析与校验
|
||||
- `db/`:数据库操作封装(含迁移逻辑)
|
||||
- `ws/`:WebSocket 管理(连接、房间、广播)
|
||||
2. **统一错误处理中间件**:自定义错误类,全局捕获并返回标准 JSON 格式。
|
||||
3. **权限统一**:删除/清空操作仅在后端检查管理员权限,AI 不再参与权限判断(只负责识别意图)。
|
||||
|
||||
---
|
||||
|
||||
### 2.4 前端架构
|
||||
|
||||
**存在问题**:
|
||||
|
||||
- 单文件 HTML 包含所有 CSS/JS,超过 800 行,难以维护。
|
||||
- 状态管理混乱:`messageCache`、`rooms`、`pendingFiles` 等全局变量散落,修改时容易出现不一致。
|
||||
- 消息渲染使用 `innerHTML` 直接拼接,存在 XSS 风险(虽然做了部分转义,但 Markdown 渲染绕过)。
|
||||
- 图片加载、滚动位置控制不稳定,经常出现自动跳转。
|
||||
|
||||
**改进方向**:
|
||||
|
||||
1. **文件拆分**:CSS 独立文件、JS 按功能模块拆分(登录、聊天、清单、管理),使用 ES modules 或构建工具(如 esbuild)打包。
|
||||
2. **虚拟 DOM 或模板引擎**:考虑引入轻量框架(如 Preact 或 lit-html),避免手动拼接 HTML,同时解决 XSS 问题。
|
||||
3. **状态管理**:将所有全局状态集中到一个 `store` 对象,通过事件或 Proxy 驱动 UI 更新。
|
||||
4. **消息渲染优化**:图片加载完成后才调整滚动位置,防止跳动;使用懒加载。
|
||||
|
||||
---
|
||||
|
||||
### 2.5 安全性
|
||||
|
||||
**存在问题**:
|
||||
|
||||
- JWT secret 使用随机字符串,但未设置过期刷新机制,长期有效。
|
||||
- 文件上传仅限制格式和大小,未限制上传频率,有被滥用的风险。
|
||||
- 用户密码在环境变量中明文存储,传输时未加密(仅 HTTPS 保护)。
|
||||
|
||||
**改进方向**:
|
||||
|
||||
1. **JWT 刷新**:增加 refresh token 机制,短期 access token(1小时)+ 长期 refresh token(7天)。
|
||||
2. **上传频率限制**:使用 `express-rate-limit` 限制每用户每天上传次数。
|
||||
3. **密码哈希**:用户密码存储为 bcrypt 哈希,不再明文比较(需要修改登录逻辑和 `.env` 配置方式)。
|
||||
|
||||
---
|
||||
|
||||
### 2.6 运维与部署
|
||||
|
||||
**存在问题**:
|
||||
|
||||
- 数据库迁移依赖手动 `ALTER TABLE` 加 `try/catch`,日志不完善,问题难排查。
|
||||
- 图片存储使用本地磁盘,容器重建后丢失(除非挂载卷)。
|
||||
- 无备份机制,数据风险高。
|
||||
|
||||
**改进方向**:
|
||||
|
||||
1. **自动化迁移**:使用 `better-sqlite3` 的迁移工具或自定义版本管理系统,记录已执行迁移。
|
||||
2. **对象存储**:支持 S3 兼容存储(如 MinIO、阿里云 OSS)作为图片存储后端,本地仅做缓存。
|
||||
3. **定时备份**:通过 cronjob 定期导出 SQLite 数据库和上传目录。
|
||||
|
||||
---
|
||||
|
||||
## 三、优先重构顺序建议
|
||||
|
||||
| 优先级 | 模块 | 原因 |
|
||||
|--------|------|------|
|
||||
| **P0** | AI交互层(Prompt模板化、JSON Schema校验、附件逻辑分离) | 直接影响用户体验,当前最不稳定 |
|
||||
| **P0** | 金额计算唯一化、时间格式统一 | 数据一致性基础,错误频发 |
|
||||
| **P1** | 后端模块化拆分 | 为后续开发铺路,当前单文件难以维护 |
|
||||
| **P1** | 权限模型统一 | 安全风险,目前依赖AI判断不可靠 |
|
||||
| **P2** | 前端状态管理与文件拆分 | 改善开发体验和运行稳定性 |
|
||||
| **P3** | 安全增强(JWT、密码哈希、上传限制) | 系统公开使用后必须处理 |
|
||||
| **P3** | 运维自动化(备份、存储迁移) | 数据安全,长期运行保障 |
|
||||
19
server/ai/client.js
Normal file
19
server/ai/client.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// DeepSeek API 客户端
|
||||
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || '';
|
||||
|
||||
async function callDeepSeek(messages, temperature = 0.1) {
|
||||
console.log('🤖 调用 DeepSeek...');
|
||||
const res = await fetch('https://api.deepseek.com/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${DEEPSEEK_API_KEY}` },
|
||||
body: JSON.stringify({ model: 'deepseek-v4-flash', messages, temperature, stream: false })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.choices || !data.choices[0]) {
|
||||
console.error('🤖 DeepSeek 返回异常:', JSON.stringify(data));
|
||||
throw new Error('DeepSeek API 返回异常: ' + (data.error?.message || JSON.stringify(data)));
|
||||
}
|
||||
return data.choices[0].message.content;
|
||||
}
|
||||
|
||||
module.exports = { callDeepSeek };
|
||||
54
server/ai/prompt.js
Normal file
54
server/ai/prompt.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// AI Prompt 模板 — 模块化拼接
|
||||
function buildSystemPrompt(username, isAdmin) {
|
||||
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
||||
|
||||
const role = `你是智能财务助手"小财"。当前服务器时间:${now}。结合对话历史理解用户意图,只返回JSON。`;
|
||||
|
||||
const intent = `
|
||||
意图分类:
|
||||
- 采购/付款/发票相关:action="purchase",提取字段:purchase_item, quantity(默认1), unit_price(默认0), freight(默认0), payment_method(淘宝默认支付宝), invoice_type(默认无票), status, applicant, remarks, created_time
|
||||
- 查询汇总:action="query"
|
||||
- 聊天:action="chat",reply简短回复
|
||||
- 删除指定物品:action="delete",delete_item为物品名
|
||||
- 清空所有采购数据:action="clear_all"
|
||||
- 忽略(仅当完全无关):action="ignore"`;
|
||||
|
||||
const permission = `
|
||||
⚠️ 权限规则:
|
||||
- 当前用户${username},管理员状态:${isAdmin}。仅管理员可执行删除/清空操作。
|
||||
- 如果用户要求删除或清空,且 isAdmin 为 false,你必须返回 action="chat" 并说明"仅管理员可操作"。
|
||||
- 如果 isAdmin 为 true,正常返回 delete 或 clear_all。`;
|
||||
|
||||
const deleteRules = `
|
||||
⚠️ 删除规则:
|
||||
- 特殊批量删除:delete_item="__AMOUNT_ZERO__"(金额为0)、"__NULL_NAME__"(空名称)
|
||||
- 精确删除:用户指定数量/金额时同时返回 quantity/amount 字段,只匹配一条
|
||||
- 不要返回 action="ask" 处理删除请求`;
|
||||
|
||||
const attachmentRules = `
|
||||
⚠️ 附件规则:
|
||||
- 图片+物品名("这是XX的图片")→ 只返回 purchase_item,绝对不要 is_new/quantity/unit_price
|
||||
- 引用历史图片("上面/前面/刚刚那张")→ use_recent_image: true
|
||||
- 纯图片+物品名不是新建采购,系统自动关联附件`;
|
||||
|
||||
const prohibitionRules = `
|
||||
⚠️ 绝对禁止:
|
||||
- 图片+物品名请求 → 只能返回 purchase_item,禁止返回 is_new、quantity、unit_price、amount`;
|
||||
|
||||
const purchaseRules = `
|
||||
⚠️ 采购规则:
|
||||
- 只有明确消费意图(购买/采购/下单/付款/买了/花了)才返回 action="purchase"
|
||||
- 纯陈述(如"前天deep")→ action="ignore"
|
||||
- 信息不完整 → action="ask" 追问
|
||||
- 新建 vs 更新:
|
||||
* "昨天/今天/又/再/新/另一批"等重复购买 → is_new: true(强制新建)
|
||||
* "改一下/更新/修改/调整"等 → 不提供 is_new(匹配已有更新)
|
||||
* "纯图片+物品名"不是新建,不要返回 is_new
|
||||
* 无法确定 → 默认 is_new: true
|
||||
- amount 由系统自动计算(quantity × unit_price + freight),AI 无需提供
|
||||
- 无法确定物品名 → action="ask" 追问`;
|
||||
|
||||
return [role, intent, permission, deleteRules, attachmentRules, prohibitionRules, purchaseRules].join('\n');
|
||||
}
|
||||
|
||||
module.exports = { buildSystemPrompt };
|
||||
40
server/ai/validator.js
Normal file
40
server/ai/validator.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// JSON Schema 校验 — 验证 AI 返回的 JSON 结构
|
||||
const schemas = {
|
||||
purchase: { required: ['purchase_item'], optional: ['quantity','unit_price','freight','payment_method','invoice_type','status','applicant','remarks','created_time','is_new','use_recent_image'] },
|
||||
query: { required: [], optional: [] },
|
||||
chat: { required: ['reply'], optional: [] },
|
||||
delete: { required: ['delete_item'], optional: ['quantity','amount'] },
|
||||
clear_all: { required: [], optional: [] },
|
||||
ignore: { required: [], optional: [] },
|
||||
};
|
||||
|
||||
function validate(aiResult) {
|
||||
if (!aiResult || !aiResult.action) {
|
||||
return { valid: false, error: '缺少 action 字段' };
|
||||
}
|
||||
const schema = schemas[aiResult.action];
|
||||
if (!schema) {
|
||||
return { valid: false, error: `未知 action: ${aiResult.action}` };
|
||||
}
|
||||
for (const field of schema.required) {
|
||||
if (aiResult[field] === undefined || aiResult[field] === null) {
|
||||
return { valid: false, error: `action=${aiResult.action} 缺少必填字段: ${field}` };
|
||||
}
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// 修复常见问题
|
||||
function normalize(aiResult) {
|
||||
// action="ask" 兼容 reply/question
|
||||
if (aiResult.action === 'ask') {
|
||||
aiResult.question = aiResult.question || aiResult.reply || '请提供更多信息。';
|
||||
}
|
||||
// chat 兼容 reply 为空
|
||||
if (aiResult.action === 'chat' && !aiResult.reply) {
|
||||
aiResult.reply = '好的。';
|
||||
}
|
||||
return aiResult;
|
||||
}
|
||||
|
||||
module.exports = { validate, normalize };
|
||||
16
server/db.js
16
server/db.js
@@ -9,9 +9,12 @@ if (!fs.existsSync(dataDir)) {
|
||||
|
||||
const dbPath = path.join(dataDir, 'xiaocai.db');
|
||||
const db = new Database(dbPath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// 创建基础表(如果不存在)
|
||||
// 关键 pragma
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// 创建基础表
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
username TEXT PRIMARY KEY,
|
||||
@@ -75,7 +78,7 @@ db.exec(`
|
||||
);
|
||||
`);
|
||||
|
||||
// 为已有数据库添加可能缺失的列(安全迁移,忽略错误)
|
||||
// 安全迁移(忽略已存在的列)
|
||||
const migrations = [
|
||||
`ALTER TABLE purchases ADD COLUMN quantity INTEGER DEFAULT 1`,
|
||||
`ALTER TABLE purchases ADD COLUMN unit_price REAL DEFAULT 0`,
|
||||
@@ -85,13 +88,8 @@ const migrations = [
|
||||
`ALTER TABLE purchases ADD COLUMN remarks TEXT DEFAULT ''`,
|
||||
`ALTER TABLE rooms ADD COLUMN white_list TEXT DEFAULT ''`,
|
||||
];
|
||||
|
||||
for (const sql of migrations) {
|
||||
try {
|
||||
db.exec(sql);
|
||||
} catch (e) {
|
||||
// 列已存在或其他错误,忽略
|
||||
}
|
||||
try { db.exec(sql); } catch (e) { /* 列已存在 */ }
|
||||
}
|
||||
|
||||
module.exports = db;
|
||||
|
||||
231
server/handlers.js
Normal file
231
server/handlers.js
Normal file
@@ -0,0 +1,231 @@
|
||||
// AI 结果处理 + 共享状态
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const db = require('../db');
|
||||
const { timestamp, normalizeTime } = require('../utils');
|
||||
const { broadcastToRoom } = require('../ws');
|
||||
|
||||
// 对话历史(内存)
|
||||
const conversationHistory = new Map();
|
||||
function getHistory(roomId) { if (!conversationHistory.has(roomId)) conversationHistory.set(roomId, []); return conversationHistory.get(roomId); }
|
||||
function addToHistory(roomId, role, content) { const h = getHistory(roomId); h.push({ role, content }); if (h.length > 60) conversationHistory.set(roomId, h.slice(-40)); }
|
||||
|
||||
// 待处理操作
|
||||
const pendingActions = new Map();
|
||||
function hasPendingAction(roomId, username) { return pendingActions.has(`${roomId}:${username}`); }
|
||||
function setPendingAction(roomId, username, action) { pendingActions.set(`${roomId}:${username}`, action); }
|
||||
function clearPendingAction(roomId, username) { pendingActions.delete(`${roomId}:${username}`); }
|
||||
|
||||
function storeAndBroadcastText(roomId, user, text) {
|
||||
const now = timestamp();
|
||||
const result = db.prepare('INSERT INTO messages (room_id, user, text, timestamp) VALUES (?,?,?,?)').run(roomId, user, text, now);
|
||||
const msg = { id: result.lastInsertRowid, room_id: roomId, user, text, attachments: [], timestamp: now };
|
||||
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY id DESC LIMIT 1').get(roomId);
|
||||
broadcastToRoom(roomId, { type: 'new_message', message: msg, room_preview: { room_id: roomId, last_message: lastMsg ? lastMsg.text : '', last_time: lastMsg ? lastMsg.timestamp : '' } });
|
||||
return msg;
|
||||
}
|
||||
|
||||
function executePendingAction(roomId, username) {
|
||||
const action = pendingActions.get(`${roomId}:${username}`);
|
||||
if (!action) return false;
|
||||
clearPendingAction(roomId, username);
|
||||
const now = timestamp();
|
||||
try {
|
||||
if (action.type === 'delete') {
|
||||
const { itemName, purchaseIds } = action.data;
|
||||
const delA = db.prepare('DELETE FROM purchase_attachments WHERE purchase_id = ?');
|
||||
const delH = db.prepare('DELETE FROM purchase_history WHERE purchase_id = ?');
|
||||
const delP = db.prepare('DELETE FROM purchases WHERE id = ?');
|
||||
for (const id of purchaseIds) { delA.run(id); delH.run(id); delP.run(id); }
|
||||
const reply = `✅ 已删除采购记录:${itemName}(共 ${purchaseIds.length} 条)`;
|
||||
storeAndBroadcastText(roomId, '小财', reply); addToHistory(roomId, 'assistant', reply);
|
||||
} else if (action.type === 'clear') {
|
||||
db.prepare('DELETE FROM purchase_attachments WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
|
||||
db.prepare('DELETE FROM purchase_history WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
|
||||
db.prepare('DELETE FROM purchases WHERE room_id = ?').run(roomId);
|
||||
const reply = '✅ 已清空当前房间的所有采购数据。';
|
||||
storeAndBroadcastText(roomId, '小财', reply); addToHistory(roomId, 'assistant', reply);
|
||||
}
|
||||
broadcastToRoom(roomId, { type: 'purchase_updated' });
|
||||
return true;
|
||||
} catch (e) { storeAndBroadcastText(roomId, '小财', '❌ 操作执行失败,请重试。'); return false; }
|
||||
}
|
||||
|
||||
function handleAIResult(aiResult, roomId, username, originalText, attachments) {
|
||||
const now = timestamp();
|
||||
|
||||
if (aiResult.action === 'ask') {
|
||||
const q = aiResult.question || aiResult.reply || '请提供更多信息。';
|
||||
storeAndBroadcastText(roomId, '小财', q); addToHistory(roomId, 'assistant', q); return;
|
||||
}
|
||||
|
||||
if (aiResult.action === 'delete') {
|
||||
const itemName = aiResult.delete_item;
|
||||
let purchases;
|
||||
if (itemName === '__AMOUNT_ZERO__') {
|
||||
purchases = db.prepare('SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND amount = 0').all(roomId);
|
||||
} else if (itemName === '__NULL_NAME__') {
|
||||
purchases = db.prepare("SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND (item IS NULL OR item = '' OR item = 'null' OR item = 'undefined')").all(roomId);
|
||||
} else {
|
||||
let query = 'SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND item LIKE ?';
|
||||
const params = [roomId, `%${itemName}%`];
|
||||
if (aiResult.quantity !== undefined) { query += ' AND quantity = ?'; params.push(aiResult.quantity); }
|
||||
if (aiResult.amount !== undefined) { query += ' AND amount = ?'; params.push(aiResult.amount); }
|
||||
console.log('🔍 删除查询:', query, params);
|
||||
purchases = db.prepare(query).all(...params);
|
||||
}
|
||||
if (purchases.length === 0) {
|
||||
const label = itemName === '__AMOUNT_ZERO__' ? '金额为0' : (itemName === '__NULL_NAME__' ? '名称为空' : `"${itemName}"`);
|
||||
storeAndBroadcastText(roomId, '小财', `没有找到与"${label}"相关的采购记录。`);
|
||||
return;
|
||||
}
|
||||
let confirmText = `⚠️ 即将删除以下 ${purchases.length} 条采购记录,请回复"确认"继续:\n\n`;
|
||||
purchases.forEach(p => confirmText += `• ${p.item} | ¥${p.amount} | ${p.status} | ${p.applicant} | ${p.created_at}\n`);
|
||||
confirmText += `\n如果不删除,请忽略此消息。`;
|
||||
storeAndBroadcastText(roomId, '小财', confirmText);
|
||||
addToHistory(roomId, 'assistant', confirmText);
|
||||
setPendingAction(roomId, username, { type: 'delete', data: { itemName, purchaseIds: purchases.map(p => p.id) } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (aiResult.action === 'clear_all') {
|
||||
const count = db.prepare('SELECT COUNT(*) as count FROM purchases WHERE room_id = ?').get(roomId).count;
|
||||
if (count === 0) { storeAndBroadcastText(roomId, '小财', '当前房间没有采购记录,无需清空。'); return; }
|
||||
storeAndBroadcastText(roomId, '小财', `⚠️ 即将清空当前房间的 ${count} 条采购数据,请回复"确认"继续,否则忽略。`);
|
||||
addToHistory(roomId, 'assistant', `请求清空${count}条记录`);
|
||||
setPendingAction(roomId, username, { type: 'clear', data: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
if (aiResult.action === 'purchase') {
|
||||
const item = aiResult.purchase_item;
|
||||
if (!item || item === 'null' || item === 'undefined' || item.trim().length < 2) {
|
||||
console.log('🚫 拒绝无效 purchase_item:', JSON.stringify(item));
|
||||
storeAndBroadcastText(roomId, '小财', '请提供具体的物品名称。');
|
||||
addToHistory(roomId, 'assistant', '请提供具体的物品名称。');
|
||||
return;
|
||||
}
|
||||
console.log('🛒 purchase 分支, item:', item, 'attachments:', JSON.stringify(attachments));
|
||||
|
||||
let usedRecentImage = false;
|
||||
if (aiResult.use_recent_image) {
|
||||
console.log('🔍 use_recent_image: 查找最近图片消息, roomId=', roomId);
|
||||
const recentMsg = db.prepare("SELECT attachments FROM messages WHERE room_id = ? AND attachments IS NOT NULL AND attachments != '' AND attachments != '[]' ORDER BY id DESC LIMIT 1").get(roomId);
|
||||
if (recentMsg) {
|
||||
try {
|
||||
const files = JSON.parse(recentMsg.attachments);
|
||||
if (files.length) { attachments = files; usedRecentImage = true; console.log('✅ 引用图片:', files.length, '个'); }
|
||||
} catch(e) { console.error('❌ 解析附件失败:', e); }
|
||||
}
|
||||
}
|
||||
|
||||
const createdTime = normalizeTime(aiResult.created_time);
|
||||
let isNew = aiResult.is_new === true;
|
||||
let purchase;
|
||||
if (isNew && attachments?.length > 0) {
|
||||
const existing = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ? ORDER BY created_at DESC LIMIT 1').get(roomId, `%${aiResult.purchase_item}%`);
|
||||
if (existing) { console.log('🛡️ 兜底纠正: 转为更新模式'); isNew = false; purchase = existing; }
|
||||
}
|
||||
if (!isNew && !purchase) {
|
||||
purchase = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ? AND status != ?').get(roomId, `%${aiResult.purchase_item}%`, '已完成');
|
||||
}
|
||||
console.log('📦 purchase_item:', aiResult.purchase_item, isNew ? '(强制新建)' : purchase ? '(匹配已有)' : '(未匹配-新建)');
|
||||
|
||||
let replyText = '';
|
||||
const quantity = aiResult.quantity || 1;
|
||||
const unitPrice = aiResult.unit_price || 0;
|
||||
const freight = aiResult.freight || 0;
|
||||
const amount = quantity * unitPrice + freight;
|
||||
|
||||
if (!purchase) {
|
||||
const id = uuidv4();
|
||||
const paymentMethod = aiResult.payment_method || (aiResult.method === '淘宝' ? '支付宝' : (aiResult.method || '未指定'));
|
||||
const invoiceType = aiResult.invoice_type || '无票';
|
||||
const status = aiResult.status || '待处理';
|
||||
const applicant = aiResult.applicant || username;
|
||||
const remarks = aiResult.remarks || '';
|
||||
db.prepare('INSERT INTO purchases (id, room_id, item, quantity, unit_price, amount, freight, payment_method, invoice_type, status, applicant, remarks, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)').run(
|
||||
id, roomId, item, quantity, unitPrice, amount, freight, paymentMethod, invoiceType, status, applicant, remarks, createdTime
|
||||
);
|
||||
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(id, `创建采购:${item},数量${quantity},金额¥${amount},状态${status}`, '小财', now);
|
||||
replyText = `✅ 已记录采购:${item},数量 ${quantity},金额 ¥${amount},状态 ${status}`;
|
||||
purchase = { id };
|
||||
} else {
|
||||
const old = purchase;
|
||||
const newVals = {
|
||||
quantity: aiResult.quantity ?? old.quantity,
|
||||
unit_price: aiResult.unit_price ?? old.unit_price,
|
||||
freight: aiResult.freight ?? old.freight,
|
||||
payment_method: aiResult.payment_method || (aiResult.method === '淘宝' ? '支付宝' : (aiResult.method || old.payment_method)),
|
||||
invoice_type: aiResult.invoice_type || old.invoice_type,
|
||||
status: aiResult.status || old.status,
|
||||
applicant: aiResult.applicant || old.applicant,
|
||||
remarks: aiResult.remarks !== undefined ? aiResult.remarks : old.remarks,
|
||||
created_at: aiResult.created_time || old.created_at
|
||||
};
|
||||
newVals.amount = newVals.quantity * newVals.unit_price + newVals.freight;
|
||||
|
||||
const historyChanges = [];
|
||||
let changed = false;
|
||||
for (const [field, newVal] of Object.entries(newVals)) {
|
||||
if (String(newVal) !== String(old[field])) {
|
||||
changed = true;
|
||||
historyChanges.push(`${field}: ${old[field]} → ${newVal}`);
|
||||
db.prepare(`UPDATE purchases SET ${field} = ? WHERE id = ?`).run(newVal, purchase.id);
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
db.prepare('UPDATE purchases SET updated_at = ? WHERE id = ?').run(now, purchase.id);
|
||||
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, `更新:${historyChanges.join(';')}`, username, now);
|
||||
replyText = `🔄 已更新「${aiResult.purchase_item}」:${historyChanges.join(',')}`;
|
||||
} else {
|
||||
replyText = `ℹ️ 采购「${aiResult.purchase_item}」的信息没有发生变化。`;
|
||||
}
|
||||
}
|
||||
|
||||
if (attachments?.length) {
|
||||
console.log('📎 插入附件:', attachments.length, '个, purchase_id=', purchase.id);
|
||||
const insertAttach = db.prepare('INSERT INTO purchase_attachments (purchase_id, file_path, uploaded_by, timestamp) VALUES (?,?,?,?)');
|
||||
attachments.forEach(fp => { console.log(' 📎', fp); insertAttach.run(purchase.id, fp, username, now); });
|
||||
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, `添加附件:${attachments.length} 个`, username, now);
|
||||
if (replyText.includes('没有发生') || usedRecentImage) {
|
||||
replyText = `📎 已为「${aiResult.purchase_item}」添加 ${attachments.length} 个附件`;
|
||||
}
|
||||
console.log('✅ 附件完成, replyText:', replyText);
|
||||
} else if (usedRecentImage) {
|
||||
replyText = '❌ 未找到最近的图片消息,请先发送图片再试。';
|
||||
}
|
||||
|
||||
storeAndBroadcastText(roomId, '小财', replyText);
|
||||
addToHistory(roomId, 'assistant', replyText);
|
||||
broadcastToRoom(roomId, { type: 'purchase_updated' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (aiResult.action === 'query') {
|
||||
const purchaseData = db.prepare('SELECT item, amount, payment_method, status FROM purchases WHERE room_id = ? ORDER BY created_at DESC').all(roomId).map(p => `${p.item} ¥${p.amount} ${p.payment_method||''} ${p.status}`).join('\n');
|
||||
const summaryPrompt = `根据以下采购记录,用自然语言回答用户查询"${originalText}"。采购记录:\n${purchaseData || '暂无记录'}`;
|
||||
callDeepSeekForSummary(summaryPrompt).then(reply => { storeAndBroadcastText(roomId, '小财', reply); addToHistory(roomId, 'assistant', reply); });
|
||||
return;
|
||||
}
|
||||
|
||||
if (aiResult.action === 'chat') {
|
||||
storeAndBroadcastText(roomId, '小财', aiResult.reply || '好的。');
|
||||
addToHistory(roomId, 'assistant', aiResult.reply);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function callDeepSeekForSummary(prompt) {
|
||||
const { callDeepSeek } = require('../ai/client');
|
||||
const messages = [
|
||||
{ role: 'system', content: '你是一个财务助手,请根据采购记录生成简洁回复。' },
|
||||
{ role: 'user', content: prompt }
|
||||
];
|
||||
return callDeepSeek(messages, 0.3);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getHistory, addToHistory,
|
||||
hasPendingAction, setPendingAction, clearPendingAction,
|
||||
handleAIResult, executePendingAction, storeAndBroadcastText
|
||||
};
|
||||
23
server/middleware/auth.js
Normal file
23
server/middleware/auth.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// 认证与授权中间件
|
||||
const jwt = require('jsonwebtoken');
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'xiaocai-secret-2024';
|
||||
|
||||
function authMiddleware(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) return res.status(401).json({ error: '未登录' });
|
||||
const token = authHeader.split(' ')[1];
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (e) {
|
||||
res.status(401).json({ error: '登录已过期' });
|
||||
}
|
||||
}
|
||||
|
||||
function adminOnly(req, res, next) {
|
||||
if (!req.user.isAdmin) return res.status(403).json({ error: '仅管理员可执行此操作' });
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { authMiddleware, adminOnly, JWT_SECRET };
|
||||
111
server/public/css/style.css
Normal file
111
server/public/css/style.css
Normal file
@@ -0,0 +1,111 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; height: 100vh; display: flex; justify-content: center; align-items: center; }
|
||||
.app { width: 100%; max-width: 420px; height: 100vh; background: #fff; display: flex; flex-direction: column; box-shadow: 0 0 20px rgba(0,0,0,0.1); position: relative; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.header { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 0 16px; display: flex; justify-content: space-between; align-items: center; min-height: 48px; }
|
||||
.header-left { display: flex; align-items: center; gap: 8px; }
|
||||
.header-left h2 { font-size: 18px; font-weight: 500; }
|
||||
.icon-btn { background: none; border: none; color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 4px; border-radius: 50%; }
|
||||
.icon-btn svg { width: 22px; height: 22px; stroke: #fff; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
#create-room-btn { visibility: hidden; }
|
||||
#create-room-btn.visible { visibility: visible; }
|
||||
|
||||
.chat-list { flex: 1; overflow-y: auto; padding-bottom: 8px; }
|
||||
.chat-item { padding: 14px 16px; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; cursor: pointer; }
|
||||
.chat-item:active { background: #f9f9f9; }
|
||||
.avatar { width: 44px; height: 44px; border-radius: 50%; background: #e0e0e0; margin-right: 12px; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 600; color: #555; flex-shrink: 0; }
|
||||
.chat-info { flex: 1; min-width: 0; }
|
||||
.chat-name { font-size: 16px; font-weight: 500; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.last-msg { font-size: 14px; color: #888; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-right { display: flex; flex-direction: column; align-items: flex-end; margin-left: 8px; flex-shrink: 0; min-width: 56px; }
|
||||
.chat-time { font-size: 12px; color: #aaa; white-space: nowrap; }
|
||||
.room-actions { display: flex; gap: 4px; margin-top: 4px; }
|
||||
.edit-room-btn { background: none; border: none; cursor: pointer; display: flex; align-items: center; padding: 2px; }
|
||||
.edit-room-btn svg { width: 16px; height: 16px; stroke: #888; fill: none; stroke-width: 2; }
|
||||
|
||||
.summary-card { background: #f0f7ff; margin: 8px; border-radius: 12px; padding: 14px 16px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
|
||||
.summary-card h4 { font-size: 16px; font-weight: 500; }
|
||||
.summary-card .summary-preview { font-size: 14px; color: #555; }
|
||||
|
||||
.chat-window { display: flex; flex-direction: column; height: 100%; }
|
||||
.chat-header { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 0 16px; display: flex; align-items: center; min-height: 48px; }
|
||||
.chat-header .back-btn { margin-right: 12px; }
|
||||
.chat-header .chat-title { font-size: 17px; font-weight: 500; flex: 1; }
|
||||
.messages { flex: 1; overflow-y: auto; padding: 12px; background: #fafafa; }
|
||||
.msg { margin-bottom: 12px; display: flex; flex-direction: column; }
|
||||
.msg.me { align-items: flex-end; }
|
||||
.msg-bubble { max-width: 75%; padding: 10px 14px; border-radius: 20px; font-size: 16px; line-height: 1.5; word-wrap: break-word; overflow-wrap: break-word; cursor: pointer; }
|
||||
.msg.me .msg-bubble { background: #d1f0d1; border-bottom-right-radius: 4px; }
|
||||
.msg.other .msg-bubble { background: #fff; border: 1px solid #eee; border-bottom-left-radius: 4px; }
|
||||
.msg-user { font-size: 12px; color: #888; margin-bottom: 2px; margin-left: 4px; }
|
||||
.msg.me .msg-user { text-align: right; margin-right: 4px; }
|
||||
.msg-time { font-size: 11px; color: #aaa; margin-top: 2px; }
|
||||
.msg-bubble h1, .msg-bubble h2, .msg-bubble h3 { font-size: 16px; margin: 5px 0; }
|
||||
.msg-bubble table { width: 100%; border-collapse: collapse; margin: 8px 0; }
|
||||
.msg-bubble th, .msg-bubble td { border: 1px solid #ddd; padding: 4px; text-align: left; font-size: 14px; }
|
||||
.msg-bubble th { background: #f0f0f0; }
|
||||
.msg-bubble pre { background: #f0f0f0; padding: 8px; border-radius: 4px; overflow-x: auto; }
|
||||
.msg-bubble code { background: #f0f0f0; padding: 2px 4px; border-radius: 3px; font-size: 14px; }
|
||||
|
||||
.input-area { padding: 8px 12px; border-top: 1px solid #eee; display: flex; align-items: center; background: #fff; gap: 8px; }
|
||||
.input-area textarea { flex: 1; border: 1px solid #ddd; border-radius: 12px; padding: 10px 12px; font-size: 16px; outline: none; resize: none; min-height: 40px; max-height: 120px; overflow-y: hidden; line-height: 1.4; }
|
||||
.file-upload-btn { background: none; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 4px; }
|
||||
.file-upload-btn svg { width: 24px; height: 24px; stroke: #666; fill: none; stroke-width: 2; }
|
||||
.send-btn { background: #3b82f6; border: none; color: #fff; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; }
|
||||
.send-btn svg { width: 18px; height: 18px; fill: #fff; }
|
||||
|
||||
.purchase-panel-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: #fff; z-index: 10; display: flex; flex-direction: column; }
|
||||
.purchase-panel-header { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 0 16px; display: flex; justify-content: space-between; align-items: center; min-height: 48px; }
|
||||
.purchase-panel-body { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
.purchase-month-group { margin-bottom: 20px; }
|
||||
.purchase-month-header { background: #f5f5f5; font-size: 16px; font-weight: 600; padding: 10px 8px; border-radius: 4px; display: flex; justify-content: space-between; }
|
||||
.purchase-item { padding: 12px 8px; border-bottom: 1px solid #f0f0f0; cursor: pointer; }
|
||||
.purchase-item:active { background: #f9f9f9; }
|
||||
.purchase-item .item-main { display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.purchase-item .item-name { font-size: 16px; font-weight: 500; }
|
||||
.purchase-item .item-amount { font-size: 16px; font-weight: 600; }
|
||||
.purchase-item .item-meta { display: flex; justify-content: space-between; margin-top: 4px; font-size: 13px; color: #888; flex-wrap: wrap; gap: 4px; }
|
||||
.status-badge { background: #fef3c7; padding: 1px 6px; border-radius: 8px; font-size: 12px; }
|
||||
.status-badge.done { background: #d1fae5; }
|
||||
|
||||
.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center; z-index: 100; }
|
||||
.modal { background: #fff; width: 90%; max-width: 400px; max-height: 80vh; border-radius: 12px; overflow: auto; padding: 16px; position: relative; }
|
||||
.modal .modal-close-btn { position: absolute; top: 10px; right: 12px; background: none; border: none; cursor: pointer; }
|
||||
.modal .modal-close-btn svg { width: 20px; height: 20px; stroke: #888; fill: none; stroke-width: 2; }
|
||||
.modal h3 { margin-bottom: 12px; font-size: 18px; padding-right: 30px; }
|
||||
.modal input { width: 100%; padding: 8px; margin: 8px 0; border: 1px solid #ddd; border-radius: 8px; font-size: 16px; }
|
||||
.modal button { margin-right: 8px; padding: 8px 16px; border-radius: 8px; border: none; cursor: pointer; font-size: 16px; }
|
||||
.modal .primary-btn { background: #3b82f6; color: #fff; }
|
||||
.history-list { list-style: none; font-size: 14px; }
|
||||
.history-list li { padding: 6px 0; border-bottom: 1px solid #f0f0f0; display: flex; }
|
||||
.history-time { min-width: 80px; color: #888; margin-right: 10px; font-size: 13px; }
|
||||
.img-thumb { width: 60px; height: 60px; object-fit: cover; border-radius: 4px; margin: 4px; border: 1px solid #eee; }
|
||||
.attachments { display: flex; flex-wrap: wrap; margin: 8px 0; }
|
||||
.msg-img { max-width: 200px; border-radius: 8px; margin: 4px 0; }
|
||||
.msg-placeholder .msg-bubble { opacity: 0.6; }
|
||||
|
||||
.version { text-align: center; font-size: 12px; color: #aaa; margin-top: 16px; }
|
||||
|
||||
.fullscreen-overlay {
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0,0,0,0.9); display: flex; align-items: center; justify-content: center;
|
||||
z-index: 200; cursor: pointer;
|
||||
}
|
||||
.fullscreen-overlay img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.fullscreen-overlay .fullscreen-text {
|
||||
background: #fff; padding: 20px; border-radius: 8px; max-width: 90%; max-height: 80%;
|
||||
overflow: auto; font-size: 16px; line-height: 1.5; color: #333;
|
||||
}
|
||||
.fullscreen-overlay .fullscreen-text img { max-width: 200px; border-radius: 8px; margin: 4px 0; }
|
||||
.fullscreen-overlay .fullscreen-text table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.fullscreen-overlay .fullscreen-text th,
|
||||
.fullscreen-overlay .fullscreen-text td {border: 1px solid #ddd;padding: 4px;text-align: left;font-size: 14px;}
|
||||
.fullscreen-overlay .fullscreen-text th {background: #f0f0f0;}
|
||||
|
||||
.detail-row { margin-bottom: 8px; }
|
||||
@@ -8,119 +8,7 @@
|
||||
<meta name="theme-color" content="#1e3c72">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; height: 100vh; display: flex; justify-content: center; align-items: center; }
|
||||
.app { width: 100%; max-width: 420px; height: 100vh; background: #fff; display: flex; flex-direction: column; box-shadow: 0 0 20px rgba(0,0,0,0.1); position: relative; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.header { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 0 16px; display: flex; justify-content: space-between; align-items: center; min-height: 48px; }
|
||||
.header-left { display: flex; align-items: center; gap: 8px; }
|
||||
.header-left h2 { font-size: 18px; font-weight: 500; }
|
||||
.icon-btn { background: none; border: none; color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 4px; border-radius: 50%; }
|
||||
.icon-btn svg { width: 22px; height: 22px; stroke: #fff; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||||
|
||||
#create-room-btn { visibility: hidden; }
|
||||
#create-room-btn.visible { visibility: visible; }
|
||||
|
||||
.chat-list { flex: 1; overflow-y: auto; padding-bottom: 8px; }
|
||||
.chat-item { padding: 14px 16px; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; cursor: pointer; }
|
||||
.chat-item:active { background: #f9f9f9; }
|
||||
.avatar { width: 44px; height: 44px; border-radius: 50%; background: #e0e0e0; margin-right: 12px; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 600; color: #555; flex-shrink: 0; }
|
||||
.chat-info { flex: 1; min-width: 0; }
|
||||
.chat-name { font-size: 16px; font-weight: 500; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.last-msg { font-size: 14px; color: #888; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-right { display: flex; flex-direction: column; align-items: flex-end; margin-left: 8px; flex-shrink: 0; min-width: 56px; }
|
||||
.chat-time { font-size: 12px; color: #aaa; white-space: nowrap; }
|
||||
.room-actions { display: flex; gap: 4px; margin-top: 4px; }
|
||||
.edit-room-btn { background: none; border: none; cursor: pointer; display: flex; align-items: center; padding: 2px; }
|
||||
.edit-room-btn svg { width: 16px; height: 16px; stroke: #888; fill: none; stroke-width: 2; }
|
||||
|
||||
.summary-card { background: #f0f7ff; margin: 8px; border-radius: 12px; padding: 14px 16px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
|
||||
.summary-card h4 { font-size: 16px; font-weight: 500; }
|
||||
.summary-card .summary-preview { font-size: 14px; color: #555; }
|
||||
|
||||
.chat-window { display: flex; flex-direction: column; height: 100%; }
|
||||
.chat-header { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 0 16px; display: flex; align-items: center; min-height: 48px; }
|
||||
.chat-header .back-btn { margin-right: 12px; }
|
||||
.chat-header .chat-title { font-size: 17px; font-weight: 500; flex: 1; }
|
||||
.messages { flex: 1; overflow-y: auto; padding: 12px; background: #fafafa; }
|
||||
.msg { margin-bottom: 12px; display: flex; flex-direction: column; }
|
||||
.msg.me { align-items: flex-end; }
|
||||
.msg-bubble { max-width: 75%; padding: 10px 14px; border-radius: 20px; font-size: 16px; line-height: 1.5; word-wrap: break-word; overflow-wrap: break-word; cursor: pointer; }
|
||||
.msg.me .msg-bubble { background: #d1f0d1; border-bottom-right-radius: 4px; }
|
||||
.msg.other .msg-bubble { background: #fff; border: 1px solid #eee; border-bottom-left-radius: 4px; }
|
||||
.msg-user { font-size: 12px; color: #888; margin-bottom: 2px; margin-left: 4px; }
|
||||
.msg.me .msg-user { text-align: right; margin-right: 4px; }
|
||||
.msg-time { font-size: 11px; color: #aaa; margin-top: 2px; }
|
||||
.msg-bubble h1, .msg-bubble h2, .msg-bubble h3 { font-size: 16px; margin: 5px 0; }
|
||||
.msg-bubble table { width: 100%; border-collapse: collapse; margin: 8px 0; }
|
||||
.msg-bubble th, .msg-bubble td { border: 1px solid #ddd; padding: 4px; text-align: left; font-size: 14px; }
|
||||
.msg-bubble th { background: #f0f0f0; }
|
||||
.msg-bubble pre { background: #f0f0f0; padding: 8px; border-radius: 4px; overflow-x: auto; }
|
||||
.msg-bubble code { background: #f0f0f0; padding: 2px 4px; border-radius: 3px; font-size: 14px; }
|
||||
|
||||
.input-area { padding: 8px 12px; border-top: 1px solid #eee; display: flex; align-items: center; background: #fff; gap: 8px; }
|
||||
.input-area textarea { flex: 1; border: 1px solid #ddd; border-radius: 12px; padding: 10px 12px; font-size: 16px; outline: none; resize: none; min-height: 40px; max-height: 120px; overflow-y: hidden; line-height: 1.4; }
|
||||
.file-upload-btn { background: none; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 4px; }
|
||||
.file-upload-btn svg { width: 24px; height: 24px; stroke: #666; fill: none; stroke-width: 2; }
|
||||
.send-btn { background: #3b82f6; border: none; color: #fff; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; }
|
||||
.send-btn svg { width: 18px; height: 18px; fill: #fff; }
|
||||
|
||||
.purchase-panel-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: #fff; z-index: 10; display: flex; flex-direction: column; }
|
||||
.purchase-panel-header { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 0 16px; display: flex; justify-content: space-between; align-items: center; min-height: 48px; }
|
||||
.purchase-panel-body { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
.purchase-month-group { margin-bottom: 20px; }
|
||||
.purchase-month-header { background: #f5f5f5; font-size: 16px; font-weight: 600; padding: 10px 8px; border-radius: 4px; display: flex; justify-content: space-between; }
|
||||
.purchase-item { padding: 12px 8px; border-bottom: 1px solid #f0f0f0; cursor: pointer; }
|
||||
.purchase-item:active { background: #f9f9f9; }
|
||||
.purchase-item .item-main { display: flex; justify-content: space-between; align-items: baseline; }
|
||||
.purchase-item .item-name { font-size: 16px; font-weight: 500; }
|
||||
.purchase-item .item-amount { font-size: 16px; font-weight: 600; }
|
||||
.purchase-item .item-meta { display: flex; justify-content: space-between; margin-top: 4px; font-size: 13px; color: #888; flex-wrap: wrap; gap: 4px; }
|
||||
.status-badge { background: #fef3c7; padding: 1px 6px; border-radius: 8px; font-size: 12px; }
|
||||
.status-badge.done { background: #d1fae5; }
|
||||
|
||||
.modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center; z-index: 100; }
|
||||
.modal { background: #fff; width: 90%; max-width: 400px; max-height: 80vh; border-radius: 12px; overflow: auto; padding: 16px; position: relative; }
|
||||
.modal .modal-close-btn { position: absolute; top: 10px; right: 12px; background: none; border: none; cursor: pointer; }
|
||||
.modal .modal-close-btn svg { width: 20px; height: 20px; stroke: #888; fill: none; stroke-width: 2; }
|
||||
.modal h3 { margin-bottom: 12px; font-size: 18px; padding-right: 30px; }
|
||||
.modal input { width: 100%; padding: 8px; margin: 8px 0; border: 1px solid #ddd; border-radius: 8px; font-size: 16px; }
|
||||
.modal button { margin-right: 8px; padding: 8px 16px; border-radius: 8px; border: none; cursor: pointer; font-size: 16px; }
|
||||
.modal .primary-btn { background: #3b82f6; color: #fff; }
|
||||
.history-list { list-style: none; font-size: 14px; }
|
||||
.history-list li { padding: 6px 0; border-bottom: 1px solid #f0f0f0; display: flex; }
|
||||
.history-time { min-width: 80px; color: #888; margin-right: 10px; font-size: 13px; }
|
||||
.img-thumb { width: 60px; height: 60px; object-fit: cover; border-radius: 4px; margin: 4px; border: 1px solid #eee; }
|
||||
.attachments { display: flex; flex-wrap: wrap; margin: 8px 0; }
|
||||
.msg-img { max-width: 200px; border-radius: 8px; margin: 4px 0; }
|
||||
.msg-placeholder .msg-bubble { opacity: 0.6; }
|
||||
|
||||
.version { text-align: center; font-size: 12px; color: #aaa; margin-top: 16px; }
|
||||
|
||||
.fullscreen-overlay {
|
||||
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0,0,0,0.9); display: flex; align-items: center; justify-content: center;
|
||||
z-index: 200; cursor: pointer;
|
||||
}
|
||||
.fullscreen-overlay img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||
.fullscreen-overlay .fullscreen-text {
|
||||
background: #fff; padding: 20px; border-radius: 8px; max-width: 90%; max-height: 80%;
|
||||
overflow: auto; font-size: 16px; line-height: 1.5; color: #333;
|
||||
}
|
||||
.fullscreen-overlay .fullscreen-text img { max-width: 200px; border-radius: 8px; margin: 4px 0; }
|
||||
.fullscreen-overlay .fullscreen-text table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.fullscreen-overlay .fullscreen-text th,
|
||||
.fullscreen-overlay .fullscreen-text td {border: 1px solid #ddd;padding: 4px;text-align: left;font-size: 14px;}
|
||||
.fullscreen-overlay .fullscreen-text th {background: #f0f0f0;}
|
||||
|
||||
.detail-row { margin-bottom: 8px; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app" id="app">
|
||||
@@ -132,7 +20,7 @@
|
||||
<input type="password" id="login-pass" placeholder="密码">
|
||||
<button class="primary-btn" onclick="login()" style="width:100%;">登录</button>
|
||||
<p id="login-error" style="color:red; margin-top:8px;"></p>
|
||||
<div class="version">v2.5</div>
|
||||
<div class="version">v2.6</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +39,7 @@
|
||||
<button class="icon-btn" id="create-room-btn" onclick="openCreateRoom()" title="新建群聊">
|
||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
||||
</button>
|
||||
<button class="icon-btn" id="logout-btn" onclick="logout()" title="注销登录">
|
||||
<button class="icon-btn" id="logout-btn" onclick="Store.logout()" title="注销登录">
|
||||
<svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -188,7 +76,6 @@
|
||||
<svg viewBox="0 0 24 24"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 采购清单覆盖层 -->
|
||||
<div id="purchase-panel-overlay" class="purchase-panel-overlay hidden">
|
||||
<div class="purchase-panel-header">
|
||||
<span>采购清单</span>
|
||||
@@ -201,7 +88,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建群聊弹窗 -->
|
||||
<!-- 弹窗 -->
|
||||
<div id="create-modal" class="modal-overlay hidden">
|
||||
<div class="modal">
|
||||
<button class="modal-close-btn" onclick="closeCreateModal()">
|
||||
@@ -214,7 +101,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理群聊弹窗 -->
|
||||
<div id="manage-modal" class="modal-overlay hidden">
|
||||
<div class="modal">
|
||||
<button class="modal-close-btn" onclick="closeManageModal()">
|
||||
@@ -228,7 +114,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 采购详情弹窗 -->
|
||||
<div id="detail-modal" class="modal-overlay hidden">
|
||||
<div class="modal">
|
||||
<button class="modal-close-btn" onclick="closeDetailModal()">
|
||||
@@ -240,632 +125,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/store.js"></script>
|
||||
<script src="/js/chat.js"></script>
|
||||
<script src="/js/ws.js"></script>
|
||||
<script src="/js/purchases.js"></script>
|
||||
<script src="/js/auth.js"></script>
|
||||
<script>
|
||||
const API = '';
|
||||
let ws;
|
||||
let currentRoom = null;
|
||||
let rooms = [];
|
||||
let pendingUploads = [];
|
||||
const messagesContainer = document.getElementById('messages-container');
|
||||
let messageCache = [];
|
||||
let tempBubbleTimer = null;
|
||||
|
||||
function getToken() { return localStorage.getItem('token'); }
|
||||
function saveAuth(data) {
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('currentUser', data.username);
|
||||
localStorage.setItem('isAdmin', data.isAdmin);
|
||||
}
|
||||
function clearAuth() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('currentUser');
|
||||
localStorage.removeItem('isAdmin');
|
||||
}
|
||||
function logout() {
|
||||
if (ws) { ws.close(); ws = null; }
|
||||
clearAuth();
|
||||
document.getElementById('login-page').classList.remove('hidden');
|
||||
document.getElementById('main-page').classList.add('hidden');
|
||||
currentRoom = null;
|
||||
rooms = [];
|
||||
document.getElementById('chat-list-container').innerHTML = '';
|
||||
document.getElementById('messages-container').innerHTML = '';
|
||||
}
|
||||
|
||||
if (typeof marked !== 'undefined') marked.setOptions({ breaks: true, gfm: true, sanitize: false });
|
||||
|
||||
function compressImage(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
let width = img.width, height = img.height;
|
||||
if (width > 1200) { height = height * (1200 / width); width = 1200; }
|
||||
canvas.width = width; canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.8);
|
||||
};
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function renderMessageHTML(msg) {
|
||||
const currentUser = localStorage.getItem('currentUser');
|
||||
const isMe = msg.user === currentUser;
|
||||
const name = msg.user === '小财' ? '小财' : msg.user;
|
||||
let contentHtml = msg.text ? escapeHtml(msg.text) : '';
|
||||
if (msg.user === '小财' && typeof marked !== 'undefined' && msg.text) {
|
||||
try { contentHtml = marked.parse(msg.text); } catch(e) {}
|
||||
}
|
||||
let attachHtml = '';
|
||||
if (msg.attachments && msg.attachments.length) {
|
||||
try {
|
||||
const files = typeof msg.attachments === 'string' ? JSON.parse(msg.attachments) : msg.attachments;
|
||||
attachHtml = files.map(f => {
|
||||
const isImage = /\.(jpg|jpeg|png|gif)$/i.test(f);
|
||||
if (isImage) return `<img class="msg-img" src="${f}" alt="${f}" loading="lazy">`;
|
||||
return `<div><a href="${f}" target="_blank">📎 ${f.split('/').pop()}</a></div>`;
|
||||
}).join('');
|
||||
} catch(e) {}
|
||||
}
|
||||
return `
|
||||
<div class="msg ${isMe ? 'me' : 'other'}" data-msg-id="${msg.id}">
|
||||
<div class="msg-user">${name}</div>
|
||||
<div class="msg-bubble">${contentHtml}${attachHtml}</div>
|
||||
<div class="msg-time">${msg.timestamp}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderAllMessages(msgs) {
|
||||
messageCache = msgs || [];
|
||||
messagesContainer.innerHTML = messageCache.map(renderMessageHTML).join('');
|
||||
scrollToBottom();
|
||||
bindMessageEvents();
|
||||
}
|
||||
|
||||
function appendMessage(msg) {
|
||||
messageCache.push(msg);
|
||||
messagesContainer.insertAdjacentHTML('beforeend', renderMessageHTML(msg));
|
||||
scrollToBottom();
|
||||
bindMessageEvents();
|
||||
clearTempBubble(); // 收到新消息时移除气泡
|
||||
}
|
||||
|
||||
function replaceMessage(msgId, newMsg) {
|
||||
const idx = messageCache.findIndex(m => m.id == msgId);
|
||||
if (idx !== -1) {
|
||||
messageCache[idx] = newMsg;
|
||||
const oldEl = messagesContainer.querySelector(`[data-msg-id="${msgId}"]`);
|
||||
if (oldEl) oldEl.outerHTML = renderMessageHTML(newMsg);
|
||||
} else {
|
||||
appendMessage(newMsg);
|
||||
}
|
||||
bindMessageEvents();
|
||||
}
|
||||
|
||||
function scrollToBottom() { messagesContainer.scrollTop = messagesContainer.scrollHeight; }
|
||||
|
||||
function bindMessageEvents() {
|
||||
document.querySelectorAll('.msg-img').forEach(img => {
|
||||
img.removeEventListener('dblclick', onImageDblClick);
|
||||
img.addEventListener('dblclick', onImageDblClick);
|
||||
});
|
||||
document.querySelectorAll('.msg-bubble').forEach(bubble => {
|
||||
bubble.removeEventListener('dblclick', onBubbleDblClick);
|
||||
bubble.addEventListener('dblclick', onBubbleDblClick);
|
||||
});
|
||||
}
|
||||
|
||||
function onImageDblClick(e) {
|
||||
e.stopPropagation();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'fullscreen-overlay';
|
||||
overlay.innerHTML = `<img src="${e.target.src}" alt="">`;
|
||||
overlay.addEventListener('click', () => overlay.remove());
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function onImgThumbDblClick(img) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'fullscreen-overlay';
|
||||
overlay.innerHTML = `<img src="${img.src}" style="max-width:100%;max-height:100%;object-fit:contain" alt="">`;
|
||||
overlay.addEventListener('click', () => overlay.remove());
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function onBubbleDblClick(e) {
|
||||
e.stopPropagation();
|
||||
const bubble = e.currentTarget;
|
||||
const clone = bubble.cloneNode(true);
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'fullscreen-overlay';
|
||||
const container = document.createElement('div');
|
||||
container.className = 'fullscreen-text';
|
||||
container.innerHTML = clone.innerHTML;
|
||||
overlay.appendChild(container);
|
||||
overlay.addEventListener('click', () => overlay.remove());
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
// 临时"输入中..."气泡
|
||||
function insertTempBubble() {
|
||||
clearTempBubble();
|
||||
const tempId = 'temp_' + Date.now();
|
||||
const tempMsg = { id: tempId, user: '小财', text: '输入中...', attachments: [], timestamp: '', isTemp: true };
|
||||
messageCache.push(tempMsg);
|
||||
messagesContainer.insertAdjacentHTML('beforeend', renderMessageHTML(tempMsg));
|
||||
scrollToBottom();
|
||||
tempBubbleTimer = setTimeout(() => { clearTempBubble(); }, 8000);
|
||||
// 降级轮询:如果 WebSocket 消息丢失,定时从服务端拉取
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
let pollCount = 0;
|
||||
pollTimer = setInterval(async () => {
|
||||
pollCount++;
|
||||
if (!currentRoom || pollCount > 5) { clearInterval(pollTimer); pollTimer = null; return; }
|
||||
try {
|
||||
const token = getToken();
|
||||
const res = await fetch(API + `/api/rooms/${currentRoom}/messages`, { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const msgs = await res.json();
|
||||
const lastCacheId = messageCache.filter(m => !m.isTemp && !m.isPlaceholder).slice(-1)[0]?.id;
|
||||
const newMsgs = msgs.filter(m => !lastCacheId || m.id > lastCacheId);
|
||||
if (newMsgs.length) {
|
||||
clearInterval(pollTimer); pollTimer = null;
|
||||
clearTempBubble();
|
||||
newMsgs.forEach(m => appendMessage(m));
|
||||
}
|
||||
} catch(e) {}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function clearTempBubble() {
|
||||
if (tempBubbleTimer) { clearTimeout(tempBubbleTimer); tempBubbleTimer = null; }
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||||
messageCache = messageCache.filter(m => !m.isTemp);
|
||||
const tempEls = messagesContainer.querySelectorAll('[data-msg-id^="temp_"]');
|
||||
tempEls.forEach(el => el.remove());
|
||||
}
|
||||
|
||||
// WebSocket 重连
|
||||
let wsReconnectTimer = null;
|
||||
let reconnectAttempts = 0;
|
||||
const MAX_RECONNECT_DELAY = 30000;
|
||||
let pollTimer = null; // 降级轮询定时器
|
||||
|
||||
function initWebSocket() {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${protocol}//${location.host}?token=${token}`);
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket 已连接');
|
||||
reconnectAttempts = 0;
|
||||
if (currentRoom) ws.send(JSON.stringify({ type: 'join', roomId: currentRoom }));
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
console.log('📩 WS 收到:', data.type);
|
||||
if (data.type === 'new_message') {
|
||||
const msg = data.message;
|
||||
if (currentRoom === msg.room_id) {
|
||||
appendMessage(msg);
|
||||
}
|
||||
if (data.room_preview) {
|
||||
const room = rooms.find(r => r.id === data.room_preview.room_id);
|
||||
if (room) { room.last_message = data.room_preview.last_message; room.last_time = data.room_preview.last_time; renderChatList(); }
|
||||
}
|
||||
} else if (data.type === 'purchase_updated') {
|
||||
if (currentRoom) loadPurchases();
|
||||
updateSummaryPreview();
|
||||
} else if (data.type === 'room_created') {
|
||||
loadRooms();
|
||||
} else if (data.type === 'room_updated') {
|
||||
const room = rooms.find(r => r.id === data.room.id);
|
||||
if (room) { room.name = data.room.name; room.white_list = data.room.white_list; renderChatList(); }
|
||||
} else if (data.type === 'room_deleted') {
|
||||
rooms = rooms.filter(r => r.id !== data.roomId);
|
||||
renderChatList();
|
||||
if (currentRoom === data.roomId) showList();
|
||||
}
|
||||
};
|
||||
ws.onerror = (e) => console.error('WebSocket 错误', e);
|
||||
ws.onclose = (event) => {
|
||||
console.log('WebSocket 关闭,代码:', event.code);
|
||||
if (event.code === 1000) return;
|
||||
scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY);
|
||||
reconnectAttempts++;
|
||||
wsReconnectTimer = setTimeout(() => { console.log('尝试重连...'); initWebSocket(); }, delay);
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
|
||||
initWebSocket();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function init() {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
try {
|
||||
const res = await fetch(API + '/api/user', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
if (res.ok) {
|
||||
const user = await res.json();
|
||||
document.getElementById('login-page').classList.add('hidden');
|
||||
document.getElementById('main-page').classList.remove('hidden');
|
||||
if (localStorage.getItem('isAdmin') === 'true') {
|
||||
document.getElementById('create-room-btn').classList.add('visible');
|
||||
} else {
|
||||
document.getElementById('create-room-btn').classList.remove('visible');
|
||||
}
|
||||
initWebSocket();
|
||||
loadRooms();
|
||||
updateSummaryPreview();
|
||||
initSwipeGestures();
|
||||
return;
|
||||
}
|
||||
} catch(e) {}
|
||||
clearAuth();
|
||||
}
|
||||
document.getElementById('login-page').classList.remove('hidden');
|
||||
document.getElementById('main-page').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const u = document.getElementById('login-user').value;
|
||||
const p = document.getElementById('login-pass').value;
|
||||
const res = await fetch(API + '/api/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: u, password: p })
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
saveAuth(data);
|
||||
document.getElementById('login-page').classList.add('hidden');
|
||||
document.getElementById('main-page').classList.remove('hidden');
|
||||
if (data.isAdmin) {
|
||||
document.getElementById('create-room-btn').classList.add('visible');
|
||||
} else {
|
||||
document.getElementById('create-room-btn').classList.remove('visible');
|
||||
}
|
||||
initWebSocket();
|
||||
loadRooms();
|
||||
updateSummaryPreview();
|
||||
initSwipeGestures();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
document.getElementById('login-error').textContent = err.error || '登录失败';
|
||||
}
|
||||
}
|
||||
|
||||
function initSwipeGestures() {
|
||||
const chatPage = document.getElementById('chat-page');
|
||||
const purchaseOverlay = document.getElementById('purchase-panel-overlay');
|
||||
let startX = 0, startY = 0;
|
||||
|
||||
function handleTouchStart(e) {
|
||||
startX = e.touches[0].clientX;
|
||||
startY = e.touches[0].clientY;
|
||||
}
|
||||
|
||||
function handleTouchEnd(e, target) {
|
||||
if (!startX) return;
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const endY = e.changedTouches[0].clientY;
|
||||
const diffX = endX - startX;
|
||||
const diffY = endY - startY;
|
||||
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
|
||||
if (target === chatPage && !purchaseOverlay.classList.contains('hidden')) {
|
||||
if (Math.abs(diffX) > 30) closePurchasePanel();
|
||||
} else if (target === chatPage) {
|
||||
if (diffX > 30) showList();
|
||||
else if (diffX < -30) openPurchasePanel();
|
||||
} else if (target === purchaseOverlay) {
|
||||
if (Math.abs(diffX) > 30) closePurchasePanel();
|
||||
}
|
||||
}
|
||||
startX = 0; startY = 0;
|
||||
}
|
||||
|
||||
chatPage.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
chatPage.addEventListener('touchend', (e) => handleTouchEnd(e, chatPage), { passive: true });
|
||||
purchaseOverlay.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
purchaseOverlay.addEventListener('touchend', (e) => handleTouchEnd(e, purchaseOverlay), { passive: true });
|
||||
}
|
||||
|
||||
async function loadRooms() {
|
||||
const token = getToken();
|
||||
const res = await fetch(API + '/api/rooms', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
if (res.status === 401) { clearAuth(); location.reload(); return; }
|
||||
rooms = await res.json();
|
||||
renderChatList();
|
||||
}
|
||||
|
||||
function renderChatList() {
|
||||
const container = document.getElementById('chat-list-container');
|
||||
const isAdmin = localStorage.getItem('isAdmin') === 'true';
|
||||
container.innerHTML = rooms.map(room => {
|
||||
const lastMsg = room.last_message || '暂无消息';
|
||||
const lastTime = room.last_time || '';
|
||||
return `
|
||||
<div class="chat-item" data-room-id="${room.id}">
|
||||
<div class="avatar" onclick="openChat('${room.id}')">${room.name.charAt(0)}</div>
|
||||
<div class="chat-info" onclick="openChat('${room.id}')">
|
||||
<div class="chat-name">${room.name}</div>
|
||||
<div class="last-msg">${escapeHtml(lastMsg)}</div>
|
||||
</div>
|
||||
<div class="chat-right">
|
||||
<div class="chat-time" onclick="openChat('${room.id}')">${lastTime}</div>
|
||||
${isAdmin ? `<div class="room-actions">
|
||||
<button class="edit-room-btn" onclick="event.stopPropagation(); openManageRoom('${room.id}')" title="编辑群聊">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
|
||||
</button>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function updateSummaryPreview() {
|
||||
const token = getToken();
|
||||
try {
|
||||
const res = await fetch(API + '/api/summary', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const summary = await res.json();
|
||||
const totalItems = summary.reduce((s, r) => s + r.purchase_count, 0);
|
||||
const totalAmount = summary.reduce((s, r) => s + r.total_amount, 0);
|
||||
const preview = document.getElementById('summary-preview');
|
||||
if (preview) preview.innerText = `共 ${totalItems} 条,合计 ¥${totalAmount}`;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function openChat(roomId, autoOpenPurchase = false) {
|
||||
currentRoom = roomId;
|
||||
clearTempBubble();
|
||||
document.getElementById('list-page').classList.add('hidden');
|
||||
document.getElementById('chat-page').classList.remove('hidden');
|
||||
const room = rooms.find(r => r.id === roomId);
|
||||
document.getElementById('current-chat-name').textContent = room?.name || '';
|
||||
document.getElementById('purchase-panel-overlay').classList.add('hidden');
|
||||
if (autoOpenPurchase) setTimeout(() => openPurchasePanel(), 200);
|
||||
const token = getToken();
|
||||
const res = await fetch(API + `/api/rooms/${roomId}/messages`, { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const msgs = await res.json();
|
||||
renderAllMessages(msgs);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'join', roomId }));
|
||||
}
|
||||
|
||||
function showList() {
|
||||
clearTempBubble();
|
||||
document.getElementById('chat-page').classList.add('hidden');
|
||||
document.getElementById('list-page').classList.remove('hidden');
|
||||
currentRoom = null;
|
||||
loadRooms();
|
||||
updateSummaryPreview();
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById('msg-input');
|
||||
const text = input.value.trim();
|
||||
if (!text && pendingUploads.length === 0) return;
|
||||
if (!currentRoom) return;
|
||||
const token = getToken();
|
||||
try {
|
||||
const res = await fetch(API + `/api/rooms/${currentRoom}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: text || '', attachments: pendingUploads })
|
||||
});
|
||||
const newMsg = await res.json();
|
||||
appendMessage(newMsg);
|
||||
input.value = '';
|
||||
pendingUploads = [];
|
||||
insertTempBubble(); // 显示“输入中...”
|
||||
} catch(e) { alert('发送失败,请重试'); }
|
||||
}
|
||||
|
||||
async function handleFileSelect(event) {
|
||||
const files = event.target.files;
|
||||
if (!files.length) return;
|
||||
const token = getToken();
|
||||
const placeholderId = 'placeholder_' + Date.now();
|
||||
const placeholderMsg = {
|
||||
id: placeholderId, room_id: currentRoom, user: localStorage.getItem('currentUser'),
|
||||
text: '图片发送中...', attachments: [], timestamp: new Date().toLocaleTimeString('zh-CN', { hour12: false }), isPlaceholder: true
|
||||
};
|
||||
appendMessage(placeholderMsg);
|
||||
try {
|
||||
for (let file of files) {
|
||||
let blob = file;
|
||||
if (file.size > 1 * 1024 * 1024) blob = await compressImage(file);
|
||||
const formData = new FormData(); formData.append('file', blob, file.name || 'image.jpg');
|
||||
const res = await fetch(API + '/api/upload', { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData });
|
||||
const data = await res.json();
|
||||
if (data.path) pendingUploads.push(data.path);
|
||||
}
|
||||
messageCache = messageCache.filter(m => m.id !== placeholderId);
|
||||
const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`);
|
||||
if (placeholderEl) placeholderEl.remove();
|
||||
sendMessage();
|
||||
} catch(e) {
|
||||
messageCache = messageCache.filter(m => m.id !== placeholderId);
|
||||
const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`);
|
||||
if (placeholderEl) placeholderEl.remove();
|
||||
alert('图片发送失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPurchases() {
|
||||
if (!currentRoom) return;
|
||||
const token = getToken();
|
||||
const res = await fetch(API + `/api/rooms/${currentRoom}/purchases`, { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const items = await res.json();
|
||||
renderPurchasePanel(items);
|
||||
}
|
||||
|
||||
function renderPurchasePanel(items) {
|
||||
const container = document.getElementById('purchase-panel-body');
|
||||
if (items.length === 0) { container.innerHTML = '<p style="text-align:center;color:#888;padding:40px;">暂无采购记录</p>'; return; }
|
||||
const groups = {};
|
||||
items.forEach(item => {
|
||||
const month = formatMonth(item.created_at);
|
||||
if (!groups[month]) groups[month] = [];
|
||||
groups[month].push(item);
|
||||
});
|
||||
let html = '';
|
||||
Object.keys(groups).sort().reverse().forEach(month => {
|
||||
const monthItems = groups[month];
|
||||
const monthTotal = monthItems.reduce((s, i) => s + (i.amount||0), 0);
|
||||
html += `<div class="purchase-month-group"><div class="purchase-month-header"><span>${month}</span><span>¥${monthTotal}</span></div>`;
|
||||
monthItems.forEach(item => {
|
||||
const timeShort = item.created_at.split(' ')[1]?.substring(0,5) || '';
|
||||
const dateShort = item.created_at.split(' ')[0]?.substring(5) || '';
|
||||
html += `
|
||||
<div class="purchase-item" onclick="openDetail('${item.id}')">
|
||||
<div class="item-main"><span class="item-name">${item.item}</span><span class="item-amount">¥${item.amount}</span></div>
|
||||
<div class="item-meta">
|
||||
<span>${dateShort} ${timeShort}</span>
|
||||
<span>×${item.quantity} ${item.invoice_type !== '无票' ? '· ' + item.invoice_type : ''}</span>
|
||||
<span class="status-badge ${item.status==='已完成'?'done':''}">${item.status}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function formatMonth(dateStr) {
|
||||
const clean = dateStr.replace(/-/g, '/');
|
||||
const parts = clean.split('/');
|
||||
if (parts.length >= 2) return `${parts[0]}年${parseInt(parts[1], 10)}月`;
|
||||
return dateStr.substring(0,7);
|
||||
}
|
||||
|
||||
function openPurchasePanel() {
|
||||
document.getElementById('purchase-panel-body').innerHTML = '<p style="text-align:center;color:#888;">加载中...</p>';
|
||||
document.getElementById('purchase-panel-overlay').classList.remove('hidden');
|
||||
loadPurchases();
|
||||
}
|
||||
function closePurchasePanel() { document.getElementById('purchase-panel-overlay').classList.add('hidden'); }
|
||||
|
||||
async function openDetail(pid) {
|
||||
const token = getToken();
|
||||
const res = await fetch(API + `/api/purchases/${pid}`, { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const p = await res.json();
|
||||
document.getElementById('detail-title').textContent = p.item;
|
||||
let attachHtml = '';
|
||||
if (p.attachments?.length) attachHtml = '<div class="attachments">' + p.attachments.map(a => `<img class="img-thumb" src="${a.file_path}" alt="${a.file_path}" ondblclick="onImgThumbDblClick(this)">`).join('') + '</div>';
|
||||
const historyHtml = p.history?.map(h => `<li><span class="history-time">${h.timestamp}</span> ${h.user}: ${h.action}</li>`).join('') || '';
|
||||
document.getElementById('detail-content').innerHTML = `
|
||||
<div class="detail-row"><strong>数量:</strong>${p.quantity}</div>
|
||||
<div class="detail-row"><strong>单价:</strong>¥${p.unit_price}</div>
|
||||
<div class="detail-row"><strong>邮费:</strong>¥${p.freight}</div>
|
||||
<div class="detail-row"><strong>总金额:</strong>¥${p.amount}</div>
|
||||
<div class="detail-row"><strong>付款方式:</strong>${p.payment_method || '未指定'}</div>
|
||||
<div class="detail-row"><strong>发票:</strong>${p.invoice_type || '无票'}</div>
|
||||
<div class="detail-row"><strong>状态:</strong>${p.status}</div>
|
||||
<div class="detail-row"><strong>申请人:</strong>${p.applicant}</div>
|
||||
<div class="detail-row"><strong>采购时间:</strong>${p.created_at}</div>
|
||||
<div class="detail-row"><strong>备注:</strong>${p.remarks || '无'}</div>
|
||||
${attachHtml}
|
||||
<h4 style="margin-top:12px;">操作历史</h4>
|
||||
<ul class="history-list">${historyHtml}</ul>`;
|
||||
document.getElementById('detail-modal').classList.remove('hidden');
|
||||
}
|
||||
function closeDetailModal() { document.getElementById('detail-modal').classList.add('hidden'); }
|
||||
|
||||
async function openSummary() {
|
||||
const token = getToken();
|
||||
const res = await fetch(API + '/api/summary', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const summary = await res.json();
|
||||
let html = '';
|
||||
if (summary.length === 0) html = '<p>暂无采购记录</p>';
|
||||
else {
|
||||
summary.forEach(s => {
|
||||
html += `<div class="summary-card" onclick="event.stopPropagation(); closeDetailModal(); openChat('${s.room_id}', true)" style="margin:8px 0;"><h4>${s.room_name}</h4><p>${s.purchase_count} 条,合计 ¥${s.total_amount}</p></div>`;
|
||||
});
|
||||
}
|
||||
document.getElementById('detail-title').textContent = '采购汇总';
|
||||
document.getElementById('detail-content').innerHTML = html;
|
||||
document.getElementById('detail-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function openCreateRoom() { document.getElementById('create-modal').classList.remove('hidden'); }
|
||||
function closeCreateModal() { document.getElementById('create-modal').classList.add('hidden'); }
|
||||
async function confirmCreateRoom() {
|
||||
const name = document.getElementById('new-room-name').value.trim();
|
||||
if (!name) return alert('请输入名称');
|
||||
const whiteList = document.getElementById('white-list').value.trim();
|
||||
const token = getToken();
|
||||
await fetch(API + '/api/rooms', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, whiteList }) });
|
||||
closeCreateModal();
|
||||
loadRooms();
|
||||
}
|
||||
|
||||
function openManageRoom(roomId) {
|
||||
const room = rooms.find(r => r.id === roomId);
|
||||
if (!room) return;
|
||||
document.getElementById('manage-title').textContent = '编辑群聊';
|
||||
document.getElementById('manage-room-name').value = room.name;
|
||||
document.getElementById('manage-white-list').value = room.white_list || '';
|
||||
document.getElementById('manage-modal').dataset.roomId = roomId;
|
||||
document.getElementById('manage-modal').classList.remove('hidden');
|
||||
}
|
||||
function closeManageModal() { document.getElementById('manage-modal').classList.add('hidden'); }
|
||||
async function saveRoom() {
|
||||
const roomId = document.getElementById('manage-modal').dataset.roomId;
|
||||
const name = document.getElementById('manage-room-name').value.trim();
|
||||
const whiteList = document.getElementById('manage-white-list').value.trim();
|
||||
if (!name) return alert('请输入群聊名称');
|
||||
const token = getToken();
|
||||
const res = await fetch(API + '/api/rooms/' + roomId, {
|
||||
method: 'PUT', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, whiteList })
|
||||
});
|
||||
if (res.ok) { closeManageModal(); loadRooms(); }
|
||||
else { const err = await res.json(); alert(err.error || '更新失败'); }
|
||||
}
|
||||
async function deleteRoom() {
|
||||
if (!confirm('确定要删除该群聊吗?所有消息和采购数据将被永久删除。')) return;
|
||||
const roomId = document.getElementById('manage-modal').dataset.roomId;
|
||||
const token = getToken();
|
||||
const res = await fetch(API + '/api/rooms/' + roomId, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } });
|
||||
if (res.ok) {
|
||||
closeManageModal();
|
||||
loadRooms();
|
||||
if (currentRoom === roomId) showList();
|
||||
} else { const err = await res.json(); alert(err.error || '删除失败'); }
|
||||
}
|
||||
|
||||
document.addEventListener('input', function(e) {
|
||||
if (e.target.id === 'msg-input') {
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = e.target.scrollHeight + 'px';
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.target.id === 'msg-input' && e.key === 'Enter' && !e.ctrlKey) { e.preventDefault(); sendMessage(); }
|
||||
});
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
54
server/public/js/auth.js
Normal file
54
server/public/js/auth.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// 认证与初始化
|
||||
async function init() {
|
||||
const token = Store.getToken();
|
||||
if (token) {
|
||||
try {
|
||||
const res = await fetch('/api/user', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
if (res.ok) {
|
||||
const user = await res.json();
|
||||
document.getElementById('login-page').classList.add('hidden');
|
||||
document.getElementById('main-page').classList.remove('hidden');
|
||||
if (localStorage.getItem('isAdmin') === 'true') {
|
||||
document.getElementById('create-room-btn').classList.add('visible');
|
||||
} else {
|
||||
document.getElementById('create-room-btn').classList.remove('visible');
|
||||
}
|
||||
initWebSocket();
|
||||
loadRooms();
|
||||
updateSummaryPreview();
|
||||
initSwipeGestures();
|
||||
return;
|
||||
}
|
||||
} catch(e) {}
|
||||
Store.clearAuth();
|
||||
}
|
||||
document.getElementById('login-page').classList.remove('hidden');
|
||||
document.getElementById('main-page').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const u = document.getElementById('login-user').value;
|
||||
const p = document.getElementById('login-pass').value;
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: u, password: p })
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
Store.saveAuth(data);
|
||||
document.getElementById('login-page').classList.add('hidden');
|
||||
document.getElementById('main-page').classList.remove('hidden');
|
||||
if (data.isAdmin) {
|
||||
document.getElementById('create-room-btn').classList.add('visible');
|
||||
} else {
|
||||
document.getElementById('create-room-btn').classList.remove('visible');
|
||||
}
|
||||
initWebSocket();
|
||||
loadRooms();
|
||||
updateSummaryPreview();
|
||||
initSwipeGestures();
|
||||
} else {
|
||||
const err = await res.json();
|
||||
document.getElementById('login-error').textContent = err.error || '登录失败';
|
||||
}
|
||||
}
|
||||
167
server/public/js/chat.js
Normal file
167
server/public/js/chat.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// 工具函数
|
||||
if (typeof marked !== 'undefined') marked.setOptions({ breaks: true, gfm: true, sanitize: false });
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function compressImage(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
let width = img.width, height = img.height;
|
||||
if (width > 1200) { height = height * (1200 / width); width = 1200; }
|
||||
canvas.width = width; canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.8);
|
||||
};
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// 消息渲染
|
||||
const messagesContainer = document.getElementById('messages-container');
|
||||
|
||||
function renderMessageHTML(msg) {
|
||||
const currentUser = localStorage.getItem('currentUser');
|
||||
const isMe = msg.user === currentUser;
|
||||
const name = msg.user === '小财' ? '小财' : msg.user;
|
||||
let contentHtml = msg.text ? escapeHtml(msg.text) : '';
|
||||
if (msg.user === '小财' && typeof marked !== 'undefined' && msg.text) {
|
||||
try { contentHtml = marked.parse(msg.text); } catch(e) {}
|
||||
}
|
||||
let attachHtml = '';
|
||||
if (msg.attachments && msg.attachments.length) {
|
||||
try {
|
||||
const files = typeof msg.attachments === 'string' ? JSON.parse(msg.attachments) : msg.attachments;
|
||||
attachHtml = files.map(f => {
|
||||
const isImage = /\.(jpg|jpeg|png|gif)$/i.test(f);
|
||||
if (isImage) return `<img class="msg-img" src="${f}" alt="${f}" loading="lazy">`;
|
||||
return `<div><a href="${f}" target="_blank">📎 ${f.split('/').pop()}</a></div>`;
|
||||
}).join('');
|
||||
} catch(e) {}
|
||||
}
|
||||
return `
|
||||
<div class="msg ${isMe ? 'me' : 'other'}" data-msg-id="${msg.id}">
|
||||
<div class="msg-user">${name}</div>
|
||||
<div class="msg-bubble">${contentHtml}${attachHtml}</div>
|
||||
<div class="msg-time">${msg.timestamp}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderAllMessages(msgs) {
|
||||
Store.messageCache = msgs || [];
|
||||
messagesContainer.innerHTML = Store.messageCache.map(renderMessageHTML).join('');
|
||||
scrollToBottom();
|
||||
bindMessageEvents();
|
||||
}
|
||||
|
||||
function appendMessage(msg) {
|
||||
Store.messageCache.push(msg);
|
||||
messagesContainer.insertAdjacentHTML('beforeend', renderMessageHTML(msg));
|
||||
scrollToBottom();
|
||||
bindMessageEvents();
|
||||
clearTempBubble();
|
||||
}
|
||||
|
||||
function replaceMessage(msgId, newMsg) {
|
||||
const idx = Store.messageCache.findIndex(m => m.id == msgId);
|
||||
if (idx !== -1) {
|
||||
Store.messageCache[idx] = newMsg;
|
||||
const oldEl = messagesContainer.querySelector(`[data-msg-id="${msgId}"]`);
|
||||
if (oldEl) oldEl.outerHTML = renderMessageHTML(newMsg);
|
||||
} else {
|
||||
appendMessage(newMsg);
|
||||
}
|
||||
bindMessageEvents();
|
||||
}
|
||||
|
||||
function scrollToBottom() { messagesContainer.scrollTop = messagesContainer.scrollHeight; }
|
||||
|
||||
// 图片/气泡双击
|
||||
function bindMessageEvents() {
|
||||
document.querySelectorAll('.msg-img').forEach(img => {
|
||||
img.removeEventListener('dblclick', onImageDblClick);
|
||||
img.addEventListener('dblclick', onImageDblClick);
|
||||
});
|
||||
document.querySelectorAll('.msg-bubble').forEach(bubble => {
|
||||
bubble.removeEventListener('dblclick', onBubbleDblClick);
|
||||
bubble.addEventListener('dblclick', onBubbleDblClick);
|
||||
});
|
||||
}
|
||||
|
||||
function onImageDblClick(e) {
|
||||
e.stopPropagation();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'fullscreen-overlay';
|
||||
overlay.innerHTML = `<img src="${e.target.src}" alt="">`;
|
||||
overlay.addEventListener('click', () => overlay.remove());
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function onImgThumbDblClick(img) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'fullscreen-overlay';
|
||||
overlay.innerHTML = `<img src="${img.src}" style="max-width:100%;max-height:100%;object-fit:contain" alt="">`;
|
||||
overlay.addEventListener('click', () => overlay.remove());
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function onBubbleDblClick(e) {
|
||||
e.stopPropagation();
|
||||
const bubble = e.currentTarget;
|
||||
const clone = bubble.cloneNode(true);
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'fullscreen-overlay';
|
||||
const container = document.createElement('div');
|
||||
container.className = 'fullscreen-text';
|
||||
container.innerHTML = clone.innerHTML;
|
||||
overlay.appendChild(container);
|
||||
overlay.addEventListener('click', () => overlay.remove());
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
// 临时气泡 + 降级轮询
|
||||
function insertTempBubble() {
|
||||
clearTempBubble();
|
||||
const tempId = 'temp_' + Date.now();
|
||||
const tempMsg = { id: tempId, user: '小财', text: '输入中...', attachments: [], timestamp: '', isTemp: true };
|
||||
Store.messageCache.push(tempMsg);
|
||||
messagesContainer.insertAdjacentHTML('beforeend', renderMessageHTML(tempMsg));
|
||||
scrollToBottom();
|
||||
Store.tempBubbleTimer = setTimeout(() => { clearTempBubble(); }, 8000);
|
||||
if (Store.pollTimer) clearInterval(Store.pollTimer);
|
||||
let pollCount = 0;
|
||||
Store.pollTimer = setInterval(async () => {
|
||||
pollCount++;
|
||||
if (!Store.currentRoom || pollCount > 5) { clearInterval(Store.pollTimer); Store.pollTimer = null; return; }
|
||||
try {
|
||||
const token = Store.getToken();
|
||||
const res = await fetch('/api/rooms/' + Store.currentRoom + '/messages', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const msgs = await res.json();
|
||||
const lastCacheId = Store.messageCache.filter(m => !m.isTemp && !m.isPlaceholder).slice(-1)[0]?.id;
|
||||
const newMsgs = msgs.filter(m => !lastCacheId || m.id > lastCacheId);
|
||||
if (newMsgs.length) {
|
||||
clearInterval(Store.pollTimer); Store.pollTimer = null;
|
||||
clearTempBubble();
|
||||
newMsgs.forEach(m => appendMessage(m));
|
||||
}
|
||||
} catch(e) {}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function clearTempBubble() {
|
||||
if (Store.tempBubbleTimer) { clearTimeout(Store.tempBubbleTimer); Store.tempBubbleTimer = null; }
|
||||
if (Store.pollTimer) { clearInterval(Store.pollTimer); Store.pollTimer = null; }
|
||||
Store.messageCache = Store.messageCache.filter(m => !m.isTemp);
|
||||
const tempEls = messagesContainer.querySelectorAll('[data-msg-id^="temp_"]');
|
||||
tempEls.forEach(el => el.remove());
|
||||
}
|
||||
182
server/public/js/purchases.js
Normal file
182
server/public/js/purchases.js
Normal file
@@ -0,0 +1,182 @@
|
||||
// 采购相关功能
|
||||
async function loadPurchases() {
|
||||
if (!Store.currentRoom) return;
|
||||
const token = Store.getToken();
|
||||
const res = await fetch('/api/rooms/' + Store.currentRoom + '/purchases', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const items = await res.json();
|
||||
renderPurchasePanel(items);
|
||||
}
|
||||
|
||||
function renderPurchasePanel(items) {
|
||||
const container = document.getElementById('purchase-panel-body');
|
||||
if (items.length === 0) { container.innerHTML = '<p style="text-align:center;color:#888;padding:40px;">暂无采购记录</p>'; return; }
|
||||
const groups = {};
|
||||
items.forEach(item => {
|
||||
const month = formatMonth(item.created_at);
|
||||
if (!groups[month]) groups[month] = [];
|
||||
groups[month].push(item);
|
||||
});
|
||||
let html = '';
|
||||
Object.keys(groups).sort().reverse().forEach(month => {
|
||||
const monthItems = groups[month];
|
||||
const monthTotal = monthItems.reduce((s, i) => s + (i.amount||0), 0);
|
||||
html += `<div class="purchase-month-group"><div class="purchase-month-header"><span>${month}</span><span>¥${monthTotal}</span></div>`;
|
||||
monthItems.forEach(item => {
|
||||
const timeShort = item.created_at.split(' ')[1]?.substring(0,5) || '';
|
||||
const dateShort = item.created_at.split(' ')[0]?.substring(5) || '';
|
||||
html += `
|
||||
<div class="purchase-item" onclick="openDetail('${item.id}')">
|
||||
<div class="item-main"><span class="item-name">${escapeHtml(item.item)}</span><span class="item-amount">¥${item.amount}</span></div>
|
||||
<div class="item-meta">
|
||||
<span>${dateShort} ${timeShort}</span>
|
||||
<span>×${item.quantity} ${item.invoice_type !== '无票' ? '· ' + item.invoice_type : ''}</span>
|
||||
<span class="status-badge ${item.status==='已完成'?'done':''}">${item.status}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function formatMonth(dateStr) {
|
||||
const clean = dateStr.replace(/-/g, '/');
|
||||
const parts = clean.split('/');
|
||||
if (parts.length >= 2) return `${parts[0]}年${parseInt(parts[1], 10)}月`;
|
||||
return dateStr.substring(0,7);
|
||||
}
|
||||
|
||||
function openPurchasePanel() {
|
||||
document.getElementById('purchase-panel-body').innerHTML = '<p style="text-align:center;color:#888;">加载中...</p>';
|
||||
document.getElementById('purchase-panel-overlay').classList.remove('hidden');
|
||||
loadPurchases();
|
||||
}
|
||||
|
||||
function closePurchasePanel() { document.getElementById('purchase-panel-overlay').classList.add('hidden'); }
|
||||
|
||||
async function openDetail(pid) {
|
||||
const token = Store.getToken();
|
||||
const res = await fetch('/api/purchases/' + pid, { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const p = await res.json();
|
||||
document.getElementById('detail-title').textContent = p.item;
|
||||
let attachHtml = '';
|
||||
if (p.attachments?.length) attachHtml = '<div class="attachments">' + p.attachments.map(a => `<img class="img-thumb" src="${a.file_path}" alt="${a.file_path}" ondblclick="onImgThumbDblClick(this)">`).join('') + '</div>';
|
||||
const historyHtml = p.history?.map(h => `<li><span class="history-time">${h.timestamp}</span> ${h.user}: ${h.action}</li>`).join('') || '';
|
||||
document.getElementById('detail-content').innerHTML = `
|
||||
<div class="detail-row"><strong>数量:</strong>${p.quantity}</div>
|
||||
<div class="detail-row"><strong>单价:</strong>¥${p.unit_price}</div>
|
||||
<div class="detail-row"><strong>邮费:</strong>¥${p.freight}</div>
|
||||
<div class="detail-row"><strong>总金额:</strong>¥${p.amount}</div>
|
||||
<div class="detail-row"><strong>付款方式:</strong>${p.payment_method || '未指定'}</div>
|
||||
<div class="detail-row"><strong>发票:</strong>${p.invoice_type || '无票'}</div>
|
||||
<div class="detail-row"><strong>状态:</strong>${p.status}</div>
|
||||
<div class="detail-row"><strong>申请人:</strong>${p.applicant}</div>
|
||||
<div class="detail-row"><strong>采购时间:</strong>${p.created_at}</div>
|
||||
<div class="detail-row"><strong>备注:</strong>${p.remarks || '无'}</div>
|
||||
${attachHtml}
|
||||
<h4 style="margin-top:12px;">操作历史</h4>
|
||||
<ul class="history-list">${historyHtml}</ul>`;
|
||||
document.getElementById('detail-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeDetailModal() { document.getElementById('detail-modal').classList.add('hidden'); }
|
||||
|
||||
async function openSummary() {
|
||||
const token = Store.getToken();
|
||||
const res = await fetch('/api/summary', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const summary = await res.json();
|
||||
let html = '';
|
||||
if (summary.length === 0) html = '<p>暂无采购记录</p>';
|
||||
else {
|
||||
summary.forEach(s => {
|
||||
html += `<div class="summary-card" onclick="event.stopPropagation(); closeDetailModal(); openChat('${s.room_id}', true)" style="margin:8px 0;"><h4>${escapeHtml(s.room_name)}</h4><p>${s.purchase_count} 条,合计 ¥${s.total_amount}</p></div>`;
|
||||
});
|
||||
}
|
||||
document.getElementById('detail-title').textContent = '采购汇总';
|
||||
document.getElementById('detail-content').innerHTML = html;
|
||||
document.getElementById('detail-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 群聊管理
|
||||
function openCreateRoom() { document.getElementById('create-modal').classList.remove('hidden'); }
|
||||
function closeCreateModal() { document.getElementById('create-modal').classList.add('hidden'); }
|
||||
async function confirmCreateRoom() {
|
||||
const name = document.getElementById('new-room-name').value.trim();
|
||||
if (!name) return alert('请输入名称');
|
||||
const whiteList = document.getElementById('white-list').value.trim();
|
||||
const token = Store.getToken();
|
||||
await fetch('/api/rooms', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, whiteList }) });
|
||||
closeCreateModal();
|
||||
loadRooms();
|
||||
}
|
||||
|
||||
function openManageRoom(roomId) {
|
||||
const room = Store.rooms.find(r => r.id === roomId);
|
||||
if (!room) return;
|
||||
document.getElementById('manage-title').textContent = '编辑群聊';
|
||||
document.getElementById('manage-room-name').value = room.name;
|
||||
document.getElementById('manage-white-list').value = room.white_list || '';
|
||||
document.getElementById('manage-modal').dataset.roomId = roomId;
|
||||
document.getElementById('manage-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeManageModal() { document.getElementById('manage-modal').classList.add('hidden'); }
|
||||
|
||||
async function saveRoom() {
|
||||
const roomId = document.getElementById('manage-modal').dataset.roomId;
|
||||
const name = document.getElementById('manage-room-name').value.trim();
|
||||
const whiteList = document.getElementById('manage-white-list').value.trim();
|
||||
if (!name) return alert('请输入群聊名称');
|
||||
const token = Store.getToken();
|
||||
const res = await fetch('/api/rooms/' + roomId, {
|
||||
method: 'PUT', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, whiteList })
|
||||
});
|
||||
if (res.ok) { closeManageModal(); loadRooms(); }
|
||||
else { const err = await res.json(); alert(err.error || '更新失败'); }
|
||||
}
|
||||
|
||||
async function deleteRoom() {
|
||||
if (!confirm('确定要删除该群聊吗?所有消息和采购数据将被永久删除。')) return;
|
||||
const roomId = document.getElementById('manage-modal').dataset.roomId;
|
||||
const token = Store.getToken();
|
||||
const res = await fetch('/api/rooms/' + roomId, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } });
|
||||
if (res.ok) {
|
||||
closeManageModal();
|
||||
loadRooms();
|
||||
if (Store.currentRoom === roomId) showList();
|
||||
} else { const err = await res.json(); alert(err.error || '删除失败'); }
|
||||
}
|
||||
|
||||
// 滑动手势
|
||||
function initSwipeGestures() {
|
||||
const chatPage = document.getElementById('chat-page');
|
||||
const purchaseOverlay = document.getElementById('purchase-panel-overlay');
|
||||
let startX = 0, startY = 0;
|
||||
|
||||
function handleTouchStart(e) { startX = e.touches[0].clientX; startY = e.touches[0].clientY; }
|
||||
|
||||
function handleTouchEnd(e, target) {
|
||||
if (!startX) return;
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const endY = e.changedTouches[0].clientY;
|
||||
const diffX = endX - startX;
|
||||
const diffY = endY - startY;
|
||||
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
|
||||
if (target === chatPage && !purchaseOverlay.classList.contains('hidden')) {
|
||||
if (Math.abs(diffX) > 30) closePurchasePanel();
|
||||
} else if (target === chatPage) {
|
||||
if (diffX > 30) showList();
|
||||
else if (diffX < -30) openPurchasePanel();
|
||||
} else if (target === purchaseOverlay) {
|
||||
if (Math.abs(diffX) > 30) closePurchasePanel();
|
||||
}
|
||||
}
|
||||
startX = 0; startY = 0;
|
||||
}
|
||||
|
||||
chatPage.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
chatPage.addEventListener('touchend', (e) => handleTouchEnd(e, chatPage), { passive: true });
|
||||
purchaseOverlay.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
purchaseOverlay.addEventListener('touchend', (e) => handleTouchEnd(e, purchaseOverlay), { passive: true });
|
||||
}
|
||||
36
server/public/js/store.js
Normal file
36
server/public/js/store.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// 全局状态管理
|
||||
const Store = {
|
||||
ws: null,
|
||||
currentRoom: null,
|
||||
rooms: [],
|
||||
pendingUploads: [],
|
||||
messageCache: [],
|
||||
tempBubbleTimer: null,
|
||||
wsReconnectTimer: null,
|
||||
reconnectAttempts: 0,
|
||||
pollTimer: null,
|
||||
|
||||
MAX_RECONNECT_DELAY: 30000,
|
||||
|
||||
getToken() { return localStorage.getItem('token'); },
|
||||
saveAuth(data) {
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('currentUser', data.username);
|
||||
localStorage.setItem('isAdmin', data.isAdmin);
|
||||
},
|
||||
clearAuth() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('currentUser');
|
||||
localStorage.removeItem('isAdmin');
|
||||
},
|
||||
logout() {
|
||||
if (Store.ws) { Store.ws.close(); Store.ws = null; }
|
||||
Store.clearAuth();
|
||||
document.getElementById('login-page').classList.remove('hidden');
|
||||
document.getElementById('main-page').classList.add('hidden');
|
||||
Store.currentRoom = null;
|
||||
Store.rooms = [];
|
||||
document.getElementById('chat-list-container').innerHTML = '';
|
||||
document.getElementById('messages-container').innerHTML = '';
|
||||
},
|
||||
};
|
||||
180
server/public/js/ws.js
Normal file
180
server/public/js/ws.js
Normal file
@@ -0,0 +1,180 @@
|
||||
// WebSocket 连接管理
|
||||
function initWebSocket() {
|
||||
const token = Store.getToken();
|
||||
if (!token) return;
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
Store.ws = new WebSocket(`${protocol}//${location.host}?token=${token}`);
|
||||
Store.ws.onopen = () => {
|
||||
console.log('WebSocket 已连接');
|
||||
Store.reconnectAttempts = 0;
|
||||
if (Store.currentRoom) Store.ws.send(JSON.stringify({ type: 'join', roomId: Store.currentRoom }));
|
||||
};
|
||||
Store.ws.onmessage = (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
console.log('📩 WS 收到:', data.type);
|
||||
if (data.type === 'new_message') {
|
||||
const msg = data.message;
|
||||
if (Store.currentRoom === msg.room_id) appendMessage(msg);
|
||||
if (data.room_preview) {
|
||||
const room = Store.rooms.find(r => r.id === data.room_preview.room_id);
|
||||
if (room) { room.last_message = data.room_preview.last_message; room.last_time = data.room_preview.last_time; renderChatList(); }
|
||||
}
|
||||
} else if (data.type === 'purchase_updated') {
|
||||
if (Store.currentRoom) loadPurchases();
|
||||
updateSummaryPreview();
|
||||
} else if (data.type === 'room_created') {
|
||||
loadRooms();
|
||||
} else if (data.type === 'room_updated') {
|
||||
const room = Store.rooms.find(r => r.id === data.room.id);
|
||||
if (room) { room.name = data.room.name; room.white_list = data.room.white_list; renderChatList(); }
|
||||
} else if (data.type === 'room_deleted') {
|
||||
Store.rooms = Store.rooms.filter(r => r.id !== data.roomId);
|
||||
renderChatList();
|
||||
if (Store.currentRoom === data.roomId) showList();
|
||||
}
|
||||
};
|
||||
Store.ws.onerror = (e) => console.error('WebSocket 错误', e);
|
||||
Store.ws.onclose = (event) => {
|
||||
console.log('WebSocket 关闭,代码:', event.code);
|
||||
if (event.code === 1000) return;
|
||||
scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (Store.wsReconnectTimer) clearTimeout(Store.wsReconnectTimer);
|
||||
const delay = Math.min(1000 * Math.pow(2, Store.reconnectAttempts), Store.MAX_RECONNECT_DELAY);
|
||||
Store.reconnectAttempts++;
|
||||
Store.wsReconnectTimer = setTimeout(() => { console.log('尝试重连...'); initWebSocket(); }, delay);
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
if (!Store.ws || Store.ws.readyState !== WebSocket.OPEN) {
|
||||
if (Store.wsReconnectTimer) clearTimeout(Store.wsReconnectTimer);
|
||||
initWebSocket();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 聊天列表
|
||||
async function loadRooms() {
|
||||
const token = Store.getToken();
|
||||
const res = await fetch('/api/rooms', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
if (res.status === 401) { Store.clearAuth(); location.reload(); return; }
|
||||
Store.rooms = await res.json();
|
||||
renderChatList();
|
||||
}
|
||||
|
||||
function renderChatList() {
|
||||
const container = document.getElementById('chat-list-container');
|
||||
const isAdmin = localStorage.getItem('isAdmin') === 'true';
|
||||
container.innerHTML = Store.rooms.map(room => {
|
||||
const lastMsg = room.last_message || '暂无消息';
|
||||
const lastTime = room.last_time || '';
|
||||
return `
|
||||
<div class="chat-item" data-room-id="${room.id}">
|
||||
<div class="avatar" onclick="openChat('${room.id}')">${escapeHtml(room.name.charAt(0))}</div>
|
||||
<div class="chat-info" onclick="openChat('${room.id}')">
|
||||
<div class="chat-name">${escapeHtml(room.name)}</div>
|
||||
<div class="last-msg">${escapeHtml(lastMsg)}</div>
|
||||
</div>
|
||||
<div class="chat-right">
|
||||
<div class="chat-time" onclick="openChat('${room.id}')">${lastTime}</div>
|
||||
${isAdmin ? `<div class="room-actions">
|
||||
<button class="edit-room-btn" onclick="event.stopPropagation(); openManageRoom('${room.id}')" title="编辑群聊">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
|
||||
</button>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function openChat(roomId, autoOpenPurchase = false) {
|
||||
Store.currentRoom = roomId;
|
||||
clearTempBubble();
|
||||
document.getElementById('list-page').classList.add('hidden');
|
||||
document.getElementById('chat-page').classList.remove('hidden');
|
||||
const room = Store.rooms.find(r => r.id === roomId);
|
||||
document.getElementById('current-chat-name').textContent = room?.name || '';
|
||||
document.getElementById('purchase-panel-overlay').classList.add('hidden');
|
||||
if (autoOpenPurchase) setTimeout(() => openPurchasePanel(), 200);
|
||||
const token = Store.getToken();
|
||||
const res = await fetch('/api/rooms/' + roomId + '/messages', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const msgs = await res.json();
|
||||
renderAllMessages(msgs);
|
||||
if (Store.ws && Store.ws.readyState === WebSocket.OPEN) Store.ws.send(JSON.stringify({ type: 'join', roomId }));
|
||||
}
|
||||
|
||||
function showList() {
|
||||
clearTempBubble();
|
||||
document.getElementById('chat-page').classList.add('hidden');
|
||||
document.getElementById('list-page').classList.remove('hidden');
|
||||
Store.currentRoom = null;
|
||||
loadRooms();
|
||||
updateSummaryPreview();
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const input = document.getElementById('msg-input');
|
||||
const text = input.value.trim();
|
||||
if (!text && Store.pendingUploads.length === 0) return;
|
||||
if (!Store.currentRoom) return;
|
||||
const token = Store.getToken();
|
||||
try {
|
||||
const res = await fetch('/api/rooms/' + Store.currentRoom + '/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: text || '', attachments: Store.pendingUploads })
|
||||
});
|
||||
const newMsg = await res.json();
|
||||
appendMessage(newMsg);
|
||||
input.value = '';
|
||||
Store.pendingUploads = [];
|
||||
insertTempBubble();
|
||||
} catch(e) { alert('发送失败,请重试'); }
|
||||
}
|
||||
|
||||
async function handleFileSelect(event) {
|
||||
const files = event.target.files;
|
||||
if (!files.length) return;
|
||||
const token = Store.getToken();
|
||||
const placeholderId = 'placeholder_' + Date.now();
|
||||
const placeholderMsg = {
|
||||
id: placeholderId, room_id: Store.currentRoom, user: localStorage.getItem('currentUser'),
|
||||
text: '图片发送中...', attachments: [], timestamp: new Date().toLocaleTimeString('zh-CN', { hour12: false }), isPlaceholder: true
|
||||
};
|
||||
appendMessage(placeholderMsg);
|
||||
try {
|
||||
for (let file of files) {
|
||||
let blob = file;
|
||||
if (file.size > 1 * 1024 * 1024) blob = await compressImage(file);
|
||||
const formData = new FormData(); formData.append('file', blob, file.name || 'image.jpg');
|
||||
const res = await fetch('/api/upload', { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData });
|
||||
const data = await res.json();
|
||||
if (data.path) Store.pendingUploads.push(data.path);
|
||||
}
|
||||
Store.messageCache = Store.messageCache.filter(m => m.id !== placeholderId);
|
||||
const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`);
|
||||
if (placeholderEl) placeholderEl.remove();
|
||||
sendMessage();
|
||||
} catch(e) {
|
||||
Store.messageCache = Store.messageCache.filter(m => m.id !== placeholderId);
|
||||
const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`);
|
||||
if (placeholderEl) placeholderEl.remove();
|
||||
alert('图片发送失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSummaryPreview() {
|
||||
const token = Store.getToken();
|
||||
try {
|
||||
const res = await fetch('/api/summary', { headers: { 'Authorization': `Bearer ${token}` } });
|
||||
const summary = await res.json();
|
||||
const totalItems = summary.reduce((s, r) => s + r.purchase_count, 0);
|
||||
const totalAmount = summary.reduce((s, r) => s + r.total_amount, 0);
|
||||
const preview = document.getElementById('summary-preview');
|
||||
if (preview) preview.innerText = `共 ${totalItems} 条,合计 ¥${totalAmount}`;
|
||||
} catch(e) {}
|
||||
}
|
||||
27
server/routes/auth.js
Normal file
27
server/routes/auth.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// 用户认证路由
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { JWT_SECRET } = require('../middleware/auth');
|
||||
const router = express.Router();
|
||||
|
||||
const USERS = {};
|
||||
(process.env.USERS || '').split(',').forEach(u => {
|
||||
const [name, pass] = u.split(':').map(s => s.trim());
|
||||
if (name && pass) USERS[name] = pass;
|
||||
});
|
||||
const ADMINS = (process.env.ADMINS || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (USERS[username] && USERS[username] === password) {
|
||||
const token = jwt.sign({ username, isAdmin: ADMINS.includes(username) }, JWT_SECRET, { expiresIn: '7d' });
|
||||
return res.json({ token, username, isAdmin: ADMINS.includes(username) });
|
||||
}
|
||||
res.status(401).json({ error: '用户名或密码错误' });
|
||||
});
|
||||
|
||||
router.get('/user', require('../middleware/auth').authMiddleware, (req, res) => {
|
||||
res.json({ username: req.user.username, isAdmin: req.user.isAdmin });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
70
server/routes/messages.js
Normal file
70
server/routes/messages.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// 消息路由 + AI 分析
|
||||
const express = require('express');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const { broadcastToRoomExcludeSelf } = require('../ws');
|
||||
const { buildSystemPrompt } = require('../ai/prompt');
|
||||
const { validate, normalize } = require('../ai/validator');
|
||||
const { callDeepSeek } = require('../ai/client');
|
||||
const { getHistory, addToHistory, hasPendingAction, handleAIResult, executePendingAction } = require('../handlers');
|
||||
const db = require('../db');
|
||||
const { timestamp } = require('../utils');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/:roomId/messages', authMiddleware, (req, res) => {
|
||||
const msgs = db.prepare('SELECT * FROM messages WHERE room_id = ? ORDER BY id ASC').all(req.params.roomId);
|
||||
res.json(msgs);
|
||||
});
|
||||
|
||||
router.post('/:roomId/messages', authMiddleware, async (req, res) => {
|
||||
const { text, attachments } = req.body;
|
||||
console.log('📨 收到消息, text:', (text||'').substring(0,30), 'attachments:', JSON.stringify(attachments));
|
||||
const roomId = req.params.roomId;
|
||||
const username = req.user.username;
|
||||
const now = timestamp();
|
||||
|
||||
const result = db.prepare('INSERT INTO messages (room_id, user, text, attachments, timestamp) VALUES (?,?,?,?,?)').run(
|
||||
roomId, username, text || '', attachments ? JSON.stringify(attachments) : null, now
|
||||
);
|
||||
const msg = { id: result.lastInsertRowid, room_id: roomId, user: username, text: text || '', attachments: attachments || [], timestamp: now };
|
||||
|
||||
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY id DESC LIMIT 1').get(roomId);
|
||||
broadcastToRoomExcludeSelf(roomId, username, {
|
||||
type: 'new_message', message: msg,
|
||||
room_preview: { room_id: roomId, last_message: lastMsg ? lastMsg.text : '', last_time: lastMsg ? lastMsg.timestamp : '' }
|
||||
});
|
||||
addToHistory(roomId, 'user', `${username}: ${text || '图片'}`);
|
||||
res.json(msg);
|
||||
|
||||
if (text && text.trim() === '确认' && hasPendingAction(roomId, username)) {
|
||||
executePendingAction(roomId, username);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const history = getHistory(roomId);
|
||||
const historyMessages = history.map(e => ({ role: e.role, content: e.content }));
|
||||
const aiResponse = await analyzeWithDeepSeek(text || '', historyMessages, username, req.user.isAdmin);
|
||||
if (aiResponse && aiResponse.action !== 'ignore') {
|
||||
handleAIResult(aiResponse, roomId, username, text || '', attachments || []);
|
||||
}
|
||||
} catch (e) { console.error('AI 分析失败:', e); }
|
||||
});
|
||||
|
||||
async function analyzeWithDeepSeek(text, historyMessages, username, isAdmin) {
|
||||
const systemPrompt = buildSystemPrompt(username, isAdmin);
|
||||
const messages = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...historyMessages.slice(-30),
|
||||
{ role: 'user', content: `${username}: ${text}` }
|
||||
];
|
||||
const content = await callDeepSeek(messages, 0.1);
|
||||
console.log('🤖 AI 返回:', content);
|
||||
const parsed = JSON.parse(content.replace(/```json|```/g, '').trim());
|
||||
normalize(parsed);
|
||||
const v = validate(parsed);
|
||||
if (!v.valid) console.warn('⚠️ AI 返回校验失败:', v.error);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
48
server/routes/purchases.js
Normal file
48
server/routes/purchases.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// 采购相关路由
|
||||
const express = require('express');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const db = require('../db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/rooms/:roomId/purchases', authMiddleware, (req, res) => {
|
||||
const purchases = db.prepare('SELECT * FROM purchases WHERE room_id = ? ORDER BY created_at DESC').all(req.params.roomId);
|
||||
res.json(purchases);
|
||||
});
|
||||
|
||||
router.get('/purchases/:id', authMiddleware, (req, res) => {
|
||||
const pur = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id);
|
||||
if (!pur) return res.status(404).json({ error: '未找到' });
|
||||
const history = db.prepare('SELECT * FROM purchase_history WHERE purchase_id = ? ORDER BY id ASC').all(req.params.id);
|
||||
const attachments = db.prepare('SELECT * FROM purchase_attachments WHERE purchase_id = ?').all(req.params.id);
|
||||
res.json({ ...pur, history, attachments });
|
||||
});
|
||||
|
||||
router.get('/summary', authMiddleware, (req, res) => {
|
||||
const username = req.user.username;
|
||||
const isAdmin = req.user.isAdmin;
|
||||
let rooms;
|
||||
if (isAdmin) {
|
||||
rooms = db.prepare('SELECT id, name FROM rooms').all();
|
||||
} else {
|
||||
rooms = db.prepare("SELECT id, name FROM rooms WHERE white_list = '' OR (',' || white_list || ',' LIKE ?)").all(`%,${username},%`);
|
||||
}
|
||||
const summary = rooms.map(room => {
|
||||
const stats = db.prepare('SELECT COUNT(*) as count, SUM(amount) as total FROM purchases WHERE room_id = ?').get(room.id);
|
||||
return { room_id: room.id, room_name: room.name, purchase_count: stats.count || 0, total_amount: stats.total || 0 };
|
||||
});
|
||||
res.json(summary);
|
||||
});
|
||||
|
||||
router.get('/rooms/:roomId/purchases/export', authMiddleware, (req, res) => {
|
||||
const purchases = db.prepare('SELECT * FROM purchases WHERE room_id = ?').all(req.params.roomId);
|
||||
let csv = '时间,事项,数量,单价,金额,邮费,付款方式,发票类型,状态,申请人,备注\n';
|
||||
purchases.forEach(p => {
|
||||
csv += `${p.created_at},"${p.item||''}",${p.quantity},${p.unit_price},${p.amount},${p.freight},"${p.payment_method||''}","${p.invoice_type||''}","${p.status}","${p.applicant}","${p.remarks||''}"\n`;
|
||||
});
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="purchases-${req.params.roomId}.csv"`);
|
||||
res.send('\uFEFF' + csv);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
57
server/routes/rooms.js
Normal file
57
server/routes/rooms.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// 群聊管理路由
|
||||
const express = require('express');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { authMiddleware, adminOnly } = require('../middleware/auth');
|
||||
const { broadcast } = require('../ws');
|
||||
const db = require('../db');
|
||||
const { timestamp } = require('../utils');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', authMiddleware, (req, res) => {
|
||||
const username = req.user.username;
|
||||
const isAdmin = req.user.isAdmin;
|
||||
let rooms;
|
||||
if (isAdmin) {
|
||||
rooms = db.prepare('SELECT * FROM rooms').all();
|
||||
} else {
|
||||
rooms = db.prepare("SELECT * FROM rooms WHERE white_list = '' OR (',' || white_list || ',' LIKE ?)").all(`%,${username},%`);
|
||||
}
|
||||
const result = rooms.map(room => {
|
||||
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY id DESC LIMIT 1').get(room.id);
|
||||
return { ...room, last_message: lastMsg ? lastMsg.text : '', last_time: lastMsg ? lastMsg.timestamp : '' };
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post('/', authMiddleware, adminOnly, (req, res) => {
|
||||
const { name, whiteList } = req.body;
|
||||
const id = uuidv4();
|
||||
const now = timestamp();
|
||||
db.prepare('INSERT INTO rooms (id, name, created_by, white_list, created_at) VALUES (?,?,?,?,?)').run(id, name, req.user.username, whiteList || '', now);
|
||||
broadcast({ type: 'room_created', room: { id, name, white_list: whiteList || '' } });
|
||||
res.json({ id, name });
|
||||
});
|
||||
|
||||
router.put('/:roomId', authMiddleware, adminOnly, (req, res) => {
|
||||
const { name, whiteList } = req.body;
|
||||
const roomId = req.params.roomId;
|
||||
const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(roomId);
|
||||
if (!room) return res.status(404).json({ error: '群聊不存在' });
|
||||
db.prepare('UPDATE rooms SET name = ?, white_list = ? WHERE id = ?').run(name, whiteList || '', roomId);
|
||||
broadcast({ type: 'room_updated', room: { id: roomId, name, white_list: whiteList || '' } });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
router.delete('/:roomId', authMiddleware, adminOnly, (req, res) => {
|
||||
const roomId = req.params.roomId;
|
||||
db.prepare('DELETE FROM messages WHERE room_id = ?').run(roomId);
|
||||
db.prepare('DELETE FROM purchase_attachments WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
|
||||
db.prepare('DELETE FROM purchase_history WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
|
||||
db.prepare('DELETE FROM purchases WHERE room_id = ?').run(roomId);
|
||||
db.prepare('DELETE FROM rooms WHERE id = ?').run(roomId);
|
||||
broadcast({ type: 'room_deleted', roomId });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
581
server/server.js
581
server/server.js
@@ -1,588 +1,61 @@
|
||||
// 入口文件 — 组装所有模块
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const db = require('./db');
|
||||
const wsModule = require('./ws');
|
||||
const { authMiddleware } = require('./middleware/auth');
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
// 环境变量
|
||||
const USERS = {};
|
||||
(process.env.USERS || '').split(',').forEach(u => {
|
||||
const [name, pass] = u.split(':');
|
||||
if (name) USERS[name] = pass;
|
||||
});
|
||||
const ADMINS = (process.env.ADMINS || '').split(',');
|
||||
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY;
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'xiaocai-secret-' + Date.now();
|
||||
// 中间件
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// 时区
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
function timestamp() {
|
||||
return new Date().toLocaleString('zh-CN', { timeZone: TIMEZONE, hour12: false });
|
||||
}
|
||||
function normalizeTime(str) {
|
||||
if (!str) return timestamp();
|
||||
// 已经是标准格式 yyyy/M/d HH:mm:ss 直接返回
|
||||
if (/\d{4}\/\d{1,2}\/\d{1,2} \d{2}:\d{2}:\d{2}/.test(str)) return str;
|
||||
// 纯日期 yyyy-MM-dd 或 yyyy/MM/dd → 补充 00:00:00
|
||||
const clean = str.replace(/-/g, '/');
|
||||
if (/^\d{4}\/\d{1,2}\/\d{1,2}$/.test(clean)) return clean + ' 00:00:00';
|
||||
// 带时间但分隔符是 - → 统一换成 /
|
||||
const withSlash = str.replace(/-/g, '/');
|
||||
if (/\d{4}\/\d{1,2}\/\d{1,2} \d{2}:\d{2}:\d{2}/.test(withSlash)) return withSlash;
|
||||
// 无法识别,返回当前时间
|
||||
return timestamp();
|
||||
}
|
||||
function currentTimeStr() { return timestamp(); }
|
||||
|
||||
// 初始化用户
|
||||
const insertUser = db.prepare('INSERT OR IGNORE INTO users (username, password, is_admin) VALUES (?, ?, ?)');
|
||||
for (const [username, password] of Object.entries(USERS)) {
|
||||
const isAdmin = ADMINS.includes(username) ? 1 : 0;
|
||||
insertUser.run(username, password, isAdmin);
|
||||
}
|
||||
|
||||
// 上传配置
|
||||
const uploadsDir = '/app/data/uploads';
|
||||
// 上传目录
|
||||
const uploadsDir = path.join(__dirname, '..', 'data', 'uploads');
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
app.use('/uploads', express.static(uploadsDir));
|
||||
|
||||
// Multer 配置
|
||||
const storage = multer.diskStorage({
|
||||
destination: uploadsDir,
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname) || '.jpg';
|
||||
const safeName = Date.now() + '-' + Math.random().toString(36).substring(2, 8) + ext;
|
||||
cb(null, safeName);
|
||||
const ext = path.extname(file.originalname);
|
||||
const safeName = uuidv4().replace(/-/g, '').substring(0, 12);
|
||||
cb(null, safeName + ext);
|
||||
}
|
||||
});
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowed = ['image/jpeg','image/png','image/gif'];
|
||||
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
cb(null, allowed.includes(file.mimetype));
|
||||
}
|
||||
});
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use('/uploads', express.static(uploadsDir));
|
||||
// 路由
|
||||
app.use('/api', require('./routes/auth'));
|
||||
app.use('/api/rooms', require('./routes/rooms'));
|
||||
app.use('/api/rooms', require('./routes/messages'));
|
||||
app.use('/api', require('./routes/purchases'));
|
||||
|
||||
// 登录
|
||||
app.post('/api/login', (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (USERS[username] && USERS[username] === password) {
|
||||
const token = jwt.sign({ username, isAdmin: ADMINS.includes(username) }, JWT_SECRET, { expiresIn: '7d' });
|
||||
return res.json({ token, username, isAdmin: ADMINS.includes(username) });
|
||||
}
|
||||
res.status(401).json({ error: '用户名或密码错误' });
|
||||
});
|
||||
|
||||
const auth = (req, res, next) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader) return res.status(401).json({ error: '未登录' });
|
||||
const token = authHeader.split(' ')[1];
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (e) {
|
||||
res.status(401).json({ error: '登录已过期' });
|
||||
}
|
||||
};
|
||||
|
||||
const adminOnly = (req, res, next) => {
|
||||
if (!req.user.isAdmin) return res.status(403).json({ error: '仅管理员可执行此操作' });
|
||||
next();
|
||||
};
|
||||
|
||||
app.get('/api/user', auth, (req, res) => {
|
||||
res.json({ username: req.user.username, isAdmin: req.user.isAdmin });
|
||||
});
|
||||
|
||||
// 群聊列表(白名单过滤)
|
||||
app.get('/api/rooms', auth, (req, res) => {
|
||||
const username = req.user.username;
|
||||
const isAdmin = req.user.isAdmin;
|
||||
let rooms;
|
||||
if (isAdmin) {
|
||||
rooms = db.prepare('SELECT * FROM rooms').all();
|
||||
} else {
|
||||
rooms = db.prepare("SELECT * FROM rooms WHERE white_list = '' OR (',' || white_list || ',' LIKE ?)").all(`%,${username},%`);
|
||||
}
|
||||
const result = rooms.map(room => {
|
||||
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY id DESC LIMIT 1').get(room.id);
|
||||
return {
|
||||
...room,
|
||||
last_message: lastMsg ? lastMsg.text : '',
|
||||
last_time: lastMsg ? lastMsg.timestamp : ''
|
||||
};
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
app.post('/api/rooms', auth, adminOnly, (req, res) => {
|
||||
const { name, whiteList } = req.body;
|
||||
const id = uuidv4();
|
||||
const now = timestamp();
|
||||
db.prepare('INSERT INTO rooms (id, name, created_by, white_list, created_at) VALUES (?,?,?,?,?)').run(id, name, req.user.username, whiteList || '', now);
|
||||
broadcast({ type: 'room_created', room: { id, name, white_list: whiteList || '' } });
|
||||
res.json({ id, name });
|
||||
});
|
||||
|
||||
app.put('/api/rooms/:roomId', auth, adminOnly, (req, res) => {
|
||||
const { name, whiteList } = req.body;
|
||||
const roomId = req.params.roomId;
|
||||
const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(roomId);
|
||||
if (!room) return res.status(404).json({ error: '群聊不存在' });
|
||||
db.prepare('UPDATE rooms SET name = ?, white_list = ? WHERE id = ?').run(name, whiteList || '', roomId);
|
||||
broadcast({ type: 'room_updated', room: { id: roomId, name, white_list: whiteList || '' } });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.delete('/api/rooms/:roomId', auth, adminOnly, (req, res) => {
|
||||
const roomId = req.params.roomId;
|
||||
db.prepare('DELETE FROM messages WHERE room_id = ?').run(roomId);
|
||||
db.prepare('DELETE FROM purchase_attachments WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
|
||||
db.prepare('DELETE FROM purchase_history WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
|
||||
db.prepare('DELETE FROM purchases WHERE room_id = ?').run(roomId);
|
||||
db.prepare('DELETE FROM rooms WHERE id = ?').run(roomId);
|
||||
broadcast({ type: 'room_deleted', roomId });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/rooms/:roomId/messages', auth, (req, res) => {
|
||||
const msgs = db.prepare('SELECT * FROM messages WHERE room_id = ? ORDER BY id ASC').all(req.params.roomId);
|
||||
res.json(msgs);
|
||||
});
|
||||
|
||||
app.post('/api/rooms/:roomId/messages', auth, async (req, res) => {
|
||||
const { text, attachments } = req.body;
|
||||
console.log('📨 收到消息, text:', (text||'').substring(0,30), 'attachments:', JSON.stringify(attachments));
|
||||
const roomId = req.params.roomId;
|
||||
const username = req.user.username;
|
||||
const now = timestamp();
|
||||
|
||||
const result = db.prepare('INSERT INTO messages (room_id, user, text, attachments, timestamp) VALUES (?,?,?,?,?)').run(
|
||||
roomId, username, text || '', attachments ? JSON.stringify(attachments) : null, now
|
||||
);
|
||||
const msg = { id: result.lastInsertRowid, room_id: roomId, user: username, text: text || '', attachments: attachments || [], timestamp: now };
|
||||
|
||||
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY id DESC LIMIT 1').get(roomId);
|
||||
broadcastToRoomExcludeSelf(roomId, username, {
|
||||
type: 'new_message', message: msg,
|
||||
room_preview: { room_id: roomId, last_message: lastMsg ? lastMsg.text : '', last_time: lastMsg ? lastMsg.timestamp : '' }
|
||||
});
|
||||
addToHistory(roomId, 'user', `${username}: ${text || '图片'}`);
|
||||
res.json(msg);
|
||||
|
||||
if (text && text.trim() === '确认' && hasPendingAction(roomId, username)) {
|
||||
executePendingAction(roomId, username);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const history = getHistory(roomId);
|
||||
const historyMessages = history.map(e => ({ role: e.role, content: e.content }));
|
||||
const aiResponse = await analyzeWithDeepSeek(text || '', historyMessages, username, req.user.isAdmin);
|
||||
if (aiResponse && aiResponse.action !== 'ignore') {
|
||||
handleAIResult(aiResponse, roomId, username, text || '', attachments || []);
|
||||
}
|
||||
} catch (e) { console.error('AI 分析失败:', e); }
|
||||
});
|
||||
|
||||
app.post('/api/upload', auth, upload.single('file'), (req, res) => {
|
||||
// 上传
|
||||
app.post('/api/upload', authMiddleware, upload.single('file'), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: '请上传图片' });
|
||||
res.json({ path: '/uploads/' + req.file.filename });
|
||||
});
|
||||
|
||||
app.get('/api/rooms/:roomId/purchases', auth, (req, res) => {
|
||||
const purchases = db.prepare('SELECT * FROM purchases WHERE room_id = ? ORDER BY created_at DESC').all(req.params.roomId);
|
||||
res.json(purchases);
|
||||
});
|
||||
|
||||
app.get('/api/purchases/:id', auth, (req, res) => {
|
||||
const pur = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id);
|
||||
if (!pur) return res.status(404).json({ error: '未找到' });
|
||||
const history = db.prepare('SELECT * FROM purchase_history WHERE purchase_id = ? ORDER BY id ASC').all(req.params.id);
|
||||
const attachments = db.prepare('SELECT * FROM purchase_attachments WHERE purchase_id = ?').all(req.params.id);
|
||||
res.json({ ...pur, history, attachments });
|
||||
});
|
||||
|
||||
app.get('/api/summary', auth, (req, res) => {
|
||||
const username = req.user.username;
|
||||
const isAdmin = req.user.isAdmin;
|
||||
let rooms;
|
||||
if (isAdmin) {
|
||||
rooms = db.prepare('SELECT id, name FROM rooms').all();
|
||||
} else {
|
||||
// 使用与群聊列表完全相同的白名单过滤方式
|
||||
rooms = db.prepare("SELECT id, name FROM rooms WHERE white_list = '' OR (',' || white_list || ',' LIKE ?)").all(`%,${username},%`)
|
||||
}
|
||||
const summary = rooms.map(room => {
|
||||
const stats = db.prepare(`SELECT COUNT(*) as count, SUM(amount) as total FROM purchases WHERE room_id = ?`).get(room.id);
|
||||
return {
|
||||
room_id: room.id,
|
||||
room_name: room.name,
|
||||
purchase_count: stats.count || 0,
|
||||
total_amount: stats.total || 0
|
||||
};
|
||||
});
|
||||
res.json(summary);
|
||||
});
|
||||
|
||||
app.get('/api/rooms/:roomId/purchases/export', auth, (req, res) => {
|
||||
const purchases = db.prepare('SELECT * FROM purchases WHERE room_id = ?').all(req.params.roomId);
|
||||
let csv = '时间,事项,数量,单价,金额,邮费,付款方式,发票类型,状态,申请人,备注\n';
|
||||
purchases.forEach(p => {
|
||||
csv += `${p.created_at},"${p.item||''}",${p.quantity},${p.unit_price},${p.amount},${p.freight},"${p.payment_method||''}","${p.invoice_type||''}","${p.status}","${p.applicant}","${p.remarks||''}"\n`;
|
||||
});
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="purchases-${req.params.roomId}.csv"`);
|
||||
res.send('\uFEFF' + csv);
|
||||
});
|
||||
|
||||
// WebSocket
|
||||
const clients = new Map();
|
||||
wss.on('connection', (ws, req) => {
|
||||
//console.log('✅ WebSocket 客户端已连接');
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token) return ws.close();
|
||||
let username;
|
||||
try { const decoded = jwt.verify(token, JWT_SECRET); username = decoded.username; }
|
||||
catch (e) { return ws.close(); }
|
||||
ws.username = username;
|
||||
ws.isAdmin = ADMINS.includes(username);
|
||||
clients.set(ws, { username, roomId: null, isAdmin: ws.isAdmin });
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === 'join') { ws.roomId = msg.roomId; clients.set(ws, { username, roomId: msg.roomId, isAdmin: ws.isAdmin }); }
|
||||
} catch (e) {}
|
||||
});
|
||||
ws.on('close', () => clients.delete(ws));
|
||||
});
|
||||
wsModule.init(wss);
|
||||
|
||||
function broadcastToRoomExcludeSelf(roomId, excludeUsername, data) {
|
||||
const message = JSON.stringify(data);
|
||||
clients.forEach((info, ws) => {
|
||||
if (info.roomId === roomId && info.username !== excludeUsername && ws.readyState === 1) ws.send(message);
|
||||
});
|
||||
}
|
||||
function broadcastToRoom(roomId, data) {
|
||||
const message = JSON.stringify(data);
|
||||
clients.forEach((info, ws) => { if (info.roomId === roomId && ws.readyState === 1) ws.send(message); });
|
||||
}
|
||||
function broadcast(data) {
|
||||
const message = JSON.stringify(data);
|
||||
clients.forEach((ws) => { if (ws.readyState === 1) ws.send(message); });
|
||||
}
|
||||
function storeAndBroadcastText(roomId, user, text) {
|
||||
const now = timestamp();
|
||||
const result = db.prepare('INSERT INTO messages (room_id, user, text, timestamp) VALUES (?,?,?,?)').run(roomId, user, text, now);
|
||||
const msg = { id: result.lastInsertRowid, room_id: roomId, user, text, attachments: [], timestamp: now };
|
||||
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY id DESC LIMIT 1').get(roomId);
|
||||
broadcastToRoom(roomId, { type: 'new_message', message: msg, room_preview: { room_id: roomId, last_message: lastMsg ? lastMsg.text : '', last_time: lastMsg ? lastMsg.timestamp : '' } });
|
||||
return msg;
|
||||
}
|
||||
|
||||
// 待处理操作
|
||||
const pendingActions = new Map();
|
||||
function hasPendingAction(roomId, username) { return pendingActions.has(`${roomId}:${username}`); }
|
||||
function setPendingAction(roomId, username, action) { pendingActions.set(`${roomId}:${username}`, action); }
|
||||
function clearPendingAction(roomId, username) { pendingActions.delete(`${roomId}:${username}`); }
|
||||
function executePendingAction(roomId, username) {
|
||||
const action = pendingActions.get(`${roomId}:${username}`);
|
||||
if (!action) return false;
|
||||
clearPendingAction(roomId, username);
|
||||
const now = timestamp();
|
||||
try {
|
||||
if (action.type === 'delete') {
|
||||
const { itemName, purchaseIds } = action.data;
|
||||
const delA = db.prepare('DELETE FROM purchase_attachments WHERE purchase_id = ?');
|
||||
const delH = db.prepare('DELETE FROM purchase_history WHERE purchase_id = ?');
|
||||
const delP = db.prepare('DELETE FROM purchases WHERE id = ?');
|
||||
for (const id of purchaseIds) { delA.run(id); delH.run(id); delP.run(id); }
|
||||
const reply = `✅ 已删除采购记录:${itemName}(共 ${purchaseIds.length} 条)`;
|
||||
storeAndBroadcastText(roomId, '小财', reply); addToHistory(roomId, 'assistant', reply);
|
||||
} else if (action.type === 'clear') {
|
||||
db.prepare('DELETE FROM purchase_attachments WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
|
||||
db.prepare('DELETE FROM purchase_history WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
|
||||
db.prepare('DELETE FROM purchases WHERE room_id = ?').run(roomId);
|
||||
const reply = '✅ 已清空当前房间的所有采购数据。';
|
||||
storeAndBroadcastText(roomId, '小财', reply); addToHistory(roomId, 'assistant', reply);
|
||||
}
|
||||
broadcastToRoom(roomId, { type: 'purchase_updated' });
|
||||
return true;
|
||||
} catch (e) { storeAndBroadcastText(roomId, '小财', '❌ 操作执行失败,请重试。'); return false; }
|
||||
}
|
||||
|
||||
// 对话历史
|
||||
const conversationHistory = new Map();
|
||||
function getHistory(roomId) { if (!conversationHistory.has(roomId)) conversationHistory.set(roomId, []); return conversationHistory.get(roomId); }
|
||||
function addToHistory(roomId, role, content) { const history = getHistory(roomId); history.push({ role, content }); if (history.length > 60) conversationHistory.set(roomId, history.slice(-40)); }
|
||||
|
||||
// AI 分析(明确权限不足时返回 chat 而非 ignore)
|
||||
async function analyzeWithDeepSeek(text, historyMessages, username, isAdmin) {
|
||||
const systemPrompt = `你是智能财务助手“小财”。当前服务器时间:${currentTimeStr()}。结合对话历史理解用户意图,只返回JSON。
|
||||
|
||||
意图分类:
|
||||
- 采购/付款/发票相关:action="purchase",提取字段:purchase_item, quantity(默认1), unit_price(默认0), amount(默认0), freight(默认0), payment_method(淘宝默认支付宝), invoice_type(默认无票), status, applicant, remarks, created_time
|
||||
- 查询汇总:action="query"
|
||||
- 聊天:action="chat",reply简短回复
|
||||
- 删除指定物品:action="delete",delete_item为物品名
|
||||
- 清空所有采购数据:action="clear_all"
|
||||
- 忽略(仅当完全无关):action="ignore"
|
||||
|
||||
⚠️ 重要权限规则:
|
||||
- ⚠️ 当前用户${username},管理员状态:${isAdmin}。仅管理员可执行删除/清空操作。
|
||||
- 如果用户要求删除或清空,且 isAdmin 为 false,你必须返回 action="chat" 并说明“仅管理员可操作”。
|
||||
- 如果 isAdmin 为 true,正常返回 delete 或 clear_all。
|
||||
- 如果用户要求“删除金额为0的记录”或“删除没有名字的记录”等无法用具体物品名描述的删除请求,你可以使用以下特殊 delete_item 值:
|
||||
- 删除所有金额为0的记录:delete_item="__AMOUNT_ZERO__"
|
||||
- 删除物品名为空、null、undefined 的记录:delete_item="__NULL_NAME__"
|
||||
- 不要再返回 action="ask" 来处理删除请求,只要确定是删除意图,就必须返回 action="delete" 并填好 delete_item。
|
||||
- 若用户描述的是特定记录(如"名字叫XXX的"),则 delete_item 填那个物品名。
|
||||
|
||||
⚠️ 删除规则补充(重要):
|
||||
- 如果用户说"删除数量X的那条/那个记录",你必须同时返回 quantity 字段,值为 X。
|
||||
- 如果用户提到"金额为Y的/价格Y的"来区分记录,你必须同时返回 amount 字段,值为 Y。
|
||||
- 提供这些字段后,系统只会精确删除匹配的那一条,避免误删同名记录。
|
||||
|
||||
⚠️ 附件补充规则:
|
||||
- 如果用户发送图片并明确说"这是XX的图片"或"作为附件存到XX采购里",
|
||||
你只需返回 purchase_item 为 XX,**绝对不要**返回 quantity、unit_price、amount、freight、payment_method 等任何字段。
|
||||
- 当消息中只有图片且文本明确指出"这是XX的图片"时,提取 purchase_item 为 XX,其余字段全部省略。
|
||||
- 如果用户要求"把刚才/之前的图片作为附件存到XX采购",或说"上面/前面/刚刚那张是XX的图片""那张图片存到XX"等引用历史上传图片的表述,你必须返回 {"action":"purchase","purchase_item":"XX","use_recent_image":true},
|
||||
不提供任何数量、价格字段,系统会自动查找最近一张图片并关联。
|
||||
|
||||
⚠️ 绝对禁止规则:
|
||||
- 如果用户发送图片并明确说"这是XX的图片"或"作为附件存到XX",你**只能返回** purchase_item,
|
||||
**绝对不允许返回** is_new、quantity、unit_price、amount 等任何其他字段。
|
||||
- 纯图片+指定物品名的请求不是新建采购,系统会自动关联附件到已有记录。
|
||||
|
||||
⚠️ 采购规则:
|
||||
- 只有用户明确表示"购买/采购/下单/付款/买了/花了"等消费意图时才返回 action="purchase"。
|
||||
- 如果用户只是陈述事实或描述物品(如"前天deep"),没有明确采购意图,必须返回 action="ignore"。
|
||||
- 如果信息不完整、无法确定具体物品名称,必须返回 action="ask" 追问,绝不允许猜测创建记录。
|
||||
- 新建 vs 更新(极其重要):
|
||||
* 如果用户提到"昨天/今天/刚刚/又/再/新/另一批/另外"等时间或重复购买词汇,必须返回 "is_new": true,系统会强制创建新记录。
|
||||
* "纯图片+物品名"的附件请求**不是**新建,不要返回 is_new。
|
||||
* 如果用户明确说"改一下/更新/修改/调整/更正"等词汇,才不提供 is_new 或设为 false,系统会尝试匹配已有记录更新。
|
||||
* 当无法确定是新建还是更新时,默认视为新建(返回 is_new: true)。
|
||||
- 用户补充信息时更新已有记录。更新时请返回完整字段值,包括未变化的字段。如果用户要求修改时间,请生成正确的created_time字段。
|
||||
- 总金额(amount)由系统自动按"数量×单价+邮费"计算,你无需提供该字段。
|
||||
- 如果无法确定 purchase_item(物品名称),必须 action="ask" 并追问"请提供物品名称",绝不允许将 purchase_item 设为空字符串、null 或 undefined。`;
|
||||
|
||||
const messages = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...historyMessages.slice(-30),
|
||||
{ role: 'user', content: `${username}: ${text}` }
|
||||
];
|
||||
|
||||
console.log('🤖 调用 DeepSeek...');
|
||||
const res = await fetch('https://api.deepseek.com/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${DEEPSEEK_API_KEY}` },
|
||||
body: JSON.stringify({ model: 'deepseek-v4-flash', messages, temperature: 0.1, stream: false })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.choices || !data.choices[0]) {
|
||||
console.error('🤖 DeepSeek 返回异常:', JSON.stringify(data));
|
||||
throw new Error('DeepSeek API 返回异常: ' + (data.error?.message || JSON.stringify(data)));
|
||||
}
|
||||
const content = data.choices[0].message.content;
|
||||
console.log('🤖 AI 返回:', content);
|
||||
return JSON.parse(content.replace(/```json|```/g, '').trim());
|
||||
}
|
||||
|
||||
function handleAIResult(aiResult, roomId, username, originalText, attachments) {
|
||||
const now = timestamp();
|
||||
if (aiResult.action === 'ask') { const q = aiResult.question || aiResult.reply || '请提供更多信息。'; storeAndBroadcastText(roomId, '小财', q); addToHistory(roomId, 'assistant', q); return; }
|
||||
if (aiResult.action === 'delete') {
|
||||
const itemName = aiResult.delete_item;
|
||||
let purchases;
|
||||
if (itemName === '__AMOUNT_ZERO__') {
|
||||
purchases = db.prepare('SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND amount = 0').all(roomId);
|
||||
} else if (itemName === '__NULL_NAME__') {
|
||||
purchases = db.prepare("SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND (item IS NULL OR item = '' OR item = 'null' OR item = 'undefined')").all(roomId);
|
||||
} else {
|
||||
// 动态构建查询:根据 AI 提供的 quantity/amount 精确匹配
|
||||
let query = 'SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND item LIKE ?';
|
||||
const params = [roomId, `%${itemName}%`];
|
||||
if (aiResult.quantity !== undefined) { query += ' AND quantity = ?'; params.push(aiResult.quantity); }
|
||||
if (aiResult.amount !== undefined) { query += ' AND amount = ?'; params.push(aiResult.amount); }
|
||||
console.log('🔍 删除查询:', query, params);
|
||||
purchases = db.prepare(query).all(...params);
|
||||
}
|
||||
if (purchases.length === 0) {
|
||||
const label = itemName === '__AMOUNT_ZERO__' ? '金额为0' : (itemName === '__NULL_NAME__' ? '名称为空' : `"${itemName}"`);
|
||||
storeAndBroadcastText(roomId, '小财', `没有找到与"${label}"相关的采购记录。`);
|
||||
return;
|
||||
}
|
||||
let confirmText = `⚠️ 即将删除以下 ${purchases.length} 条采购记录,请回复“确认”继续:\n\n`;
|
||||
purchases.forEach(p => confirmText += `• ${p.item} | ¥${p.amount} | ${p.status} | ${p.applicant} | ${p.created_at}\n`);
|
||||
confirmText += `\n如果不删除,请忽略此消息。`;
|
||||
storeAndBroadcastText(roomId, '小财', confirmText);
|
||||
addToHistory(roomId, 'assistant', confirmText);
|
||||
setPendingAction(roomId, username, { type: 'delete', data: { itemName, purchaseIds: purchases.map(p => p.id) } });
|
||||
return;
|
||||
}
|
||||
if (aiResult.action === 'clear_all') {
|
||||
const count = db.prepare('SELECT COUNT(*) as count FROM purchases WHERE room_id = ?').get(roomId).count;
|
||||
if (count === 0) { storeAndBroadcastText(roomId, '小财', '当前房间没有采购记录,无需清空。'); return; }
|
||||
storeAndBroadcastText(roomId, '小财', `⚠️ 即将清空当前房间的 ${count} 条采购数据,请回复“确认”继续,否则忽略。`);
|
||||
addToHistory(roomId, 'assistant', `请求清空${count}条记录`);
|
||||
setPendingAction(roomId, username, { type: 'clear', data: {} });
|
||||
return;
|
||||
}
|
||||
if (aiResult.action === 'purchase') {
|
||||
const item = aiResult.purchase_item;
|
||||
// 兜底拦截:物品名为空/null/undefined/过短(<2字符) → 拒绝创建
|
||||
if (!item || item === 'null' || item === 'undefined' || item.trim().length < 2) {
|
||||
console.log('🚫 拒绝无效 purchase_item:', JSON.stringify(item));
|
||||
storeAndBroadcastText(roomId, '小财', '请提供具体的物品名称。');
|
||||
addToHistory(roomId, 'assistant', '请提供具体的物品名称。');
|
||||
return;
|
||||
}
|
||||
console.log('🛒 purchase 分支, item:', item, 'attachments:', JSON.stringify(attachments));
|
||||
// 引用最近图片:用户要求把之前的图片关联到某采购
|
||||
let usedRecentImage = false;
|
||||
if (aiResult.use_recent_image) {
|
||||
console.log('🔍 use_recent_image: 查找最近图片消息, roomId=', roomId);
|
||||
const recentMsg = db.prepare("SELECT attachments FROM messages WHERE room_id = ? AND attachments IS NOT NULL AND attachments != '' AND attachments != '[]' ORDER BY id DESC LIMIT 1").get(roomId);
|
||||
console.log('🔍 最近消息:', recentMsg ? recentMsg.attachments : '(无)');
|
||||
if (recentMsg) {
|
||||
try {
|
||||
const files = JSON.parse(recentMsg.attachments);
|
||||
if (files.length) { attachments = files; usedRecentImage = true; console.log('✅ 引用图片:', files.length, '个'); }
|
||||
} catch(e) { console.error('❌ 解析附件失败:', e); }
|
||||
}
|
||||
}
|
||||
const createdTime = normalizeTime(aiResult.created_time);
|
||||
// 新建 vs 更新:AI 标记 is_new 时强制新建,不尝试匹配已有记录
|
||||
let isNew = aiResult.is_new === true;
|
||||
let purchase;
|
||||
// 兜底纠正:如果 AI 误将"纯附件请求"标记为 is_new,强制转为匹配已有记录
|
||||
if (isNew && attachments?.length > 0) {
|
||||
const existing = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ? ORDER BY created_at DESC LIMIT 1').get(roomId, `%${aiResult.purchase_item}%`);
|
||||
if (existing) {
|
||||
console.log('🛡️ 兜底纠正: is_new 但附件非空且已有同名记录,转为更新模式');
|
||||
isNew = false;
|
||||
purchase = existing;
|
||||
}
|
||||
}
|
||||
if (!isNew && !purchase) {
|
||||
purchase = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ? AND status != ?').get(roomId, `%${aiResult.purchase_item}%`, '已完成');
|
||||
}
|
||||
console.log('📦 purchase_item:', aiResult.purchase_item, isNew ? '(强制新建)' : purchase ? '(匹配已有)' : '(未匹配-新建)');
|
||||
let replyText = '';
|
||||
const quantity = aiResult.quantity || 1;
|
||||
const unitPrice = aiResult.unit_price || 0;
|
||||
const freight = aiResult.freight || 0;
|
||||
const amount = quantity * unitPrice + freight;
|
||||
|
||||
if (!purchase) {
|
||||
const id = uuidv4();
|
||||
const item = aiResult.purchase_item;
|
||||
const paymentMethod = aiResult.payment_method || (aiResult.method === '淘宝' ? '支付宝' : (aiResult.method || '未指定'));
|
||||
const invoiceType = aiResult.invoice_type || '无票';
|
||||
const status = aiResult.status || '待处理';
|
||||
const applicant = aiResult.applicant || username;
|
||||
const remarks = aiResult.remarks || '';
|
||||
db.prepare(`INSERT INTO purchases (id, room_id, item, quantity, unit_price, amount, freight, payment_method, invoice_type, status, applicant, remarks, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(
|
||||
id, roomId, item, quantity, unitPrice, amount, freight, paymentMethod, invoiceType, status, applicant, remarks, createdTime
|
||||
);
|
||||
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(id, `创建采购:${item},数量${quantity},金额¥${amount},状态${status}`, '小财', now);
|
||||
replyText = `✅ 已记录采购:${item},数量 ${quantity},金额 ¥${amount},状态 ${status}`;
|
||||
purchase = { id };
|
||||
} else {
|
||||
const old = purchase;
|
||||
const newVals = {
|
||||
quantity: aiResult.quantity ?? old.quantity,
|
||||
unit_price: aiResult.unit_price ?? old.unit_price,
|
||||
freight: aiResult.freight ?? old.freight,
|
||||
payment_method: aiResult.payment_method || (aiResult.method === '淘宝' ? '支付宝' : (aiResult.method || old.payment_method)),
|
||||
invoice_type: aiResult.invoice_type || old.invoice_type,
|
||||
status: aiResult.status || old.status,
|
||||
applicant: aiResult.applicant || old.applicant,
|
||||
remarks: aiResult.remarks !== undefined ? aiResult.remarks : old.remarks,
|
||||
created_at: aiResult.created_time || old.created_at
|
||||
};
|
||||
if (aiResult.amount !== undefined && aiResult.amount !== null) {
|
||||
// 始终根据数量、单价、邮费重新计算总金额,确保准确
|
||||
// newVals.amount = aiResult.amount;
|
||||
newVals.amount = newVals.quantity * newVals.unit_price + newVals.freight;
|
||||
} else {
|
||||
newVals.amount = newVals.unit_price * newVals.quantity + newVals.freight;
|
||||
}
|
||||
|
||||
const historyChanges = [];
|
||||
let changed = false;
|
||||
for (const [field, newVal] of Object.entries(newVals)) {
|
||||
if (String(newVal) !== String(old[field])) {
|
||||
changed = true;
|
||||
historyChanges.push(`${field}: ${old[field]} → ${newVal}`);
|
||||
db.prepare(`UPDATE purchases SET ${field} = ? WHERE id = ?`).run(newVal, purchase.id);
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
db.prepare('UPDATE purchases SET updated_at = ? WHERE id = ?').run(now, purchase.id);
|
||||
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, `更新:${historyChanges.join(';')}`, username, now);
|
||||
replyText = `🔄 已更新「${aiResult.purchase_item}」:${historyChanges.join(',')}`;
|
||||
} else {
|
||||
replyText = `ℹ️ 采购「${aiResult.purchase_item}」的信息没有发生变化。`;
|
||||
}
|
||||
}
|
||||
if (attachments?.length) {
|
||||
console.log('📎 插入附件:', attachments.length, '个, purchase_id=', purchase.id);
|
||||
const insertAttach = db.prepare('INSERT INTO purchase_attachments (purchase_id, file_path, uploaded_by, timestamp) VALUES (?,?,?,?)');
|
||||
attachments.forEach(fp => { console.log(' 📎', fp); insertAttach.run(purchase.id, fp, username, now); });
|
||||
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, `添加附件:${attachments.length} 个`, username, now);
|
||||
// 如果字段没有变化,覆盖回复消息
|
||||
if (replyText.includes('没有发生') || usedRecentImage) {
|
||||
replyText = `📎 已为「${aiResult.purchase_item}」添加 ${attachments.length} 个附件`;
|
||||
}
|
||||
console.log('✅ 附件完成, replyText:', replyText);
|
||||
} else if (usedRecentImage) {
|
||||
replyText = `❌ 未找到最近的图片消息,请先发送图片再试。`;
|
||||
console.log('⚠️ usedRecentImage 但附件为空');
|
||||
}
|
||||
storeAndBroadcastText(roomId, '小财', replyText);
|
||||
addToHistory(roomId, 'assistant', replyText);
|
||||
broadcastToRoom(roomId, { type: 'purchase_updated' });
|
||||
return;
|
||||
}
|
||||
if (aiResult.action === 'query') {
|
||||
const purchaseData = db.prepare('SELECT item, amount, payment_method, status FROM purchases WHERE room_id = ? ORDER BY created_at DESC').all(roomId).map(p => `${p.item} ¥${p.amount} ${p.payment_method||''} ${p.status}`).join('\n');
|
||||
const summaryPrompt = `根据以下采购记录,用自然语言回答用户查询“${originalText}”。采购记录:\n${purchaseData || '暂无记录'}`;
|
||||
callDeepSeekForSummary(summaryPrompt).then(reply => { storeAndBroadcastText(roomId, '小财', reply); addToHistory(roomId, 'assistant', reply); });
|
||||
return;
|
||||
}
|
||||
if (aiResult.action === 'chat') { storeAndBroadcastText(roomId, '小财', aiResult.reply || '好的。'); addToHistory(roomId, 'assistant', aiResult.reply); return; }
|
||||
}
|
||||
|
||||
async function callDeepSeekForSummary(prompt) {
|
||||
const res = await fetch('https://api.deepseek.com/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${DEEPSEEK_API_KEY}` },
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [
|
||||
{ role: 'system', content: '你是一个财务助手,请根据采购记录生成简洁回复。' },
|
||||
{ role: 'user', content: prompt }
|
||||
],
|
||||
temperature: 0.3, stream: false
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.choices[0].message.content;
|
||||
}
|
||||
|
||||
server.listen(process.env.PORT || 3000, () => console.log(`Server running on port ${process.env.PORT || 3000}`));
|
||||
// 启动
|
||||
const PORT = process.env.PORT || 3000;
|
||||
server.listen(PORT, () => console.log(`Server running on port ${PORT}`));
|
||||
|
||||
18
server/utils.js
Normal file
18
server/utils.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// 公共工具函数
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
|
||||
function timestamp() {
|
||||
return new Date().toLocaleString('zh-CN', { timeZone: TIMEZONE, hour12: false });
|
||||
}
|
||||
|
||||
function normalizeTime(str) {
|
||||
if (!str) return timestamp();
|
||||
if (/\d{4}\/\d{1,2}\/\d{1,2} \d{2}:\d{2}:\d{2}/.test(str)) return str;
|
||||
const clean = str.replace(/-/g, '/');
|
||||
if (/^\d{4}\/\d{1,2}\/\d{1,2}$/.test(clean)) return clean + ' 00:00:00';
|
||||
const withSlash = str.replace(/-/g, '/');
|
||||
if (/\d{4}\/\d{1,2}\/\d{1,2} \d{2}:\d{2}:\d{2}/.test(withSlash)) return withSlash;
|
||||
return timestamp();
|
||||
}
|
||||
|
||||
module.exports = { timestamp, normalizeTime };
|
||||
56
server/ws/index.js
Normal file
56
server/ws/index.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// WebSocket 管理模块
|
||||
const jwt = require('jsonwebtoken');
|
||||
const ADMINS = (process.env.ADMINS || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'xiaocai-secret-2024';
|
||||
|
||||
const clients = new Map();
|
||||
|
||||
function init(wss) {
|
||||
wss.on('connection', (ws, req) => {
|
||||
console.log('✅ WebSocket 客户端已连接');
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const token = url.searchParams.get('token');
|
||||
if (!token) return ws.close();
|
||||
|
||||
let username;
|
||||
try { const decoded = jwt.verify(token, JWT_SECRET); username = decoded.username; }
|
||||
catch (e) { return ws.close(); }
|
||||
|
||||
ws.username = username;
|
||||
ws.isAdmin = ADMINS.includes(username);
|
||||
clients.set(ws, { username, roomId: null, isAdmin: ws.isAdmin });
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.type === 'join') {
|
||||
ws.roomId = msg.roomId;
|
||||
clients.set(ws, { username, roomId: msg.roomId, isAdmin: ws.isAdmin });
|
||||
}
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
ws.on('close', () => clients.delete(ws));
|
||||
});
|
||||
}
|
||||
|
||||
function broadcastToRoomExcludeSelf(roomId, excludeUsername, data) {
|
||||
const message = JSON.stringify(data);
|
||||
clients.forEach((info, ws) => {
|
||||
if (info.roomId === roomId && info.username !== excludeUsername && ws.readyState === 1) ws.send(message);
|
||||
});
|
||||
}
|
||||
|
||||
function broadcastToRoom(roomId, data) {
|
||||
const message = JSON.stringify(data);
|
||||
clients.forEach((info, ws) => {
|
||||
if (info.roomId === roomId && ws.readyState === 1) ws.send(message);
|
||||
});
|
||||
}
|
||||
|
||||
function broadcast(data) {
|
||||
const message = JSON.stringify(data);
|
||||
clients.forEach((info, ws) => { if (ws.readyState === 1) ws.send(message); });
|
||||
}
|
||||
|
||||
module.exports = { init, broadcastToRoomExcludeSelf, broadcastToRoom, broadcast };
|
||||
Reference in New Issue
Block a user