Compare commits

..
24 Commits
Author SHA1 Message Date
kicer 762c7e24f6 add README.md 2026-07-28 20:20:24 +08:00
kicer b83f895fd1 支持图片缩放拖拽 2026-07-28 20:14:40 +08:00
kicer 406dafd64f 单击打开聊天记录消息 2026-07-28 20:08:38 +08:00
kicer 624968bede fix css 2026-07-28 20:00:45 +08:00
kicer 2c4dc19654 调整配色 2026-07-28 19:51:03 +08:00
kicer 81a9993fac 最近活跃的群聊排最上面,无消息的排最后 2026-07-28 19:43:13 +08:00
kicer 05266703e8 fix css 2026-07-28 19:26:26 +08:00
kicer 2e9da6c1ac 普通用户也支持新建群聊 2026-07-28 19:02:23 +08:00
kicer d4a512bc6e backup codes 2026-07-28 18:41:22 +08:00
kicer 2964917c14 backup codes 2026-07-28 18:38:11 +08:00
kicer 3f9f95b5ef fix css 2026-07-28 18:05:08 +08:00
kicer 24bfe66773 采购详情支持手动修改 2026-07-28 17:51:14 +08:00
kicer 0883566045 默认进入采购清单页 2026-07-28 17:12:30 +08:00
kicer dc93451775 管理员可以直观查看白名单 2026-07-28 16:23:20 +08:00
kicer 8b022bd949 fix issues 2026-07-22 18:07:12 +08:00
kicer b7eabb1b11 fix issues 2026-07-22 18:03:15 +08:00
kicer 17385335fd fix issues 2026-07-22 17:59:21 +08:00
kicer b7605fcf34 fix issues 2026-07-22 17:53:32 +08:00
kicer 5c97060a52 fix issues 2026-07-22 17:50:21 +08:00
kicer 84bd6efd2f fix issues 2026-07-22 17:28:55 +08:00
kicer 9183308769 fix issues 2026-07-22 17:25:07 +08:00
kicer 981f9ed4bf fix issues 2026-07-22 17:17:00 +08:00
kicer db31e7209d backup codes 2026-07-22 17:13:35 +08:00
kicer 49ae9ef251 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
2026-07-22 16:59:56 +08:00
22 changed files with 2321 additions and 1324 deletions
+145
View File
@@ -0,0 +1,145 @@
# 小财记账 (XiaoCai Accounting)
AI 驱动的内部群聊记账系统,通过自然语言对话自动记录采购信息。
## 功能特性
- **AI 自然语言记账** — 聊天中输入"打印纸一包,30元,已付"即可自动创建采购记录
- **采购清单管理** — 按月汇总,支持编辑、删除、附件管理,状态追踪(待付款→已付款→已收货→已完成)
- **多群聊支持** — 每个项目独立群聊,支持白名单权限控制
- **实时推送** — WebSocket 实时同步消息和采购变更
- **移动端适配** — 响应式设计,支持滑动手势、图片双指缩放拖拽
- **管理员功能** — 群聊管理、采购导出 CSV、全局汇总
## 技术栈
| 层 | 技术 |
|---|---|
| 后端 | Node.js + Express + WebSocket (ws) |
| 数据库 | SQLite (better-sqlite3) |
| AI | DeepSeek API |
| 前端 | 原生 HTML/CSS/JS,无框架 |
| 部署 | Docker + Docker Compose |
| 认证 | JWT |
## 项目结构
```
xiaocai-ai-server/
├── docker-compose.yml # Docker 编排
├── build.sh # 构建脚本
├── server/
│ ├── server.js # 入口
│ ├── db.js # 数据库初始化 + 迁移
│ ├── utils.js # 时间工具函数
│ ├── handlers.js # AI 结果处理 + 对话状态
│ ├── package.json
│ ├── Dockerfile
│ ├── ai/
│ │ ├── prompt.js # Prompt 模板
│ │ ├── validator.js # JSON Schema 校验
│ │ └── client.js # DeepSeek API 客户端
│ ├── middleware/
│ │ └── auth.js # JWT 认证 + 管理员中间件
│ ├── routes/
│ │ ├── auth.js # 登录 / 用户信息
│ │ ├── rooms.js # 群聊 CRUD
│ │ ├── messages.js # 消息 + AI 分析
│ │ └── purchases.js # 采购 CRUD / 汇总 / 导出
│ ├── ws/
│ │ └── index.js # WebSocket 连接管理 + 广播
│ └── public/
│ ├── index.html # 单页应用
│ ├── css/style.css # 样式
│ └── js/
│ ├── store.js # 全局状态管理
│ ├── chat.js # 消息渲染 + 图片处理
│ ├── ws.js # WebSocket + 聊天列表
│ ├── purchases.js # 采购清单 + 编辑 + 手势
│ └── auth.js # 登录 / 初始化
└── docs/
└── v2.md # 重构设计文档
```
## 快速开始
### 环境变量
创建 `.env` 文件:
```bash
# 用户账号(username:password 逗号分隔)
USERS=admin:admin123,user1:pass1
# 管理员列表(逗号分隔)
ADMINS=admin
# DeepSeek API Key
DEEPSEEK_API_KEY=sk-xxx
```
### Docker 部署
```bash
# 构建并启动
./build.sh
# 或手动
docker compose up -d
```
服务默认监听 `3000` 端口。
## API 概览
### 认证
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | `/api/login` | 登录获取 JWT |
### 群聊
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/rooms` | 群聊列表(按活跃时间排序) |
| POST | `/api/rooms` | 新建群聊 |
| PUT | `/api/rooms/:id` | 编辑群聊 |
| DELETE | `/api/rooms/:id` | 删除群聊(管理员) |
### 消息
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/rooms/:id/messages` | 获取消息历史 |
| POST | `/api/rooms/:id/messages` | 发送消息(触发 AI 分析) |
| POST | `/api/upload` | 上传图片 |
### 采购
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/rooms/:id/purchases` | 群聊采购清单 |
| GET | `/api/purchases/:id` | 采购详情(含历史 + 附件) |
| PUT | `/api/purchases/:id` | 更新采购记录 |
| DELETE | `/api/purchases/:id` | 删除采购记录 |
| POST | `/api/purchases/:id/attachments` | 添加附件 |
| DELETE | `/api/purchases/:id/attachments` | 删除附件 |
| GET | `/api/summary` | 全局采购汇总 |
| GET | `/api/rooms/:id/purchases/export` | 导出 CSV |
## 采购状态流转
```
待付款 → 已付款 → 已收货 → 已完成
```
- **聊天 AI** — 仅负责新建记录,修改操作引导用户去采购清单手动完成
- **采购清单** — 点击条目进入详情,可编辑所有字段、管理附件、查看操作历史
## 设计原则
- AI 不负责更新已有记录,所有修改由用户手动完成
- 新建记录通过聊天自然语言输入
- 移动端优先:滑动手势、数字键盘、双指缩放
- 操作历史自动记录所有变更,可追溯
## License
MIT
+197
View File
@@ -0,0 +1,197 @@
# 小财记账系统重构计划
> **文档状态**:讨论稿
> **创建日期**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 token1小时)+ 长期 refresh token7天)。
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** | 运维自动化(备份、存储迁移) | 数据安全,长期运行保障 |
# 修改记录
```
server/
├── server.js # 入口,40行(原 588行)
├── db.js # 数据库(新增 foreign_keys = ON
├── utils.js # timestamp / normalizeTime
├── handlers.js # AI 结果处理 + 共享状态
├── ai/
│ ├── prompt.js # Prompt 模板化
│ ├── validator.js # JSON Schema 校验
│ └── client.js # DeepSeek API 客户端
├── middleware/
│ └── auth.js # JWT + adminOnly
├── routes/
│ ├── auth.js # 登录 / 用户信息
│ ├── rooms.js # 群聊 CRUD
│ ├── messages.js # 消息 + AI 分析
│ └── purchases.js # 采购 CRUD / 汇总 / 导出
├── ws/
│ └── index.js # WebSocket 连接管理 + 广播
└── public/
├── index.html # 纯结构(引用 CSS/JS
├── css/
│ └── style.css # 独立样式
└── js/
├── store.js # 全局状态
├── chat.js # 消息渲染 + 图片处理
├── ws.js # WebSocket + 聊天列表 + 发送
├── purchases.js # 采购面板 + 详情 + 群聊管理 + 手势
└── auth.js # 登录 / 初始化
```
### P0 完成项
- Prompt 模板化(`ai/prompt.js`
- JSON Schema 校验(`ai/validator.js`
- AI 客户端封装(`ai/client.js`
- 金额计算唯一化(`handlers.js` 中 amount 仅由系统计算)
- 时间格式统一(`utils.js` normalizeTime
### P1 完成项
- 后端模块化拆分(routes / ai / ws / middleware
- 权限模型统一(middleware/auth.js
- 外键约束开启(`db.js` `PRAGMA foreign_keys = ON`
### P2 完成项
- CSS 独立文件(`public/css/style.css`
- JS 模块化拆分(store / chat / ws / purchases / auth
- 状态集中管理(`Store` 对象)
+19
View 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 };
+74
View File
@@ -0,0 +1,74 @@
// 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"'
].join('\n');
const permission = [
'⚠️ 权限规则:',
'- 当前用户' + username + ',管理员状态:' + isAdmin + '。仅管理员可执行删除/清空操作。',
'- 如果用户要求删除或清空,且 isAdmin 为 false,你必须返回 action="chat" 并说明"仅管理员可操作"。',
'- 如果 isAdmin 为 true,正常返回 delete 或 clear_all。'
].join('\n');
const deleteRules = [
'⚠️ 删除规则:',
'- 特殊批量删除:delete_item="__AMOUNT_ZERO__"(金额为0)、"__NULL_NAME__"(空名称)',
'- 精确删除:用户指定数量/金额时同时返回 quantity/amount 字段,只匹配一条',
'- 不要返回 action="ask" 处理删除请求'
].join('\n');
const attachmentRules = [
'⚠️ 附件规则:',
'- 图片+物品名("这是XX的图片")→ 只返回 purchase_item,绝对不要 is_new/quantity/unit_price',
'- 引用历史图片("上面/前面/刚刚那张""存入附件/添加到附件/加到XX的附件/关联到")→ use_recent_image: true**同时必须提供** purchase_item',
'- 纯图片+物品名不是新建采购,系统自动关联附件'
].join('\n');
const prohibitionRules = [
'⚠️ 绝对禁止:',
'- 图片+物品名请求 → 只能返回 purchase_item,禁止返回 is_new、quantity、unit_price、amount'
].join('\n');
const modifyGuide = [
'⚠️ 修改引导规则(极其重要!):',
'- 聊天窗口仅用于**新建**采购记录和查询汇总。',
'- 如果用户要求**修改/更正/调整/改一下/更新/纠正**已有采购记录 → 你必须返回 action="chat"reply引导用户去采购清单页面手动修改。',
' 示例回复:"请在采购清单中找到对应记录,点击进入详情页面直接修改。修改后点击保存即可。"',
'- 如果用户要求修改,你绝不要尝试去匹配或更新已有记录,只能引导用户手动操作。'
].join('\n');
const purchaseRules = [
'⚠️ 采购规则:',
'- 聊天默认为**新建**采购记录。只要用户提到购买/采购/下单/付款/买了/花了,就创建新记录。',
'- 采购状态(status)只能从以下 4 个值中选择,绝不允许自创:',
' * "待付款" — 采购已记录,等待付款',
' * "已付款" — 已完成支付,等待收货',
' * "已收货" — 已收到货物',
' * "已完成" — 发票已收,全部结束',
'- 状态映射:',
' * "付了/已付/付款了" → "已付款"',
' * "到了/收到货了" → "已收货"',
' * "发票到/全搞定/完结" → "已完成"',
' * 新建不指定 → "待付款"',
'- 只有明确消费意图(购买/采购/下单/付款/买了/花了)才返回 action="purchase"',
'- 纯陈述 → action="ignore"',
'- 信息不完整 → action="ask" 追问',
'- amount 由系统自动计算(quantity × unit_price + freight),AI 无需提供',
'- 无法确定物品名 → action="ask" 追问'
].join('\n');
return [role, intent, permission, deleteRules, attachmentRules, prohibitionRules, modifyGuide, purchaseRules].join('\n');
}
module.exports = { buildSystemPrompt };
+41
View File
@@ -0,0 +1,41 @@
// JSON Schema 校验 — 验证 AI 返回的 JSON 结构
const schemas = {
ask: { required: [], optional: ['question','reply'] },
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 };
+11 -10
View File
@@ -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,
@@ -47,7 +50,7 @@ db.exec(`
freight REAL DEFAULT 0,
payment_method TEXT DEFAULT '未指定',
invoice_type TEXT DEFAULT '无票',
status TEXT DEFAULT '待处理',
status TEXT DEFAULT '待付款',
applicant TEXT,
remarks TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now','localtime')),
@@ -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,11 @@ 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) { /* 列已存在 */ }
}
// 统一存量状态值
try { db.exec("UPDATE purchases SET status = '待付款' WHERE status = '待处理'"); } catch (e) {}
module.exports = db;
+197
View File
@@ -0,0 +1,197 @@
// 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 = '\u2705 \u5df2\u5220\u9664\u91c7\u8d2d\u8bb0\u5f55\uff1a' + itemName + '\uff08\u5171 ' + purchaseIds.length + ' \u6761\uff09';
storeAndBroadcastText(roomId, '\u5c0f\u8d22', 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 = '\u2705 \u5df2\u6e05\u7a7a\u5f53\u524d\u623f\u95f4\u7684\u6240\u6709\u91c7\u8d2d\u6570\u636e\u3002';
storeAndBroadcastText(roomId, '\u5c0f\u8d22', reply); addToHistory(roomId, 'assistant', reply);
}
broadcastToRoom(roomId, { type: 'purchase_updated' });
return true;
} catch (e) { storeAndBroadcastText(roomId, '\u5c0f\u8d22', '\u274c \u64cd\u4f5c\u6267\u884c\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5\u3002'); return false; }
}
function handleAIResult(aiResult, roomId, username, originalText, attachments) {
const now = timestamp();
if (aiResult.action === 'ask') {
const q = aiResult.question || aiResult.reply || '\u8bf7\u63d0\u4f9b\u66f4\u591a\u4fe1\u606f\u3002';
storeAndBroadcastText(roomId, '\u5c0f\u8d22', 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); }
purchases = db.prepare(query).all(...params);
}
if (purchases.length === 0) {
const label = itemName === '__AMOUNT_ZERO__' ? '\u91d1\u989d\u4e3a0' : (itemName === '__NULL_NAME__' ? '\u540d\u79f0\u4e3a\u7a7a' : '"' + itemName + '"');
storeAndBroadcastText(roomId, '\u5c0f\u8d22', '\u6ca1\u6709\u627e\u5230\u4e0e"' + label + '"\u76f8\u5173\u7684\u91c7\u8d2d\u8bb0\u5f55\u3002');
return;
}
let confirmText = '\u26a0\ufe0f \u5373\u5c06\u5220\u9664\u4ee5\u4e0b ' + purchases.length + ' \u6761\u91c7\u8d2d\u8bb0\u5f55\uff0c\u8bf7\u56de\u590d"\u786e\u8ba4"\u7ee7\u7eed\uff1a\n\n';
purchases.forEach(p => confirmText += '\u2022 ' + p.item + ' | ' + p.amount + ' | ' + p.status + ' | ' + p.applicant + ' | ' + p.created_at + '\n');
confirmText += '\n\u5982\u679c\u4e0d\u5220\u9664\uff0c\u8bf7\u5ffd\u7565\u6b64\u6d88\u606f\u3002';
storeAndBroadcastText(roomId, '\u5c0f\u8d22', 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, '\u5c0f\u8d22', '\u5f53\u524d\u623f\u95f4\u6ca1\u6709\u91c7\u8d2d\u8bb0\u5f55\uff0c\u65e0\u9700\u6e05\u7a7a\u3002'); return; }
storeAndBroadcastText(roomId, '\u5c0f\u8d22', '\u26a0\ufe0f \u5373\u5c06\u6e05\u7a7a\u5f53\u524d\u623f\u95f4\u7684 ' + count + ' \u6761\u91c7\u8d2d\u6570\u636e\uff0c\u8bf7\u56de\u590d"\u786e\u8ba4"\u7ee7\u7eed\uff0c\u5426\u5219\u5ffd\u7565\u3002');
addToHistory(roomId, 'assistant', '\u8bf7\u6c42\u6e05\u7a7a' + count + '\u6761\u8bb0\u5f55');
setPendingAction(roomId, username, { type: 'clear', data: {} });
return;
}
if (aiResult.action === 'purchase') {
// use_recent_image 但缺 purchase_item
if (aiResult.use_recent_image && (!aiResult.purchase_item || aiResult.purchase_item === 'null')) {
const lastPurchase = db.prepare("SELECT item FROM purchases WHERE room_id = ? AND item IS NOT NULL AND item != '' ORDER BY created_at DESC LIMIT 1").get(roomId);
if (lastPurchase) { aiResult.purchase_item = lastPurchase.item; }
}
const item = aiResult.purchase_item;
if (!item || item === 'null' || item === 'undefined' || item.trim().length < 2) {
storeAndBroadcastText(roomId, '\u5c0f\u8d22', '\u8bf7\u63d0\u4f9b\u5177\u4f53\u7684\u7269\u54c1\u540d\u79f0\u3002');
addToHistory(roomId, 'assistant', '\u8bf7\u63d0\u4f9b\u5177\u4f53\u7684\u7269\u54c1\u540d\u79f0\u3002');
return;
}
let usedRecentImage = false;
if (aiResult.use_recent_image) {
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; } } catch(e) {}
}
}
if (!usedRecentImage && !attachments?.length && /(\u56fe\u7247|\u9644\u4ef6|\u52a0\u5230|\u5b58\u5165|\u5173\u8054|\u4f5c\u4e3a\u9644\u4ef6)/.test(originalText)) {
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; } } catch(e) {}
}
}
const createdTime = normalizeTime(aiResult.created_time);
// AI 不负责更新 — 聊天永远新建,修改由用户从采购清单手动完成
let purchase = null;
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 && amount === 0 && aiResult.quantity === undefined && aiResult.unit_price === undefined) {
storeAndBroadcastText(roomId, '\u5c0f\u8d22', '\u65e0\u6cd5\u521b\u5efa\u300c' + aiResult.purchase_item + '\u300d\uff1a\u7f3a\u5c11\u6570\u91cf\u548c\u4ef7\u683c\u4fe1\u606f\uff0c\u8bf7\u8865\u5145\u3002');
addToHistory(roomId, 'assistant', '\u7f3a\u5c11\u6570\u91cf\u548c\u4ef7\u683c');
return;
}
// 永远新建
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 };
if (attachments?.length) {
const insertAttach = db.prepare('INSERT INTO purchase_attachments (purchase_id, file_path, uploaded_by, timestamp) VALUES (?,?,?,?)');
attachments.forEach(fp => insertAttach.run(purchase.id, fp, username, now));
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, '\u6dfb\u52a0\u9644\u4ef6\uff1a' + attachments.length + ' \u4e2a', username, now);
if (replyText.includes('\u6ca1\u6709\u53d1\u751f') || usedRecentImage) replyText = '\ud83d\udcce \u5df2\u4e3a\u300c' + aiResult.purchase_item + '\u300d\u6dfb\u52a0 ' + attachments.length + ' \u4e2a\u9644\u4ef6';
} else if (usedRecentImage) {
replyText = '\u274c \u672a\u627e\u5230\u6700\u8fd1\u7684\u56fe\u7247\u6d88\u606f\uff0c\u8bf7\u5148\u53d1\u9001\u56fe\u7247\u518d\u8bd5\u3002';
}
storeAndBroadcastText(roomId, '\u5c0f\u8d22', 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 = '\u6839\u636e\u4ee5\u4e0b\u91c7\u8d2d\u8bb0\u5f55\uff0c\u7528\u81ea\u7136\u8bed\u8a00\u56de\u7b54\u7528\u6237\u67e5\u8be2"' + originalText + '"' + '\u3002\u91c7\u8d2d\u8bb0\u5f55\uff1a\n' + (purchaseData || '\u6682\u65e0\u8bb0\u5f55');
callDeepSeekForSummary(summaryPrompt).then(reply => { storeAndBroadcastText(roomId, '\u5c0f\u8d22', reply); addToHistory(roomId, 'assistant', reply); });
return;
}
if (aiResult.action === 'chat') {
storeAndBroadcastText(roomId, '\u5c0f\u8d22', aiResult.reply || '\u597d\u7684\u3002');
addToHistory(roomId, 'assistant', aiResult.reply);
return;
}
}
async function callDeepSeekForSummary(prompt) {
const { callDeepSeek } = require('./ai/client');
const messages = [
{ role: 'system', content: '\u4f60\u662f\u4e00\u4e2a\u8d22\u52a1\u52a9\u624b\uff0c\u8bf7\u6839\u636e\u91c7\u8d2d\u8bb0\u5f55\u751f\u6210\u7b80\u6d01\u56de\u590d\u3002' },
{ role: 'user', content: prompt }
];
return callDeepSeek(messages, 0.3);
}
module.exports = {
getHistory, addToHistory,
hasPendingAction, setPendingAction, clearPendingAction,
handleAIResult, executePendingAction, storeAndBroadcastText
};
+23
View 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 };
+177
View File
@@ -0,0 +1,177 @@
* { 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; cursor: pointer; }
.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; }
.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: linear-gradient(135deg, #e8f0fe 0%, #d4e4fc 100%); margin: 8px; border-radius: 12px; padding: 14px 16px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; border: 1px solid #b8d4f8; }
.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; }
.header .back-btn { margin-right: 12px; }
.chat-header .back-btn { margin-right: 12px; }
.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-body { flex: 1; overflow-y: auto; padding: 12px; }
.purchase-month-group { margin-bottom: 20px; }
.purchase-month-group summary { list-style: none; cursor: pointer; }
.purchase-month-group summary::-webkit-details-marker { display: none; }
.purchase-month-header { background: #f5f5f5; font-size: 16px; font-weight: 600; padding: 10px 8px; border-radius: 4px; display: flex; justify-content: space-between; align-items: center; }
.purchase-month-header span:last-child { margin-left: auto; margin-right: 4px; }
.month-arrow { width: 16px; height: 16px; stroke: #888; fill: none; stroke-width: 2; flex-shrink: 0; margin-left: 6px; transition: transform 0.2s; }
.purchase-month-group[open] .month-arrow { transform: rotate(180deg); }
.purchase-month-header span:last-child { margin-left: auto; margin-right: 4px; }
.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 { padding: 1px 6px; border-radius: 8px; font-size: 12px; color: #fff; }
.status-badge.待付款 { background: #f59e0b; }
.status-badge.已付款 { background: #3b82f6; }
.status-badge.已收货 { background: #8b5cf6; }
.status-badge.已完成 { background: #10b981; }
.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; }
/* 可编辑详情弹窗 */
.detail-modal { max-height: 90vh; overflow-y: auto; padding: 0; }
.detail-modal input, .detail-modal select, .detail-modal textarea { margin: 0; }
.detail-header { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; padding: 8px 12px 8px 16px; display: flex; align-items: center; gap: 8px; position: sticky; top: 0; z-index: 1; }
.detail-header .icon-btn { padding:0; margin-right:0; flex-shrink: 0; margin-left: auto; }
.detail-header .icon-btn svg { stroke: #fff; }
.modal .detail-title-input { flex: 1; font-size: 18px; font-weight: 600; color: #fff; background: transparent; border: none; outline: none; padding: 4px 0; min-width: 0; }
.detail-title-input::placeholder { color: rgba(255,255,255,0.6); }
.detail-title-input:focus { border: none; outline: none; }
.edit-form { padding: 12px 16px 16px 16px; }
.edit-row { display: flex; align-items: center; margin-bottom: 8px; gap: 8px; }
.edit-row label { min-width: 48px; font-size: 13px; color: #777; text-align: right; flex-shrink: 0; }
.edit-row input, .edit-row select, .edit-row textarea { flex: 1; padding: 7px 8px; border: 1px solid #e0e0e0; border-radius: 6px; font-size: 14px; outline: none; margin: 0; }
.edit-row input:focus, .edit-row select:focus, .edit-row textarea:focus { border-color: #3b82f6; }
.edit-row select { appearance: auto; }
.edit-row textarea { resize: vertical; min-height: 40px; }
.amount-row { font-weight: 600; }
.amount-row label { color: #333; }
.edit-amount { flex: 1; font-size: 17px; color: #e53e3e; font-weight: 700; }
.edit-group { margin-bottom: 8px; }
.edit-group-summary { display: flex; align-items: center; gap: 8px; padding: 7px 0; cursor: pointer; border-bottom: 1px solid #f0f0f0; }
.edit-group-summary label { min-width: 48px; font-size: 13px; color: #777; text-align: right; flex-shrink: 0; }
.edit-group-summary span { flex: 1; font-size: 14px; color: #333; }
.group-arrow { width: 14px; height: 14px; stroke: #aaa; fill: none; stroke-width: 2; flex-shrink: 0; }
.edit-group-detail { padding: 4px 0; }
.edit-group-detail .edit-row { margin-bottom: 4px; }
.edit-section { margin-top: 8px; border-top: 1px solid #eee; padding-top: 8px; }
.edit-section-header { font-size: 13px; font-weight: 600; color: #555; cursor: pointer; display: flex; justify-content: space-between; align-items: center; padding: 2px 0; }
.edit-section-header svg { width: 16px; height: 16px; stroke: #888; fill: none; stroke-width: 2; }
.history-summary { list-style: none; display: flex; align-items: center; gap: 6px; }
.history-summary::-webkit-details-marker { display: none; }
.history-summary .section-arrow { width: 14px; height: 14px; stroke: #888; fill: none; stroke-width: 2; flex-shrink: 0; transition: transform 0.2s; }
details[open] .history-summary .section-arrow { transform: rotate(180deg); }
.attach-gallery { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.attach-thumb-wrap { position: relative; display: inline-block; }
.attach-thumb { width: 56px; height: 56px; object-fit: cover; border-radius: 6px; border: 1px solid #eee; }
.attach-gallery .attach-del-btn { position: absolute; top: -4px; right: -4px; width: 18px; height: 18px; border-radius: 50%; background: rgba(0,0,0,0.5); border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0; }
.attach-del-btn svg { width: 10px; height: 10px; stroke: #fff; fill: none; stroke-width: 2.5; }
.attach-add-btn { cursor: pointer; display: flex; align-items: center; }
.attach-add-btn svg { width: 18px; height: 18px; stroke: #3b82f6; fill: none; stroke-width: 2; }
.edit-buttons { display: flex; margin-top: 12px; }
.save-btn { flex: 1; background: #3b82f6; color: #fff; border: none; padding: 10px 0; border-radius: 6px; font-size: 15px; cursor: pointer; font-weight: 500; }
.danger-btn { background: transparent; color: #e53e3e; border: 1px solid #e53e3e; padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; flex-shrink: 0; margin-left: 8px; }
.white-list-tag {
font-size: 12px;
color: #555;
background: #f0f0f0;
padding: 2px 8px;
border-radius: 10px;
margin-right: 6px;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
vertical-align: middle;
}
+136 -753
View File
@@ -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" id="version-text"></div>
</div>
</div>
@@ -141,19 +29,16 @@
<!-- 群聊列表页 -->
<div id="list-page" style="display:flex; flex-direction:column; height:100%;">
<div class="header">
<div class="header-left">
<div class="header-left" onclick="Store.logout()">
<svg width="24" height="24" viewBox="0 0 24 24" stroke="#fff" fill="none" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="3"/><line x1="9" y1="9" x2="15" y2="9"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="12" y2="17"/>
</svg>
<h2>小财记账</h2>
</div>
<div style="display:flex; gap:2px;">
<button class="icon-btn" id="create-room-btn" onclick="openCreateRoom()" title="新建群聊">
<button class="icon-btn visible" 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="注销登录">
<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>
</div>
<div class="chat-list" id="chat-list-container"></div>
@@ -166,15 +51,40 @@
</div>
</div>
<!-- 聊天窗口 -->
<div id="chat-page" class="chat-window hidden">
<div class="chat-header">
<!-- 采购清单页 -->
<div id="purchase-page" class="hidden" style="display:flex; flex-direction:column; height:100%;">
<div class="header">
<button class="icon-btn back-btn" onclick="showList()">
<svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
<span class="chat-title" id="purchase-page-title">采购清单</span>
<button class="icon-btn" onclick="openChatFromPurchase()" title="聊天">
<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
</button>
</div>
<div class="purchase-panel-body" id="purchase-panel-body"></div>
<!-- 新建采购输入栏 -->
<div class="input-area">
<label class="file-upload-btn" title="上传图片">
<input type="file" id="purchase-file-input" accept="image/*" multiple style="display:none;" onchange="handlePurchaseFileSelect(event)">
<svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>
</label>
<textarea id="purchase-msg-input" placeholder="输入采购信息..." rows="1"></textarea>
<button class="send-btn" onclick="sendFromPurchase()">
<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>
<!-- 聊天窗口 -->
<div id="chat-page" class="chat-window hidden">
<div class="chat-header">
<button class="icon-btn back-btn" onclick="backToPurchase()">
<svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
<span class="chat-title" id="current-chat-name"></span>
<button class="icon-btn" onclick="openPurchasePanel()" title="采购清单">
<svg viewBox="0 0 24 24"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>
<button class="icon-btn" onclick="backToPurchase()" title="关闭聊天">
<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div class="messages" id="messages-container"></div>
@@ -188,21 +98,11 @@
<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>
<button class="icon-btn close-btn" onclick="closePurchasePanel()">
<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div class="purchase-panel-body" id="purchase-panel-body"></div>
</div>
</div>
</div>
<!-- 创建群聊弹窗 -->
<div id="create-modal" class="modal-overlay hidden">
<!-- 弹窗 -->
<div id="create-modal" class="modal-overlay hidden" onclick="if(event.target===this)closeCreateModal()">
<div class="modal">
<button class="modal-close-btn" onclick="closeCreateModal()">
<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
@@ -214,8 +114,7 @@
</div>
</div>
<!-- 管理群聊弹窗 -->
<div id="manage-modal" class="modal-overlay hidden">
<div id="manage-modal" class="modal-overlay hidden" onclick="if(event.target===this)closeManageModal()">
<div class="modal">
<button class="modal-close-btn" onclick="closeManageModal()">
<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
@@ -228,644 +127,128 @@
</div>
</div>
<!-- 采购详情弹窗 -->
<div id="detail-modal" class="modal-overlay hidden">
<!-- 采购汇总弹窗 -->
<div id="summary-modal" class="modal-overlay hidden" onclick="if(event.target===this)closeSummaryModal()">
<div class="modal">
<button class="modal-close-btn" onclick="closeDetailModal()">
<button class="modal-close-btn" onclick="closeSummaryModal()">
<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
<h3 id="detail-title"></h3>
<div id="detail-content"></div>
</div>
<h3 id="summary-title"></h3>
<div id="summary-content"></div>
</div>
</div>
<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>
<!-- 采购详情编辑弹窗 -->
<div id="detail-modal" class="modal-overlay hidden" onclick="if(event.target===this)closeDetailModal()">
<div class="modal detail-modal">
<div class="detail-header">
<input type="text" id="edit-item" class="detail-title-input" placeholder="物品名称">
<button class="icon-btn" onclick="closeDetailModal()">
<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></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 class="edit-form">
<!-- 金额组:折叠 -->
<div class="edit-group">
<div class="edit-group-summary" onclick="toggleEditGroup('amount-group')">
<label>金额</label>
<span id="amount-summary">0</span>
<svg class="group-arrow" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"></polyline></svg>
</div>
<div class="edit-group-detail hidden" id="amount-group">
<div class="edit-row"><label>数量</label><input type="number" id="edit-quantity" inputmode="decimal" min="0" step="1" oninput="updateAmountSummary()"></div>
<div class="edit-row"><label>单价</label><input type="number" id="edit-unit-price" inputmode="decimal" min="0" step="0.01" oninput="updateAmountSummary()"></div>
<div class="edit-row"><label>邮费</label><input type="number" id="edit-freight" inputmode="decimal" min="0" step="0.01" oninput="updateAmountSummary()"></div>
</div>
</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);
}
<!-- 状态组:折叠 -->
<div class="edit-group">
<div class="edit-group-summary" onclick="toggleEditGroup('status-group')">
<label>状态</label>
<span id="status-summary">待付款</span>
<svg class="group-arrow" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"></polyline></svg>
</div>
<div class="edit-group-detail hidden" id="status-group">
<div class="edit-row">
<label>付款</label>
<select id="edit-payment-method" onchange="updateStatusSummary()">
<option>未指定</option><option>支付宝</option><option>微信</option><option>银行卡</option><option>现金</option><option>对公转账</option>
</select>
</div>
<div class="edit-row">
<label>发票</label>
<select id="edit-invoice-type" onchange="updateStatusSummary()">
<option>无票</option><option>普票</option><option>专票</option>
</select>
</div>
<div class="edit-row">
<label>状态</label>
<select id="edit-status" onchange="updateStatusSummary()">
<option>待付款</option><option>已付款</option><option>已收货</option><option>已完成</option>
</select>
</div>
</div>
</div>
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'); }
<div class="edit-row">
<label>申请人</label><input type="text" id="edit-applicant">
</div>
<div class="edit-row">
<label>时间</label><input type="text" id="edit-time" placeholder="yyyy/MM/dd HH:mm:ss">
</div>
<div class="edit-row">
<label>备注</label><textarea id="edit-remarks" rows="2"></textarea>
</div>
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'); }
<!-- 附件 -->
<div class="edit-section">
<div class="edit-section-header">
<span>附件</span>
<label class="attach-add-btn" title="添加附件">
<input type="file" id="detail-file-input" accept="image/*" multiple style="display:none;" onchange="handleDetailFileUpload(event)">
<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>
</label>
</div>
<div class="attach-gallery" id="edit-attachments"></div>
</div>
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');
}
<!-- 操作历史(折叠) -->
<details class="edit-section" id="edit-history-section">
<summary class="edit-section-header history-summary"><span>操作历史</span><svg class="section-arrow" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg></summary>
<ul class="history-list" id="edit-history"></ul>
</details>
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 || '删除失败'); }
}
<!-- 按钮 -->
<div class="edit-buttons">
<button class="save-btn" onclick="savePurchase()">保存</button>
<button class="danger-btn" id="delete-purchase-btn" onclick="deletePurchaseDetail()">删除</button>
</div>
</div>
</div>
</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>
// 版本号
(function() {
document.getElementById('version-text').textContent = 'v2.8-260728';
})();
document.addEventListener('input', function(e) {
if (e.target.id === 'msg-input') {
if (e.target.id === 'msg-input' || e.target.id === 'purchase-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(); }
if (e.target.id === 'purchase-msg-input' && e.key === 'Enter' && !e.ctrlKey) { e.preventDefault(); sendFromPurchase(); }
});
init();
</script>
</body>
+43
View File
@@ -0,0 +1,43 @@
// 认证与初始化
async function init() {
const token = Store.getToken();
if (token) {
try {
const res = await fetch('/api/user', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) {
document.getElementById('login-page').classList.add('hidden');
document.getElementById('main-page').classList.remove('hidden');
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');
initWebSocket();
loadRooms();
updateSummaryPreview();
initSwipeGestures();
} else {
const err = await res.json();
document.getElementById('login-error').textContent = err.error || '\u767b\u5f55\u5931\u8d25';
}
}
+225
View File
@@ -0,0 +1,225 @@
// 工具函数
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) {
// 去重:轮询和 WebSocket 可能投递同一条消息
if (Store.messageCache.some(m => m.id === msg.id)) return;
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('click', onImageClick);
img.addEventListener('click', onImageClick);
});
document.querySelectorAll('.msg-bubble').forEach(bubble => {
bubble.removeEventListener('click', onBubbleClick);
bubble.addEventListener('click', onBubbleClick);
});
}
function onImageClick(e) {
e.stopPropagation();
const overlay = document.createElement('div');
overlay.className = 'fullscreen-overlay';
overlay.innerHTML = '<img src="' + e.target.src + '" alt="" style="transition: transform 0.1s; transform-origin: 0 0;">';
overlay.addEventListener('click', function(ev) { if (ev.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
initPinchZoom(overlay.querySelector('img'));
}
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;transition: transform 0.1s; transform-origin: 0 0;" alt="">';
overlay.addEventListener('click', function(ev) { if (ev.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
initPinchZoom(overlay.querySelector('img'));
}
function onBubbleClick(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(function(m) { return !m.isTemp; });
var tempEls = messagesContainer.querySelectorAll('[data-msg-id^="temp_"]');
tempEls.forEach(function(el) { el.remove(); });
}
// 双指缩放 + 拖拽
function initPinchZoom(img) {
var scale = 1, startDist = 0, startScale = 1;
var translateX = 0, translateY = 0;
var startX = 0, startY = 0, startTX = 0, startTY = 0;
var dragging = false;
function update() {
img.style.transform = 'translate(' + translateX + 'px,' + translateY + 'px) scale(' + scale + ')';
}
img.addEventListener('touchstart', function(e) {
if (e.touches.length === 2) {
dragging = false;
startDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
startScale = scale;
startTX = translateX;
startTY = translateY;
var cx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var cy = (e.touches[0].clientY + e.touches[1].clientY) / 2;
startX = cx; startY = cy;
} else if (e.touches.length === 1 && scale > 1) {
dragging = true;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
startTX = translateX;
startTY = translateY;
}
});
img.addEventListener('touchmove', function(e) {
if (e.touches.length === 2) {
e.preventDefault();
var dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
scale = Math.max(0.5, Math.min(5, startScale * (dist / startDist)));
var cx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
var cy = (e.touches[0].clientY + e.touches[1].clientY) / 2;
translateX = startTX + (cx - startX);
translateY = startTY + (cy - startY);
update();
} else if (e.touches.length === 1 && dragging) {
translateX = startTX + (e.touches[0].clientX - startX);
translateY = startTY + (e.touches[0].clientY - startY);
update();
}
}, { passive: false });
img.addEventListener('touchend', function() {
dragging = false;
if (scale < 0.6) { scale = 0.5; translateX = 0; translateY = 0; update(); }
if (scale <= 1) { translateX = 0; translateY = 0; update(); }
});
}
+347
View File
@@ -0,0 +1,347 @@
// 采购相关功能
let editPid = null;
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;">\u6682\u65e0\u91c7\u8d2d\u8bb0\u5f55</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 += '<details class="purchase-month-group" open><summary class="purchase-month-header"><span>' + month + '</span><span style="display:flex;align-items:center;gap:6px;">\uffe5' + monthTotal + '<svg class="month-arrow" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg></span></summary>';
monthItems.forEach(item => {
var t = item.created_at.split(' ')[1];
var timeShort = t ? t.substring(0,5) : '';
var datePart = item.created_at.split(' ')[0];
var dateShort = datePart ? datePart.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">\uffe5' + item.amount + '</span></div>' +
'<div class="item-meta">' +
'<span>' + dateShort + ' ' + timeShort + '</span>' +
'<span>x' + item.quantity + (item.invoice_type !== '\u65e0\u7968' ? ' \u00b7 ' + item.invoice_type : '') + '</span>' +
'<span class="status-badge ' + item.status + '">' + item.status + '</span>' +
'</div>' +
'</div>';
});
html += '</details>';
});
container.innerHTML = html;
}
function formatMonth(dateStr) {
var clean = dateStr.replace(/-/g, '/');
var parts = clean.split('/');
if (parts.length >= 2) return parts[0] + '\u5e74' + parseInt(parts[1], 10) + '\u6708';
return dateStr.substring(0,7);
}
async function openDetail(pid) {
var token = Store.getToken();
var res = await fetch('/api/purchases/' + pid, { headers: { 'Authorization': 'Bearer ' + token } });
var p = await res.json();
editPid = pid;
document.getElementById('edit-item').value = p.item || '';
document.getElementById('edit-quantity').value = p.quantity || 0;
document.getElementById('edit-unit-price').value = p.unit_price || 0;
document.getElementById('edit-freight').value = p.freight || 0;
updateAmountSummary();
document.getElementById('edit-payment-method').value = p.payment_method || '\u672a\u6307\u5b9a';
document.getElementById('edit-invoice-type').value = p.invoice_type || '\u65e0\u7968';
document.getElementById('edit-status').value = p.status || '\u5f85\u4ed8\u6b3e';
updateStatusSummary();
document.getElementById('edit-applicant').value = p.applicant || '';
document.getElementById('edit-time').value = p.created_at || '';
document.getElementById('edit-remarks').value = p.remarks || '';
renderEditAttachments(p.attachments || []);
var historyHtml = (p.history || []).map(function(h) {
return '<li><span class="history-time">' + h.timestamp + '</span> ' + escapeHtml(h.user) + ': ' + escapeHtml(h.action) + '</li>';
}).join('');
document.getElementById('edit-history').innerHTML = historyHtml || '<li style="color:#888;">\u6682\u65e0\u64cd\u4f5c\u8bb0\u5f55</li>';
document.getElementById('edit-history-section').open = false;
document.getElementById('delete-purchase-btn').style.display = '';
document.getElementById('delete-purchase-btn').style.marginLeft = '';
document.querySelectorAll('.edit-group-detail').forEach(function(el) { el.classList.add('hidden'); });
document.querySelectorAll('.edit-group-summary').forEach(function(el) { el.classList.remove('hidden'); });
document.getElementById('detail-modal').classList.remove('hidden');
}
function renderEditAttachments(attachments) {
var container = document.getElementById('edit-attachments');
if (!attachments || attachments.length === 0) {
container.innerHTML = '<span style="color:#888;font-size:14px;">\u6682\u65e0\u9644\u4ef6</span>';
return;
}
container.innerHTML = attachments.map(function(a) {
return '<div class="attach-thumb-wrap">' +
'<img class="attach-thumb" src="' + a.file_path + '" ondblclick="onImgThumbDblClick(this)">' +
'<button class="attach-del-btn" onclick="deleteAttachment(event, \'' + encodeURIComponent(a.file_path) + '\')">' +
'<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>' +
'</button>' +
'</div>';
}).join('');
}
function updateAmountSummary() {
var q = parseFloat(document.getElementById('edit-quantity').value) || 0;
var up = parseFloat(document.getElementById('edit-unit-price').value) || 0;
var f = parseFloat(document.getElementById('edit-freight').value) || 0;
var total = (q * up + f).toFixed(2);
document.getElementById('amount-summary').textContent = total + ', \u6570\u91cf' + q + ', \u5355\u4ef7' + up + ', \u90ae\u8d39' + f;
}
function updateStatusSummary() {
var pm = document.getElementById('edit-payment-method').value;
var iv = document.getElementById('edit-invoice-type').value;
var st = document.getElementById('edit-status').value;
document.getElementById('status-summary').textContent = st + ', ' + pm + ', ' + iv;
}
function toggleEditGroup(groupId) {
var detail = document.getElementById(groupId);
var summary = detail.previousElementSibling;
var isOpen = !detail.classList.contains('hidden');
if (isOpen) {
detail.classList.add('hidden');
summary.classList.remove('hidden');
} else {
detail.classList.remove('hidden');
summary.classList.add('hidden');
}
}
async function savePurchase() {
if (!editPid) return;
var token = Store.getToken();
var body = {
item: document.getElementById('edit-item').value,
quantity: parseFloat(document.getElementById('edit-quantity').value) || 0,
unit_price: parseFloat(document.getElementById('edit-unit-price').value) || 0,
freight: parseFloat(document.getElementById('edit-freight').value) || 0,
payment_method: document.getElementById('edit-payment-method').value,
invoice_type: document.getElementById('edit-invoice-type').value,
status: document.getElementById('edit-status').value,
applicant: document.getElementById('edit-applicant').value,
remarks: document.getElementById('edit-remarks').value,
created_at: document.getElementById('edit-time').value
};
var res = await fetch('/api/purchases/' + editPid, {
method: 'PUT',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
var result = await res.json();
if (result.success) {
closeDetailModal();
loadPurchases();
} else {
alert('\u4fdd\u5b58\u5931\u8d25\uff1a' + (result.error || '\u672a\u77e5\u9519\u8bef'));
}
}
async function deletePurchaseDetail() {
if (!editPid) return;
if (!confirm('\u786e\u5b9a\u8981\u5220\u9664\u6b64\u91c7\u8d2d\u8bb0\u5f55\u5417\uff1f')) return;
var token = Store.getToken();
var res = await fetch('/api/purchases/' + editPid, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
});
if (res.ok) {
closeDetailModal();
loadPurchases();
} else {
var err = await res.json();
alert('\u5220\u9664\u5931\u8d25\uff1a' + (err.error || '\u672a\u77e5\u9519\u8bef'));
}
}
async function deleteAttachment(event, encodedPath) {
event.stopPropagation();
var filePath = decodeURIComponent(encodedPath);
if (!confirm('\u786e\u5b9a\u5220\u9664\u6b64\u9644\u4ef6\u5417\uff1f')) return;
var token = Store.getToken();
var res = await fetch('/api/purchases/' + editPid + '/attachments', {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath })
});
if (res.ok) openDetail(editPid);
}
async function handleDetailFileUpload(event) {
var files = event.target.files;
if (!files.length) return;
var token = Store.getToken();
var paths = [];
try {
for (var i = 0; i < files.length; i++) {
var file = files[i];
var blob = file;
if (file.size > 1 * 1024 * 1024) blob = await compressImage(file);
var formData = new FormData(); formData.append('file', blob, file.name || 'image.jpg');
var res = await fetch('/api/upload', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: formData });
var data = await res.json();
if (data.path) paths.push(data.path);
}
if (paths.length) {
await fetch('/api/purchases/' + editPid + '/attachments', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ file_paths: paths })
});
openDetail(editPid);
}
} catch(e) { alert('\u4e0a\u4f20\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5'); }
}
function closeDetailModal() {
document.getElementById('detail-modal').classList.add('hidden');
editPid = null;
}
async function handlePurchaseFileSelect(event) {
var files = event.target.files;
if (!files.length) return;
var token = Store.getToken();
try {
for (var i = 0; i < files.length; i++) {
var file = files[i];
var blob = file;
if (file.size > 1 * 1024 * 1024) blob = await compressImage(file);
var formData = new FormData(); formData.append('file', blob, file.name || 'image.jpg');
var res = await fetch('/api/upload', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: formData });
var data = await res.json();
if (data.path) Store.pendingUploads.push(data.path);
}
} catch(e) { alert('\u56fe\u7247\u4e0a\u4f20\u5931\u8d25'); }
}
function sendFromPurchase() {
var input = document.getElementById('purchase-msg-input');
var text = input.value.trim();
if (!text && Store.pendingUploads.length === 0) return;
Store.pendingPurchaseText = text;
input.value = '';
openChatFromPurchase();
}
async function openSummary() {
var token = Store.getToken();
var res = await fetch('/api/summary', { headers: { 'Authorization': 'Bearer ' + token } });
var summary = await res.json();
var html = '';
if (summary.length === 0) html = '<p>\u6682\u65e0\u91c7\u8d2d\u8bb0\u5f55</p>';
else {
summary.forEach(function(s) {
html += '<div class="summary-card" onclick="event.stopPropagation(); closeSummaryModal(); openPurchasePage(\'' + s.room_id + '\')" style="margin:8px 0;"><h4>' + escapeHtml(s.room_name) + '</h4><p>' + s.purchase_count + ' \u6761\uff0c\u5408\u8ba1 \uffe5' + s.total_amount + '</p></div>';
});
}
document.getElementById('summary-title').textContent = '\u91c7\u8d2d\u6c47\u603b';
document.getElementById('summary-content').innerHTML = html;
document.getElementById('summary-modal').classList.remove('hidden');
}
function closeSummaryModal() { document.getElementById('summary-modal').classList.add('hidden'); }
function openCreateRoom() {
document.getElementById('create-modal').classList.remove('hidden');
var isAdmin = localStorage.getItem('isAdmin') === 'true';
document.getElementById('white-list').style.display = isAdmin ? '' : 'none';
}
function closeCreateModal() { document.getElementById('create-modal').classList.add('hidden'); }
async function confirmCreateRoom() {
var name = document.getElementById('new-room-name').value.trim();
if (!name) return alert('\u8bf7\u8f93\u5165\u540d\u79f0');
var isAdmin = localStorage.getItem('isAdmin') === 'true';
var whiteList = isAdmin ? document.getElementById('white-list').value.trim() : localStorage.getItem('currentUser');
var token = Store.getToken();
await fetch('/api/rooms', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name, whiteList: whiteList }) });
closeCreateModal();
loadRooms();
}
function openManageRoom(roomId) {
var room = Store.rooms.find(function(r) { return r.id === roomId; });
if (!room) return;
var isAdmin = localStorage.getItem('isAdmin') === 'true';
document.getElementById('manage-title').textContent = '\u7f16\u8f91\u7fa4\u804a';
document.getElementById('manage-room-name').value = room.name;
document.getElementById('manage-white-list').value = room.white_list || '';
document.getElementById('manage-white-list').style.display = isAdmin ? '' : 'none';
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() {
var roomId = document.getElementById('manage-modal').dataset.roomId;
var name = document.getElementById('manage-room-name').value.trim();
var isAdmin = localStorage.getItem('isAdmin') === 'true';
var whiteList = isAdmin ? document.getElementById('manage-white-list').value.trim() : undefined;
if (!name) return alert('\u8bf7\u8f93\u5165\u7fa4\u804a\u540d\u79f0');
var token = Store.getToken();
var res = await fetch('/api/rooms/' + roomId, {
method: 'PUT', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, whiteList: whiteList })
});
if (res.ok) { closeManageModal(); loadRooms(); }
else { var err = await res.json(); alert(err.error || '\u66f4\u65b0\u5931\u8d25'); }
}
async function deleteRoom() {
if (!confirm('\u786e\u5b9a\u8981\u5220\u9664\u8be5\u7fa4\u804a\u5417\uff1f\u6240\u6709\u6d88\u606f\u548c\u91c7\u8d2d\u6570\u636e\u5c06\u88ab\u6c38\u4e45\u5220\u9664\u3002')) return;
var roomId = document.getElementById('manage-modal').dataset.roomId;
var token = Store.getToken();
var res = await fetch('/api/rooms/' + roomId, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) {
closeManageModal();
loadRooms();
if (Store.currentRoom === roomId) showList();
} else { var err = await res.json(); alert(err.error || '\u5220\u9664\u5931\u8d25'); }
}
function initSwipeGestures() {
var purchasePage = document.getElementById('purchase-page');
var chatPage = document.getElementById('chat-page');
var startX = 0;
purchasePage.addEventListener('touchstart', function(e) { startX = e.touches[0].clientX; }, { passive: true });
purchasePage.addEventListener('touchend', function(e) {
if (!startX) return;
var diffX = e.changedTouches[0].clientX - startX;
if (Math.abs(diffX) > 50) {
if (diffX > 30) showList();
else if (diffX < -30) openChatFromPurchase();
}
startX = 0;
}, { passive: true });
chatPage.addEventListener('touchstart', function(e) { startX = e.touches[0].clientX; }, { passive: true });
chatPage.addEventListener('touchend', function(e) {
if (!startX) return;
var diffX = e.changedTouches[0].clientX - startX;
if (Math.abs(diffX) > 50) backToPurchase();
startX = 0;
}, { passive: true });
}
+32
View File
@@ -0,0 +1,32 @@
// 全局状态管理
const Store = {
ws: null,
currentRoom: null,
rooms: [],
pendingUploads: [],
pendingPurchaseText: '',
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();
location.reload();
},
};
+223
View File
@@ -0,0 +1,223 @@
// 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';
const currentUser = localStorage.getItem('currentUser');
container.innerHTML = Store.rooms.map(room => {
const lastMsg = room.last_message || '暂无消息';
const lastTime = room.last_time || '';
// 白名单标签仅管理员可见
let whiteListHtml = '';
if (isAdmin && room.white_list && room.white_list.trim() !== '') {
const members = room.white_list.split(',').map(s => s.trim()).filter(Boolean).join(', ');
whiteListHtml = '<span class="white-list-tag" title="' + escapeHtml(members) + '">' + escapeHtml(members) + '</span>';
}
// 编辑按钮:管理员始终可见,普通用户仅自己群聊可见
const canEdit = isAdmin || (room.white_list && room.white_list.trim() === currentUser);
const editBtnHtml = canEdit ? '<div class="room-actions">' +
whiteListHtml +
'<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>' : '';
return '<div class="chat-item" data-room-id="' + room.id + '">' +
'<div class="avatar" onclick="openPurchasePage(\'' + room.id + '\')">' + escapeHtml(room.name.charAt(0)) + '</div>' +
'<div class="chat-info" onclick="openPurchasePage(\'' + 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="openPurchasePage(\'' + room.id + '\')">' + lastTime + '</div>' +
editBtnHtml +
'</div>' +
'</div>';
}).join('');
}
async function openPurchasePage(roomId) {
Store.currentRoom = roomId;
document.getElementById('list-page').classList.add('hidden');
document.getElementById('chat-page').classList.add('hidden');
document.getElementById('purchase-page').classList.remove('hidden');
document.getElementById('purchase-panel-body').innerHTML = '<p style="text-align:center;color:#888;">加载中...</p>';
var room = Store.rooms.find(function(r) { return r.id === roomId; });
document.getElementById('purchase-page-title').textContent = (room ? room.name : '') + '(清单)';
loadPurchases();
}
async function openChatFromPurchase() {
if (!Store.currentRoom) return;
await openChat(Store.currentRoom, true);
}
function backToPurchase() {
clearTempBubble();
document.getElementById('chat-page').classList.add('hidden');
document.getElementById('purchase-page').classList.remove('hidden');
loadPurchases();
}
async function openChat(roomId, fromPurchase = false) {
Store.currentRoom = roomId;
clearTempBubble();
document.getElementById('list-page').classList.add('hidden');
document.getElementById('purchase-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 ? room.name : '') + '(聊天)';
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 }));
// 从采购页跳转过来时,自动发送缓存的文本
if (fromPurchase && Store.pendingPurchaseText) {
const text = Store.pendingPurchaseText;
Store.pendingPurchaseText = '';
document.getElementById('msg-input').value = text;
document.getElementById('msg-input').style.height = 'auto';
document.getElementById('msg-input').style.height = document.getElementById('msg-input').scrollHeight + 'px';
setTimeout(() => sendMessage(), 300);
}
}
function showList() {
clearTempBubble();
document.getElementById('chat-page').classList.add('hidden');
document.getElementById('purchase-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
View 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;
+81
View File
@@ -0,0 +1,81 @@
// 消息路由 + 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, roomId);
if (aiResponse && aiResponse.action !== 'ignore') {
handleAIResult(aiResponse, roomId, username, text || '', attachments || []);
}
} catch (e) { console.error('AI 分析失败:', e); }
});
async function analyzeWithDeepSeek(text, historyMessages, username, isAdmin, roomId) {
// 注入当前房间采购清单上下文
const purchases = db.prepare("SELECT item, quantity, amount, status, created_at FROM purchases WHERE room_id = ? ORDER BY created_at DESC LIMIT 10").all(roomId);
const purchaseContext = purchases.length
? `\n📋 当前房间采购清单(最近10条):\n${purchases.map(p => `- ${p.item} | ×${p.quantity} | ¥${p.amount} | ${p.status} | ${p.created_at}`).join('\n')}`
: '';
const systemPrompt = buildSystemPrompt(username, isAdmin) + purchaseContext;
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);
try {
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;
} catch (e) {
// AI 返回非 JSON(如纯文本回复图片消息)→ 降级为 chat
console.warn('⚠️ AI 返回非 JSON,降级为 chat:', content.substring(0, 50));
return { action: 'chat', reply: content };
}
}
module.exports = router;
+145
View File
@@ -0,0 +1,145 @@
// 采购相关路由
const express = require('express');
const { authMiddleware } = require('../middleware/auth');
const { broadcastToRoom } = require('../ws');
const db = require('../db');
const { timestamp } = require('../utils');
const router = express.Router();
function insertChatMsg(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 };
broadcastToRoom(roomId, { type: 'new_message', message: msg });
}
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: '\u672a\u627e\u5230' });
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.put('/purchases/:id', authMiddleware, (req, res) => {
const old = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id);
if (!old) return res.status(404).json({ error: '\u672a\u627e\u5230' });
const now = timestamp();
const username = req.user.username;
const { item, quantity, unit_price, freight, payment_method, invoice_type, status, applicant, remarks, created_at } = req.body;
const newVals = {
item: item !== undefined ? String(item) : old.item,
quantity: quantity !== undefined ? Number(quantity) : old.quantity,
unit_price: unit_price !== undefined ? Number(unit_price) : old.unit_price,
freight: freight !== undefined ? Number(freight) : old.freight,
payment_method: payment_method !== undefined ? String(payment_method) : old.payment_method,
invoice_type: invoice_type !== undefined ? String(invoice_type) : old.invoice_type,
status: status !== undefined ? String(status) : old.status,
applicant: applicant !== undefined ? String(applicant) : old.applicant,
remarks: remarks !== undefined ? String(remarks) : old.remarks,
created_at: created_at !== undefined ? String(created_at) : old.created_at
};
newVals.amount = newVals.quantity * newVals.unit_price + newVals.freight;
const fieldLabels = {
item: '\u7269\u54c1', quantity: '\u6570\u91cf', unit_price: '\u5355\u4ef7', freight: '\u90ae\u8d39', amount: '\u91d1\u989d',
payment_method: '\u4ed8\u6b3e\u65b9\u5f0f', invoice_type: '\u53d1\u7968', status: '\u72b6\u6001', applicant: '\u7533\u8bf7\u4eba', remarks: '\u5907\u6ce8', created_at: '\u65f6\u95f4'
};
const changes = [];
for (const [field, label] of Object.entries(fieldLabels)) {
if (String(newVals[field]) !== String(old[field])) {
changes.push(label + ': ' + (old[field] || '\u7a7a') + ' \u2192 ' + newVals[field]);
db.prepare('UPDATE purchases SET ' + field + ' = ? WHERE id = ?').run(newVals[field], old.id);
}
}
if (changes.length > 0) {
db.prepare('UPDATE purchases SET updated_at = ? WHERE id = ?').run(now, old.id);
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(old.id, '\u66f4\u65b0\uff1a' + changes.join('\uff1b'), username, now);
insertChatMsg(old.room_id, '\u5c0f\u8d22', '\ud83d\udd04 ' + username + ' \u66f4\u65b0\u4e86\u91c7\u8d2d\u300c' + newVals.item + '\u300d\uff1a' + changes.join('\uff0c'));
broadcastToRoom(old.room_id, { type: 'purchase_updated' });
res.json({ success: true, changes });
} else {
res.json({ success: true, changes: [] });
}
});
// 删除采购记录
router.delete('/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: '\u672a\u627e\u5230' });
const username = req.user.username;
const itemName = pur.item;
db.prepare('DELETE FROM purchase_attachments WHERE purchase_id = ?').run(pur.id);
db.prepare('DELETE FROM purchase_history WHERE purchase_id = ?').run(pur.id);
db.prepare('DELETE FROM purchases WHERE id = ?').run(pur.id);
insertChatMsg(pur.room_id, '\u5c0f\u8d22', '\ud83d\uddd1 ' + username + ' \u5220\u9664\u4e86\u91c7\u8d2d\u8bb0\u5f55\u300c' + itemName + '\u300d');
broadcastToRoom(pur.room_id, { type: 'purchase_updated' });
res.json({ success: true });
});
// 管理附件
router.post('/purchases/:id/attachments', authMiddleware, (req, res) => {
const pur = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id);
if (!pur) return res.status(404).json({ error: '\u672a\u627e\u5230' });
const now = timestamp();
const username = req.user.username;
const { file_paths } = req.body;
if (!file_paths || !file_paths.length) return res.status(400).json({ error: '\u7f3a\u5c11\u6587\u4ef6\u8def\u5f84' });
const insertAttach = db.prepare('INSERT INTO purchase_attachments (purchase_id, file_path, uploaded_by, timestamp) VALUES (?,?,?,?)');
file_paths.forEach(fp => insertAttach.run(pur.id, fp, username, now));
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(pur.id, '\u6dfb\u52a0\u9644\u4ef6\uff1a' + file_paths.length + ' \u4e2a', username, now);
broadcastToRoom(pur.room_id, { type: 'purchase_updated' });
res.json({ success: true });
});
router.delete('/purchases/:id/attachments', authMiddleware, (req, res) => {
const pur = db.prepare('SELECT * FROM purchases WHERE id = ?').get(req.params.id);
if (!pur) return res.status(404).json({ error: '\u672a\u627e\u5230' });
const username = req.user.username;
const { file_path } = req.body;
if (!file_path) return res.status(400).json({ error: '\u7f3a\u5c11\u6587\u4ef6\u8def\u5f84' });
db.prepare('DELETE FROM purchase_attachments WHERE purchase_id = ? AND file_path = ?').run(pur.id, file_path);
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(pur.id, '\u5220\u9664\u9644\u4ef6\uff1a' + file_path.split('/').pop(), username, timestamp());
broadcastToRoom(pur.room_id, { type: 'purchase_updated' });
res.json({ success: true });
});
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 = '\u65f6\u95f4,\u4e8b\u9879,\u6570\u91cf,\u5355\u4ef7,\u91d1\u989d,\u90ae\u8d39,\u4ed8\u6b3e\u65b9\u5f0f,\u53d1\u7968\u7c7b\u578b,\u72b6\u6001,\u7533\u8bf7\u4eba,\u5907\u6ce8\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;
+70
View File
@@ -0,0 +1,70 @@
// 群聊管理路由
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 : '' };
});
// 按最后消息时间排序,最近的在前,无消息的排最后
result.sort((a, b) => {
if (!a.last_time && !b.last_time) return 0;
if (!a.last_time) return 1;
if (!b.last_time) return -1;
return b.last_time.localeCompare(a.last_time);
});
res.json(result);
});
// 普通用户可新建,白名单自动设为用户名
router.post('/', authMiddleware, (req, res) => {
const { name, whiteList } = req.body;
const isAdmin = req.user.isAdmin;
const finalWhiteList = isAdmin ? (whiteList || '') : req.user.username;
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, finalWhiteList, now);
broadcast({ type: 'room_created', room: { id, name, white_list: finalWhiteList } });
res.json({ id, name });
});
// 普通用户可编辑名称,白名单不变
router.put('/:roomId', authMiddleware, (req, res) => {
const { name, whiteList } = req.body;
const roomId = req.params.roomId;
const isAdmin = req.user.isAdmin;
const room = db.prepare('SELECT * FROM rooms WHERE id = ?').get(roomId);
if (!room) return res.status(404).json({ error: '群聊不存在' });
const finalWhiteList = isAdmin ? (whiteList !== undefined ? whiteList : room.white_list) : room.white_list;
db.prepare('UPDATE rooms SET name = ?, white_list = ? WHERE id = ?').run(name, finalWhiteList, roomId);
broadcast({ type: 'room_updated', room: { id: roomId, name, white_list: finalWhiteList } });
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;
+27 -554
View File
@@ -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
View 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
View 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 };