Compare commits

..
57 Commits
Author SHA1 Message Date
kicer 738ef1697c v3.0.1, 改成tools调用方式 2026-08-29 23:36:59 +08:00
kicer 45cf00bf14 update domain, 后续需实现function版本替代system prompt 2026-08-29 10:23:46 +08:00
kicer b41779c051 v2.8.5, 加入查看详情功能 2026-07-29 12:57:27 +08:00
kicer e08a17ffa0 fix prompt 2026-07-29 11:36:44 +08:00
kicer cd41676ee3 优化prmpt 2026-07-29 11:15:27 +08:00
kicer 905973d739 fix prompt 2026-07-29 10:58:29 +08:00
kicer 7cf008eac5 fix issues 2026-07-29 10:47:49 +08:00
kicer f3faa36e99 fix issues 2026-07-29 10:38:19 +08:00
kicer abf84e1081 fix issues 2026-07-29 10:24:28 +08:00
kicer 6a768ef146 优化缓存命中 2026-07-29 10:23:01 +08:00
kicer de2de85e58 优化缓存命中 2026-07-29 10:10:31 +08:00
kicer 886df10775 优化缓存命中 2026-07-29 09:48:44 +08:00
kicer 7247b1d832 支持批量处理 2026-07-29 08:20:38 +08:00
kicer b6f2c8dd42 fix issues 2026-07-29 08:18:11 +08:00
kicer 074e907cae 修改缓存命中逻辑 2026-07-29 08:16:57 +08:00
kicer 0e17abd653 对话时候严格要求json输出 2026-07-29 08:12:37 +08:00
kicer c90718c6de 关闭思考,温度0.1 2026-07-29 08:10:09 +08:00
kicer 467d879dc3 ds api加上错误时的回复 2026-07-29 08:03:15 +08:00
kicer 416b173183 ds api请求时加上user_id 2026-07-29 08:00:51 +08:00
kicer e08c0cdff0 修改prompt 2026-07-29 07:46:57 +08:00
kicer 1f6bc4789b v2.8.1, 基本版本功能完成 2026-07-29 07:27:20 +08:00
kicer 300930cebd 附件图片单击后打开 2026-07-29 07:11:31 +08:00
kicer 56cc1f6e2e fix css 2026-07-28 20:44:04 +08:00
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
kicer e9adbc90cf fix: 防止 AI 在信息不完整时误建无效采购记录
- AI prompt 新增采购意图校验:无消费意图→ignore,信息不全→ask
- 后端 purchase 入口增加兜底拦截:purchase_item 无效(<2字符)直接拒绝
- 杜绝 'deep' 等碎片化信息被当成物品名创建记录
2026-07-22 16:24:21 +08:00
kicer c61d64a249 双击放大后显示表格线 2026-07-22 16:16:20 +08:00
kicer 5f73c384cf fix: 扩展 use_recent_image 触发词,覆盖更自然的引用表述
- AI prompt 新增触发词:上面/前面/刚刚那张是XX的图片、那张图片存到XX
- 客户端 pendingUploads 每次发送后清空是正常行为,不能依赖
- 引用历史图片必须走 use_recent_image 机制
- 移除 purchase 分支和 POST 入口的调试日志
2026-07-22 16:07:14 +08:00
kicer fb07144ef3 debug: purchase 分支和 POST 入口加 attachments 日志
- POST /api/rooms/:roomId/messages 入口打印收到的 attachments
- handleAIResult purchase 分支开头打印 attachments 值
- 用于定位附件关联失败的具体环节
2026-07-22 16:04:41 +08:00
kicer 742bb908a5 fix: 防止 is_new + 附件导致误建空白采购记录
- AI prompt 新增「绝对禁止规则」:纯图片+物品名请求禁止返回 is_new
- AI prompt「新建 vs 更新」补充:附件请求不是新建
- 后端兜底:is_new 但附件非空时,查已有同名记录,存在则强制转为更新模式
- 避免创建 quantity=1 amount=0 的无意义记录
2026-07-22 16:00:20 +08:00
kicer 3ea58fcf3d backup 2026-07-22 15:58:46 +08:00
kicer 3d7732474d fix: delete 支持 quantity/amount 精确匹配,避免误删同名记录
- AI prompt 新增「删除规则补充」:用户指定数量/金额时同时返回对应字段
- 后端 handleAIResult delete 分支改为动态构建 SQL,quantity/amount 存在时追加 AND 条件
- 新增删除查询日志方便排查
2026-07-22 15:44:33 +08:00
kicer 4fe5abb74a fix issues 2026-07-22 15:43:00 +08:00
kicer cf63ed5e3f backup codes 2026-07-22 15:38:37 +08:00
kicer 2feb6004b8 附件图片双击放大显示 2026-07-22 15:12:14 +08:00
24 changed files with 2466 additions and 1224 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
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- ADMINS=${ADMINS} - ADMINS=${ADMINS}
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY} - DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}
- PORT=3000 - PORT=3000
- VIRTUAL_HOST=ai.foresh.com - VIRTUAL_HOST=xiaocai.ai.foresh.com
- VIRTUAL_PORT=3000 - VIRTUAL_PORT=3000
- CERT_NAME=default - CERT_NAME=default
networks: networks:
+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` 对象)
+58
View File
@@ -0,0 +1,58 @@
// DeepSeek API 客户端
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || '';
function sanitizeUserId(username) {
return username.replace(/[^a-zA-Z0-9\-_]/g, '_').substring(0, 64) || 'anon';
}
function getErrorMsg(status, data) {
var msg = data && data.error && data.error.message ? data.error.message : '';
switch (status) {
case 400: return 'AI 请求格式错误,请联系管理员(400)' + (msg ? '' + msg : '');
case 401: return 'AI 认证失败,请检查 API Key(401)' + (msg ? '' + msg : '');
case 402: return 'AI 账户余额不足,请联系管理员充值(402)';
case 422: return 'AI 参数错误(422)' + (msg ? '' + msg : '');
case 429: return 'AI 请求太频繁,请稍后重试(429)';
case 500: return 'AI 服务器故障,请稍后重试(500)';
case 503: return 'AI 服务器繁忙,请稍后重试(503)';
default: return 'AI 响应异常(' + status + ')' + (msg ? '' + msg : '');
}
}
async function callDeepSeek(messages, userId, temperature = 0.1, jsonMode = true, tools) {
console.log('调用 DeepSeek...');
var body = { model: 'deepseek-v4-flash', messages: messages, temperature: temperature, stream: false, thinking: { type: 'disabled' } };
if (tools && tools.length) body.tools = tools;
if (jsonMode && !(tools && tools.length)) body.response_format = { type: 'json_object' };
if (userId) body.user_id = sanitizeUserId(userId);
var res = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + DEEPSEEK_API_KEY },
body: JSON.stringify(body)
});
var data = await res.json();
if (!res.ok) {
var errMsg = getErrorMsg(res.status, data);
console.error('DeepSeek API 错误:', errMsg);
throw new Error(errMsg);
}
if (!data.choices || !data.choices[0]) {
console.error('DeepSeek 返回异常:', JSON.stringify(data));
throw new Error('AI 返回数据异常');
}
// 缓存命中日志
if (data.usage) {
var hit = data.usage.prompt_cache_hit_tokens || 0;
var miss = data.usage.prompt_cache_miss_tokens || 0;
var total = data.usage.prompt_tokens || 0;
var rate = total > 0 ? (hit / total * 100).toFixed(1) : 0;
console.log('缓存: hit=' + hit + ' miss=' + miss + ' total=' + total + ' 命中率=' + rate + '%');
}
// 输入消息大小日志
//var inputChars = JSON.stringify(messages).length;
//console.log('输入大小: ' + inputChars + ' 字符, ' + messages.length + ' 条消息');
// 返回整个 message 对象,可能包含 content 或 tool_calls(供上层决定走工具还是 JSON)
return data.choices[0].message;
}
module.exports = { callDeepSeek };
+40
View File
@@ -0,0 +1,40 @@
// AI Prompt 模板 — 工具模式(阶段一)
// 新建采购 / 查询 / 聊天 / 忽略 走 Function Tool 调用;
// 删除 / 清空 保留 JSON 输出路径(旧逻辑兜底,见 messages.js)。
// 图片关联由后端决定,不在 prompt 中引导模型输出 use_recent_image。
function buildSystemPrompt(username, isAdmin) {
const role = '你是智能财务助手"小财"。当前用户:' + username + ',管理员状态:' + isAdmin + 'true=管理员,false=普通用户)。';
const guidance = [
'请根据用户消息选择合适的方式回应:',
'',
'一、调用工具(适用于以下场景):',
'1. 新建采购记录 → 调用工具 create_purchase。',
' 触发条件:用户明确表达了购买/采购/下单/付款/买了/花了等消费意图,且不是在查询历史。',
' 物品名、数量、单价、运费、支付方式、发票类型、状态、备注等字段含义见工具参数定义。',
' 关键规则1:如果用户没有明确提供单价(价格),不要调用 create_purchase,而应调用 reply_chat 追问价格。',
' 关键规则2:用户如提及"交了""付了""已付款""已收货""已完成"等,必须把 status 填为对应状态,不要漏填。',
' 关键规则3:一次消息中包含多笔采购时,必须为每一笔分别调用一次 create_purchase,不要合并或遗漏。',
'2. 查询历史采购记录 → 调用工具 query_purchases,参数 keywords 为物品名或关键词(查全部则用空字符串)。',
' 触发条件:用户询问"买过什么/有没有买过/查一下/找找看/搜索/列表/我买过/看看"等查询意图。',
' 即使物品名听起来奇怪或可能不存在,也必须用 query_purchases,绝不能调 create_purchase。',
'3. 纯闲聊、引导手动修改、追问价格、权限不足拒绝 → 调用工具 reply_chat,参数 reply 为要显示给用户的回复。',
'4. 完全无关的消息(纯符号、纯表情、明显不是对助手说的话)→ 调用工具 ignore。',
'',
'二、直接输出 JSON(不要调用任何工具):',
'5. 删除采购记录 → 输出 {"action":"delete","delete_item":"物品名","quantity":数量,"amount":金额},其中 quantity/amount 可选。',
'6. 清空所有采购数据 → 输出 {"action":"clear_all"}。仅管理员(isAdmin=true)可执行;',
' 非管理员必须输出 {"action":"chat","reply":"仅管理员可执行此操作"}。',
'',
'三、通用规则:',
'- 用户要求修改、更正、调整、更新已有采购记录的任何意图(改数量、改价格、改状态、补发票、加备注等),一律用 reply_chat 引导用户手动操作,不要尝试更新记录。',
'- 修改意图的统一回复模板:"如需修改记录,请前往采购清单页面,找到对应条目后手动编辑。"',
'- 如果用户同时表达了购买和查询,优先判断为购买(create_purchase),除非提问明显是查询历史。',
'- 若无法确定用户意图,默认调用 reply_chat 并给出友好回复,绝不忽略。'
].join('\n');
return [role, guidance].join('\n\n');
}
module.exports = { buildSystemPrompt };
+85
View File
@@ -0,0 +1,85 @@
// DeepSeek Function Tools 定义
// 阶段一:迁移 purchase / chat / ignore 为工具调用;query 也提供工具入口(避免查询意图误判成新建),
// 但其汇总逻辑仍复用 handlers.js 的 query 分支(不做二次调用回填)。
// delete / clear_all 保留旧 JSON 输出路径(在 prompt 中引导走 JSON,见 prompt.js)。
// 图片关联不在工具参数中暴露,由后端根据消息文本/附件自行决定(见 handlers.js 的 create_purchase 分支)。
const STATUS_ENUM = ['待付款', '已付款', '已收货', '已完成'];
const INVOICE_ENUM = ['无票', '电子票'];
const createPurchaseTool = {
type: 'function',
function: {
name: 'create_purchase',
description:
'新建一条采购记录。仅在用户明确表达购买/采购/下单/付款/买了/花了等消费意图且不是查询历史时调用。' +
'注意:如果用户没有明确提供单价(价格),不要调用本工具,应改为调用 reply_chat 追问价格。',
parameters: {
type: 'object',
properties: {
purchase_item: { type: 'string', description: '物品名称,必填' },
quantity: { type: 'number', description: '数量,默认 1' },
unit_price: { type: 'number', description: '单价。必须大于 0,若用户未明确给出价格请不要调用本工具,改为 reply_chat 追问。' },
freight: { type: 'number', description: '运费,默认 0' },
payment_method: { type: 'string', description: '支付方式,如 支付宝/微信,未提及则默认未指定' },
invoice_type: { type: 'string', enum: INVOICE_ENUM, description: '发票类型,默认无票' },
status: { type: 'string', enum: STATUS_ENUM, description: '付款/收货状态。若用户明确说了已付款、交了钱、付了款、已收、已到货、已完成等,必须主动填对应值(如已付款),不要省略。仅当完全没提状态时才用默认待付款。' },
remarks: { type: 'string', description: '备注,如购买平台等' }
},
required: ['purchase_item']
}
}
};
const replyChatTool = {
type: 'function',
function: {
name: 'reply_chat',
description:
'直接回复一段文本给用户。用于以下情况:' +
'1) 纯闲聊(如打招呼、问天气等);' +
'2) 用户要求修改/更正/调整/更新已有采购记录时,引导其去采购清单页面手动编辑;' +
'3) 用户有购买意图但缺少价格信息时,追问价格;' +
'4) 权限不足需要拒绝(如非管理员要求清空记录);' +
'5) 其他需要回复但不属于新建采购或查询/删除/清空的情况。',
parameters: {
type: 'object',
properties: {
reply: { type: 'string', description: '显示给用户的回复内容' }
},
required: ['reply']
}
}
};
const queryPurchasesTool = {
type: 'function',
function: {
name: 'query_purchases',
description:
'查询历史采购记录。当用户询问"买过什么/有没有买过/查一下/找找看/搜索/列表/我买过/看看"等查询历史采购的意图时调用。' +
'即使物品名听起来奇怪或可能不存在,也必须调用本工具,绝不能调用 create_purchase 或 ignore。' +
'调用后系统会搜索本地数据库并生成回答,本工具不直接返回查询结果。',
parameters: {
type: 'object',
properties: {
keywords: { type: 'string', description: '用户想查找的物品名或关键词;若查询全部记录则设为空字符串 ""' }
},
required: ['keywords']
}
}
};
const ignoreTool = {
type: 'function',
function: {
name: 'ignore',
description:
'忽略当前消息,不产生任何回复。用于纯符号、纯表情、明显不是对助手说的话等无关消息。',
parameters: { type: 'object', properties: {} }
}
};
const TOOLS = [createPurchaseTool, replyChatTool, queryPurchasesTool, ignoreTool];
module.exports = { TOOLS };
+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 dbPath = path.join(dataDir, 'xiaocai.db');
const db = new Database(dbPath); const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
// 创建基础表(如果不存在) // 关键 pragma
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// 创建基础表
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY, username TEXT PRIMARY KEY,
@@ -47,7 +50,7 @@ db.exec(`
freight REAL DEFAULT 0, freight REAL DEFAULT 0,
payment_method TEXT DEFAULT '未指定', payment_method TEXT DEFAULT '未指定',
invoice_type TEXT DEFAULT '无票', invoice_type TEXT DEFAULT '无票',
status TEXT DEFAULT '待处理', status TEXT DEFAULT '待付款',
applicant TEXT, applicant TEXT,
remarks TEXT DEFAULT '', remarks TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now','localtime')), created_at TEXT DEFAULT (datetime('now','localtime')),
@@ -75,7 +78,7 @@ db.exec(`
); );
`); `);
// 为已有数据库添加可能缺失的列(安全迁移忽略错误 // 安全迁移忽略已存在的列
const migrations = [ const migrations = [
`ALTER TABLE purchases ADD COLUMN quantity INTEGER DEFAULT 1`, `ALTER TABLE purchases ADD COLUMN quantity INTEGER DEFAULT 1`,
`ALTER TABLE purchases ADD COLUMN unit_price REAL DEFAULT 0`, `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 purchases ADD COLUMN remarks TEXT DEFAULT ''`,
`ALTER TABLE rooms ADD COLUMN white_list TEXT DEFAULT ''`, `ALTER TABLE rooms ADD COLUMN white_list TEXT DEFAULT ''`,
]; ];
for (const sql of migrations) { for (const sql of migrations) {
try { try { db.exec(sql); } catch (e) { /* 列已存在 */ }
db.exec(sql);
} catch (e) {
// 列已存在或其他错误,忽略
}
} }
// 统一存量状态值
try { db.exec("UPDATE purchases SET status = '待付款' WHERE status = '待处理'"); } catch (e) {}
module.exports = db; module.exports = db;
+193
View File
@@ -0,0 +1,193 @@
// 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) { var h = getHistory(roomId); h.push({ role: role, content: content }); if (h.length > 16) conversationHistory.set(roomId, h.slice(-8)); }
// 待处理操作
const pendingActions = new Map();
function hasPendingAction(roomId, username) { return pendingActions.has(roomId + ':' + username); }
function setPendingAction(roomId, username, action) { pendingActions.set(roomId + ':' + username, action); }
function clearPendingAction(roomId, username) { pendingActions.delete(roomId + ':' + username); }
function storeAndBroadcastText(roomId, user, text) {
const now = timestamp();
const result = db.prepare('INSERT INTO messages (room_id, user, text, timestamp) VALUES (?,?,?,?)').run(roomId, user, text, now);
const msg = { id: result.lastInsertRowid, room_id: roomId, user, text, attachments: [], timestamp: now };
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY id DESC LIMIT 1').get(roomId);
broadcastToRoom(roomId, { type: 'new_message', message: msg, room_preview: { room_id: roomId, last_message: lastMsg ? lastMsg.text : '', last_time: lastMsg ? lastMsg.timestamp : '' } });
return msg;
}
function executePendingAction(roomId, username) {
const action = pendingActions.get(roomId + ':' + username);
if (!action) return false;
clearPendingAction(roomId, username);
const now = timestamp();
try {
if (action.type === 'delete') {
const { itemName, purchaseIds } = action.data;
const delA = db.prepare('DELETE FROM purchase_attachments WHERE purchase_id = ?');
const delH = db.prepare('DELETE FROM purchase_history WHERE purchase_id = ?');
const delP = db.prepare('DELETE FROM purchases WHERE id = ?');
for (const id of purchaseIds) { delA.run(id); delH.run(id); delP.run(id); }
const reply = '✅ 已删除采购记录:' + itemName + '(共 ' + purchaseIds.length + ' 条)';
storeAndBroadcastText(roomId, '小财', reply); addToHistory(roomId, 'assistant', reply);
} else if (action.type === 'clear') {
db.prepare('DELETE FROM purchase_attachments WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
db.prepare('DELETE FROM purchase_history WHERE purchase_id IN (SELECT id FROM purchases WHERE room_id = ?)').run(roomId);
db.prepare('DELETE FROM purchases WHERE room_id = ?').run(roomId);
const reply = '✅ 已清空当前房间的所有采购数据。';
storeAndBroadcastText(roomId, '小财', reply); addToHistory(roomId, 'assistant', reply);
}
broadcastToRoom(roomId, { type: 'purchase_updated' });
return true;
} catch (e) { storeAndBroadcastText(roomId, '小财', '❌ 操作执行失败,请重试。'); return false; }
}
function handleAIResult(aiResult, roomId, username, originalText, attachments) {
const now = timestamp();
if (aiResult.action === 'ask') {
const q = aiResult.question || aiResult.reply || '请提供更多信息。';
storeAndBroadcastText(roomId, '小财', q); addToHistory(roomId, 'assistant', q); return;
}
if (aiResult.action === 'delete') {
const itemName = aiResult.delete_item;
let purchases;
if (itemName === '__AMOUNT_ZERO__') {
purchases = db.prepare('SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND amount = 0').all(roomId);
} else if (itemName === '__NULL_NAME__') {
purchases = db.prepare("SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND (item IS NULL OR item = '' OR item = 'null' OR item = 'undefined')").all(roomId);
} else {
let query = 'SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND item LIKE ?';
const params = [roomId, '%' + itemName + '%'];
if (aiResult.quantity !== undefined) { query += ' AND quantity = ?'; params.push(aiResult.quantity); }
if (aiResult.amount !== undefined) { query += ' AND amount = ?'; params.push(aiResult.amount); }
purchases = db.prepare(query).all(...params);
}
if (purchases.length === 0) {
const label = itemName === '__AMOUNT_ZERO__' ? '金额为0' : (itemName === '__NULL_NAME__' ? '名称为空' : '"' + itemName + '"');
storeAndBroadcastText(roomId, '小财', '没有找到与"' + label + '"相关的采购记录。');
return;
}
let confirmText = '⚠️ 即将删除以下 ' + purchases.length + ' 条采购记录,请回复"确认"继续:\n\n';
purchases.forEach(p => confirmText += '• ' + p.item + ' | ¥' + p.amount + ' | ' + p.status + ' | ' + p.applicant + ' | ' + p.created_at + '\n');
confirmText += '\n如果不删除,请忽略此消息。';
storeAndBroadcastText(roomId, '小财', confirmText);
addToHistory(roomId, 'assistant', confirmText);
setPendingAction(roomId, username, { type: 'delete', data: { itemName, purchaseIds: purchases.map(p => p.id) } });
return;
}
if (aiResult.action === 'clear_all') {
const count = db.prepare('SELECT COUNT(*) as count FROM purchases WHERE room_id = ?').get(roomId).count;
if (count === 0) { storeAndBroadcastText(roomId, '小财', '当前房间没有采购记录,无需清空。'); return; }
storeAndBroadcastText(roomId, '小财', '⚠️ 即将清空当前房间的 ' + count + ' 条采购数据,请回复"确认"继续,否则忽略。');
addToHistory(roomId, 'assistant', '请求清空' + count + '条记录');
setPendingAction(roomId, username, { type: 'clear', data: {} });
return;
}
if (aiResult.action === 'purchase') {
const item = aiResult.purchase_item;
if (!item || item === 'null' || item === 'undefined' || item.trim().length < 2) {
storeAndBroadcastText(roomId, '小财', '请提供具体的物品名称。');
addToHistory(roomId, 'assistant', '请提供具体的物品名称。');
return;
}
let usedRecentImage = false;
// 图片关联:由后端根据消息文本判断,不再依赖模型返回 use_recent_image。
// 用户消息提及图片/附件等词且本次未带附件时,自动关联最近的图片消息。
if (!attachments?.length && /(图片|附件|加到|存入|关联|作为附件)/.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);
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;
// 金额为 0 → AI 判断错误,拒绝创建
if (!purchase && amount === 0) {
storeAndBroadcastText(roomId, '小财', '无法创建「' + aiResult.purchase_item + '」:金额为 0,请补充价格信息。');
addToHistory(roomId, 'assistant', '金额为0,拒绝');
return;
}
const id = uuidv4();
const paymentMethod = aiResult.payment_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 + ' <a href="#" onclick="openDetail(\'' + id + '\')">查看详情</a>';
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, '添加附件:' + attachments.length + ' 个', username, now);
if (usedRecentImage) replyText = '📎 已为「' + aiResult.purchase_item + '」添加 ' + attachments.length + ' 个附件';
} else if (usedRecentImage) {
replyText = '❌ 未找到最近的图片消息,请先发送图片再试。';
}
storeAndBroadcastText(roomId, '小财', replyText);
addToHistory(roomId, 'assistant', replyText);
broadcastToRoom(roomId, { type: 'purchase_updated' });
return;
}
if (aiResult.action === 'query') {
broadcastToRoom(roomId, { type: 'query_pending', text: '🔍 正在查询采购数据,请稍候...' });
const purchaseData = db.prepare('SELECT item, amount, payment_method, status, created_at FROM purchases WHERE room_id = ? ORDER BY created_at DESC').all(roomId).map(p => p.created_at + ' ' + p.item + ' ¥' + p.amount + ' ' + (p.payment_method||'') + ' ' + p.status).join('\n');
const summaryPrompt = '根据以下采购记录,用自然语言回答用户查询"' + originalText + '"。采购记录:\n' + (purchaseData || '暂无记录');
callDeepSeekForSummary(summaryPrompt, username).then(function(reply) {
console.log('Query汇总回复:', reply.substring(0, 200));
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, userId) {
const { callDeepSeek } = require('./ai/client');
const messages = [
{ role: 'system', content: '你是一个财务助手,请根据采购记录生成简洁回复。' },
{ role: 'user', content: prompt }
];
const msg = await callDeepSeek(messages, userId, 0.3, false);
return msg && typeof msg === 'object' ? (msg.content || '') : (msg || '');
}
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 };
+172
View File
@@ -0,0 +1,172 @@
* { 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%; 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: #2a5298; 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-item:nth-child(even) {background-color: #e8f0fe;}
.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: #2a5298; margin-right: 12px; display: flex; align-items: center; justify-content: center; font-size: 18px; font-weight: 600; color: #fff; 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: #2a5298; 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: #2a5298; 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;
}
+143 -721
View File
@@ -8,111 +8,7 @@
<meta name="theme-color" content="#1e3c72"> <meta name="theme-color" content="#1e3c72">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<style> <link rel="stylesheet" href="/css/style.css">
* { 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; }
.detail-row { margin-bottom: 8px; }
</style>
</head> </head>
<body> <body>
<div class="app" id="app"> <div class="app" id="app">
@@ -124,7 +20,7 @@
<input type="password" id="login-pass" placeholder="密码"> <input type="password" id="login-pass" placeholder="密码">
<button class="primary-btn" onclick="login()" style="width:100%;">登录</button> <button class="primary-btn" onclick="login()" style="width:100%;">登录</button>
<p id="login-error" style="color:red; margin-top:8px;"></p> <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>
</div> </div>
@@ -133,19 +29,16 @@
<!-- 群聊列表页 --> <!-- 群聊列表页 -->
<div id="list-page" style="display:flex; flex-direction:column; height:100%;"> <div id="list-page" style="display:flex; flex-direction:column; height:100%;">
<div class="header"> <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"> <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"/> <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> </svg>
<h2>小财记账</h2> <h2>小财记账</h2>
</div> </div>
<div style="display:flex; gap:2px;"> <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> <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>
<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> </div>
<div class="chat-list" id="chat-list-container"></div> <div class="chat-list" id="chat-list-container"></div>
@@ -158,15 +51,40 @@
</div> </div>
</div> </div>
<!-- 聊天窗口 --> <!-- 采购清单页 -->
<div id="chat-page" class="chat-window hidden"> <div id="purchase-page" class="hidden" style="display:flex; flex-direction:column; height:100%;">
<div class="chat-header"> <div class="header">
<button class="icon-btn back-btn" onclick="showList()"> <button class="icon-btn back-btn" onclick="showList()">
<svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"></polyline></svg> <svg viewBox="0 0 24 24"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button> </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> <span class="chat-title" id="current-chat-name"></span>
<button class="icon-btn" onclick="openPurchasePanel()" title="采购清单"> <button class="icon-btn" onclick="backToPurchase()" 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> <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> </button>
</div> </div>
<div class="messages" id="messages-container"></div> <div class="messages" id="messages-container"></div>
@@ -180,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> <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> </button>
</div> </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> </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"> <div class="modal">
<button class="modal-close-btn" onclick="closeCreateModal()"> <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> <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>
@@ -206,8 +114,7 @@
</div> </div>
</div> </div>
<!-- 管理群聊弹窗 --> <div id="manage-modal" class="modal-overlay hidden" onclick="if(event.target===this)closeManageModal()">
<div id="manage-modal" class="modal-overlay hidden">
<div class="modal"> <div class="modal">
<button class="modal-close-btn" onclick="closeManageModal()"> <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> <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>
@@ -220,613 +127,128 @@
</div> </div>
</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"> <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> <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> </button>
<h3 id="detail-title"></h3> <h3 id="summary-title"></h3>
<div id="detail-content"></div> <div id="summary-content"></div>
</div>
</div>
<!-- 采购详情编辑弹窗 -->
<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 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 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>
<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>
<!-- 附件 -->
<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>
<!-- 操作历史(折叠) -->
<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>
<!-- 按钮 -->
<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> </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> <script>
const API = ''; // 版本号
let ws; (function() {
let currentRoom = null; document.getElementById('version-text').textContent = 'v3.0.1-260829';
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 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);
}
function clearTempBubble() {
if (tempBubbleTimer) { clearTimeout(tempBubbleTimer); tempBubbleTimer = 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;
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);
if (data.type === 'new_message') {
const msg = data.message;
if (currentRoom === msg.room_id) {
appendMessage(msg);
}
if (data.room_preview) {
const room = rooms.find(r => r.id === data.room_preview.room_id);
if (room) { room.last_message = data.room_preview.last_message; room.last_time = data.room_preview.last_time; renderChatList(); }
}
} else if (data.type === 'purchase_updated') {
if (currentRoom) loadPurchases();
updateSummaryPreview();
} else if (data.type === 'room_created') {
loadRooms();
} else if (data.type === 'room_updated') {
const room = rooms.find(r => r.id === data.room.id);
if (room) { room.name = data.room.name; room.white_list = data.room.white_list; renderChatList(); }
} else if (data.type === 'room_deleted') {
rooms = rooms.filter(r => r.id !== data.roomId);
renderChatList();
if (currentRoom === data.roomId) showList();
}
};
ws.onerror = (e) => console.error('WebSocket 错误', e);
ws.onclose = (event) => {
console.log('WebSocket 关闭,代码:', event.code);
if (event.code === 1000) return;
scheduleReconnect();
};
}
function scheduleReconnect() {
if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), MAX_RECONNECT_DELAY);
reconnectAttempts++;
wsReconnectTimer = setTimeout(() => { console.log('尝试重连...'); initWebSocket(); }, delay);
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
if (!ws || ws.readyState !== WebSocket.OPEN) {
if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
initWebSocket();
}
}
});
async function init() {
const token = getToken();
if (token) {
try {
const res = await fetch(API + '/api/user', { headers: { 'Authorization': `Bearer ${token}` } });
if (res.ok) {
const user = await res.json();
document.getElementById('login-page').classList.add('hidden');
document.getElementById('main-page').classList.remove('hidden');
if (localStorage.getItem('isAdmin') === 'true') {
document.getElementById('create-room-btn').classList.add('visible');
} else {
document.getElementById('create-room-btn').classList.remove('visible');
}
initWebSocket();
loadRooms();
updateSummaryPreview();
initSwipeGestures();
return;
}
} catch(e) {}
clearAuth();
}
document.getElementById('login-page').classList.remove('hidden');
document.getElementById('main-page').classList.add('hidden');
}
async function login() {
const u = document.getElementById('login-user').value;
const p = document.getElementById('login-pass').value;
const res = await fetch(API + '/api/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u, password: p })
});
if (res.ok) {
const data = await res.json();
saveAuth(data);
document.getElementById('login-page').classList.add('hidden');
document.getElementById('main-page').classList.remove('hidden');
if (data.isAdmin) {
document.getElementById('create-room-btn').classList.add('visible');
} else {
document.getElementById('create-room-btn').classList.remove('visible');
}
initWebSocket();
loadRooms();
updateSummaryPreview();
initSwipeGestures();
} else {
const err = await res.json();
document.getElementById('login-error').textContent = err.error || '登录失败';
}
}
function initSwipeGestures() {
const chatPage = document.getElementById('chat-page');
const purchaseOverlay = document.getElementById('purchase-panel-overlay');
let startX = 0, startY = 0;
function handleTouchStart(e) {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
}
function handleTouchEnd(e, target) {
if (!startX) return;
const endX = e.changedTouches[0].clientX;
const endY = e.changedTouches[0].clientY;
const diffX = endX - startX;
const diffY = endY - startY;
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 50) {
if (target === chatPage && !purchaseOverlay.classList.contains('hidden')) {
if (Math.abs(diffX) > 30) closePurchasePanel();
} else if (target === chatPage) {
if (diffX > 30) showList();
else if (diffX < -30) openPurchasePanel();
} else if (target === purchaseOverlay) {
if (Math.abs(diffX) > 30) closePurchasePanel();
}
}
startX = 0; startY = 0;
}
chatPage.addEventListener('touchstart', handleTouchStart, { passive: true });
chatPage.addEventListener('touchend', (e) => handleTouchEnd(e, chatPage), { passive: true });
purchaseOverlay.addEventListener('touchstart', handleTouchStart, { passive: true });
purchaseOverlay.addEventListener('touchend', (e) => handleTouchEnd(e, purchaseOverlay), { passive: true });
}
async function loadRooms() {
const token = getToken();
const res = await fetch(API + '/api/rooms', { headers: { 'Authorization': `Bearer ${token}` } });
if (res.status === 401) { clearAuth(); location.reload(); return; }
rooms = await res.json();
renderChatList();
}
function renderChatList() {
const container = document.getElementById('chat-list-container');
const isAdmin = localStorage.getItem('isAdmin') === 'true';
container.innerHTML = rooms.map(room => {
const lastMsg = room.last_message || '暂无消息';
const lastTime = room.last_time || '';
return `
<div class="chat-item" data-room-id="${room.id}">
<div class="avatar" onclick="openChat('${room.id}')">${room.name.charAt(0)}</div>
<div class="chat-info" onclick="openChat('${room.id}')">
<div class="chat-name">${room.name}</div>
<div class="last-msg">${escapeHtml(lastMsg)}</div>
</div>
<div class="chat-right">
<div class="chat-time" onclick="openChat('${room.id}')">${lastTime}</div>
${isAdmin ? `<div class="room-actions">
<button class="edit-room-btn" onclick="event.stopPropagation(); openManageRoom('${room.id}')" title="编辑群聊">
<svg viewBox="0 0 24 24"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
</button>
</div>` : ''}
</div>
</div>`;
}).join('');
}
async function updateSummaryPreview() {
const token = getToken();
try {
const res = await fetch(API + '/api/summary', { headers: { 'Authorization': `Bearer ${token}` } });
const summary = await res.json();
const totalItems = summary.reduce((s, r) => s + r.purchase_count, 0);
const totalAmount = summary.reduce((s, r) => s + r.total_amount, 0);
const preview = document.getElementById('summary-preview');
if (preview) preview.innerText = `${totalItems} 条,合计 ¥${totalAmount}`;
} catch(e) {}
}
async function openChat(roomId, autoOpenPurchase = false) {
currentRoom = roomId;
clearTempBubble();
document.getElementById('list-page').classList.add('hidden');
document.getElementById('chat-page').classList.remove('hidden');
const room = rooms.find(r => r.id === roomId);
document.getElementById('current-chat-name').textContent = room?.name || '';
document.getElementById('purchase-panel-overlay').classList.add('hidden');
if (autoOpenPurchase) setTimeout(() => openPurchasePanel(), 200);
const token = getToken();
const res = await fetch(API + `/api/rooms/${roomId}/messages`, { headers: { 'Authorization': `Bearer ${token}` } });
const msgs = await res.json();
renderAllMessages(msgs);
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'join', roomId }));
}
function showList() {
clearTempBubble();
document.getElementById('chat-page').classList.add('hidden');
document.getElementById('list-page').classList.remove('hidden');
currentRoom = null;
loadRooms();
updateSummaryPreview();
}
async function sendMessage() {
const input = document.getElementById('msg-input');
const text = input.value.trim();
if (!text && pendingUploads.length === 0) return;
if (!currentRoom) return;
const token = getToken();
try {
const res = await fetch(API + `/api/rooms/${currentRoom}/messages`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text || '', attachments: pendingUploads })
});
const newMsg = await res.json();
appendMessage(newMsg);
input.value = '';
pendingUploads = [];
insertTempBubble(); // 显示“输入中...”
} catch(e) { alert('发送失败,请重试'); }
}
async function handleFileSelect(event) {
const files = event.target.files;
if (!files.length) return;
const token = getToken();
const placeholderId = 'placeholder_' + Date.now();
const placeholderMsg = {
id: placeholderId, room_id: currentRoom, user: localStorage.getItem('currentUser'),
text: '图片发送中...', attachments: [], timestamp: new Date().toLocaleTimeString('zh-CN', { hour12: false }), isPlaceholder: true
};
appendMessage(placeholderMsg);
try {
for (let file of files) {
let blob = file;
if (file.size > 1 * 1024 * 1024) blob = await compressImage(file);
const formData = new FormData(); formData.append('file', blob, file.name || 'image.jpg');
const res = await fetch(API + '/api/upload', { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData });
const data = await res.json();
if (data.path) pendingUploads.push(data.path);
}
messageCache = messageCache.filter(m => m.id !== placeholderId);
const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`);
if (placeholderEl) placeholderEl.remove();
sendMessage();
} catch(e) {
messageCache = messageCache.filter(m => m.id !== placeholderId);
const placeholderEl = messagesContainer.querySelector(`[data-msg-id="${placeholderId}"]`);
if (placeholderEl) placeholderEl.remove();
alert('图片发送失败,请重试');
}
}
async function loadPurchases() {
if (!currentRoom) return;
const token = getToken();
const res = await fetch(API + `/api/rooms/${currentRoom}/purchases`, { headers: { 'Authorization': `Bearer ${token}` } });
const items = await res.json();
renderPurchasePanel(items);
}
function renderPurchasePanel(items) {
const container = document.getElementById('purchase-panel-body');
if (items.length === 0) { container.innerHTML = '<p style="text-align:center;color:#888;padding:40px;">暂无采购记录</p>'; return; }
const groups = {};
items.forEach(item => {
const month = formatMonth(item.created_at);
if (!groups[month]) groups[month] = [];
groups[month].push(item);
});
let html = '';
Object.keys(groups).sort().reverse().forEach(month => {
const monthItems = groups[month];
const monthTotal = monthItems.reduce((s, i) => s + (i.amount||0), 0);
html += `<div class="purchase-month-group"><div class="purchase-month-header"><span>${month}</span><span>¥${monthTotal}</span></div>`;
monthItems.forEach(item => {
const timeShort = item.created_at.split(' ')[1]?.substring(0,5) || '';
const dateShort = item.created_at.split(' ')[0]?.substring(5) || '';
html += `
<div class="purchase-item" onclick="openDetail('${item.id}')">
<div class="item-main"><span class="item-name">${item.item}</span><span class="item-amount">¥${item.amount}</span></div>
<div class="item-meta">
<span>${dateShort} ${timeShort}</span>
<span>×${item.quantity} ${item.invoice_type !== '无票' ? '· ' + item.invoice_type : ''}</span>
<span class="status-badge ${item.status==='已完成'?'done':''}">${item.status}</span>
</div>
</div>`;
});
html += '</div>';
});
container.innerHTML = html;
}
function formatMonth(dateStr) {
const parts = dateStr.split('/');
if (parts.length >= 2) return `${parts[0]}${parseInt(parts[1], 10)}`;
return dateStr.substring(0,7);
}
function openPurchasePanel() {
document.getElementById('purchase-panel-body').innerHTML = '<p style="text-align:center;color:#888;">加载中...</p>';
document.getElementById('purchase-panel-overlay').classList.remove('hidden');
loadPurchases();
}
function closePurchasePanel() { document.getElementById('purchase-panel-overlay').classList.add('hidden'); }
async function openDetail(pid) {
const token = getToken();
const res = await fetch(API + `/api/purchases/${pid}`, { headers: { 'Authorization': `Bearer ${token}` } });
const p = await res.json();
document.getElementById('detail-title').textContent = p.item;
let attachHtml = '';
if (p.attachments?.length) attachHtml = '<div class="attachments">' + p.attachments.map(a => `<img class="img-thumb" src="${a.file_path}" alt="${a.file_path}">`).join('') + '</div>';
const historyHtml = p.history?.map(h => `<li><span class="history-time">${h.timestamp}</span> ${h.user}: ${h.action}</li>`).join('') || '';
document.getElementById('detail-content').innerHTML = `
<div class="detail-row"><strong>数量:</strong>${p.quantity}</div>
<div class="detail-row"><strong>单价:</strong>¥${p.unit_price}</div>
<div class="detail-row"><strong>邮费:</strong>¥${p.freight}</div>
<div class="detail-row"><strong>总金额:</strong>¥${p.amount}</div>
<div class="detail-row"><strong>付款方式:</strong>${p.payment_method || '未指定'}</div>
<div class="detail-row"><strong>发票:</strong>${p.invoice_type || '无票'}</div>
<div class="detail-row"><strong>状态:</strong>${p.status}</div>
<div class="detail-row"><strong>申请人:</strong>${p.applicant}</div>
<div class="detail-row"><strong>采购时间:</strong>${p.created_at}</div>
<div class="detail-row"><strong>备注:</strong>${p.remarks || '无'}</div>
${attachHtml}
<h4 style="margin-top:12px;">操作历史</h4>
<ul class="history-list">${historyHtml}</ul>`;
document.getElementById('detail-modal').classList.remove('hidden');
}
function closeDetailModal() { document.getElementById('detail-modal').classList.add('hidden'); }
async function openSummary() {
const token = getToken();
const res = await fetch(API + '/api/summary', { headers: { 'Authorization': `Bearer ${token}` } });
const summary = await res.json();
let html = '';
if (summary.length === 0) html = '<p>暂无采购记录</p>';
else {
summary.forEach(s => {
html += `<div class="summary-card" onclick="event.stopPropagation(); closeDetailModal(); openChat('${s.room_id}', true)" style="margin:8px 0;"><h4>${s.room_name}</h4><p>${s.purchase_count} 条,合计 ¥${s.total_amount}</p></div>`;
});
}
document.getElementById('detail-title').textContent = '采购汇总';
document.getElementById('detail-content').innerHTML = html;
document.getElementById('detail-modal').classList.remove('hidden');
}
function openCreateRoom() { document.getElementById('create-modal').classList.remove('hidden'); }
function closeCreateModal() { document.getElementById('create-modal').classList.add('hidden'); }
async function confirmCreateRoom() {
const name = document.getElementById('new-room-name').value.trim();
if (!name) return alert('请输入名称');
const whiteList = document.getElementById('white-list').value.trim();
const token = getToken();
await fetch(API + '/api/rooms', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name, whiteList }) });
closeCreateModal();
loadRooms();
}
function openManageRoom(roomId) {
const room = rooms.find(r => r.id === roomId);
if (!room) return;
document.getElementById('manage-title').textContent = '编辑群聊';
document.getElementById('manage-room-name').value = room.name;
document.getElementById('manage-white-list').value = room.white_list || '';
document.getElementById('manage-modal').dataset.roomId = roomId;
document.getElementById('manage-modal').classList.remove('hidden');
}
function closeManageModal() { document.getElementById('manage-modal').classList.add('hidden'); }
async function saveRoom() {
const roomId = document.getElementById('manage-modal').dataset.roomId;
const name = document.getElementById('manage-room-name').value.trim();
const whiteList = document.getElementById('manage-white-list').value.trim();
if (!name) return alert('请输入群聊名称');
const token = getToken();
const res = await fetch(API + '/api/rooms/' + roomId, {
method: 'PUT', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name, whiteList })
});
if (res.ok) { closeManageModal(); loadRooms(); }
else { const err = await res.json(); alert(err.error || '更新失败'); }
}
async function deleteRoom() {
if (!confirm('确定要删除该群聊吗?所有消息和采购数据将被永久删除。')) return;
const roomId = document.getElementById('manage-modal').dataset.roomId;
const token = getToken();
const res = await fetch(API + '/api/rooms/' + roomId, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } });
if (res.ok) {
closeManageModal();
loadRooms();
if (currentRoom === roomId) showList();
} else { const err = await res.json(); alert(err.error || '删除失败'); }
}
document.addEventListener('input', function(e) { 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 = 'auto';
e.target.style.height = e.target.scrollHeight + 'px'; e.target.style.height = e.target.scrollHeight + 'px';
} }
}); });
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.target.id === 'msg-input' && e.key === 'Enter' && !e.ctrlKey) { e.preventDefault(); sendMessage(); } 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(); init();
</script> </script>
</body> </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';
}
}
+234
View File
@@ -0,0 +1,234 @@
// 工具函数
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(function(img) {
img.removeEventListener('click', onImageClick);
img.addEventListener('click', onImageClick);
});
document.querySelectorAll('.msg-bubble').forEach(function(bubble) {
bubble.removeEventListener('dblclick', onBubbleClick);
bubble.addEventListener('dblclick', 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 onImgThumbClick(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 showTempBubble(text) {
clearTempBubble();
var tempId = 'temp_' + Date.now();
var tempMsg = { id: tempId, user: '小财', text: text, attachments: [], timestamp: '', isTemp: true };
Store.messageCache.push(tempMsg);
messagesContainer.insertAdjacentHTML('beforeend', renderMessageHTML(tempMsg));
scrollToBottom();
}
// 双指缩放 + 拖拽
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 + '" onclick="onImgThumbClick(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();
},
};
+227
View File
@@ -0,0 +1,227 @@
// 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 === 'query_pending') {
showTempBubble(data.text);
} else if (data.type === 'clear_temp') {
clearTempBubble();
} 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;
+131
View File
@@ -0,0 +1,131 @@
// 消息路由 + AI 分析
const express = require('express');
const { authMiddleware } = require('../middleware/auth');
const { broadcastToRoomExcludeSelf, broadcastToRoom } = require('../ws');
const { buildSystemPrompt } = require('../ai/prompt');
const { validate, normalize } = require('../ai/validator');
const { callDeepSeek } = require('../ai/client');
const { TOOLS } = require('../ai/tools');
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) {
var responses = Array.isArray(aiResponse) ? aiResponse : [aiResponse];
var allIgnore = responses.every(function(r) { return r.action === 'ignore'; });
responses.forEach(function(r) {
if (r.action !== 'ignore') handleAIResult(r, roomId, username, text || '', attachments || []);
});
if (allIgnore) broadcastToRoom(roomId, { type: 'clear_temp' });
}
} catch (e) {
console.error('AI 分析失败:', e.message);
var errText = '❌ ' + (e.message || 'AI 处理失败,请稍后重试');
var errResult = db.prepare('INSERT INTO messages (room_id, user, text, timestamp) VALUES (?,?,?,?)').run(roomId, '小财', errText, timestamp());
var errMsg = { id: errResult.lastInsertRowid, room_id: roomId, user: '小财', text: errText, attachments: [], timestamp: timestamp() };
broadcastToRoom(roomId, { type: 'new_message', message: errMsg });
}
});
async function analyzeWithDeepSeek(text, historyMessages, username, isAdmin, roomId) {
var systemPrompt = buildSystemPrompt(username, isAdmin);
var messages = [
{ role: 'system', content: systemPrompt }
];
historyMessages.forEach(function(m) { messages.push(m); });
messages.push({ role: 'system', content: '当前服务器时间:' + new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }) });
const message = await callDeepSeek(messages, username, 0.1, true, TOOLS);
// 1) 工具调用路径:模型调用了 create_purchase / query_purchases / reply_chat / ignore
if (message && Array.isArray(message.tool_calls) && message.tool_calls.length) {
message.tool_calls.forEach(function(tc, i) {
const fn = tc.function || {};
console.log('🤖 AI 工具调用[' + i + ']: ' + (fn.name || '?') + ' args=' + (fn.arguments || '').substring(0, 300));
});
const results = [];
message.tool_calls.forEach(function(tc) {
const fn = tc.function || {};
const name = fn.name || '';
let args = {};
try { args = JSON.parse(fn.arguments || '{}'); } catch(e) { console.warn('⚠️ 工具参数解析失败:', name, (fn.arguments || '').substring(0, 100)); args = {}; }
if (name === 'create_purchase') {
// 工具参数已是结构化对象,补上 action 字段以复用现有 handleAIResult 的 purchase 分支
results.push(Object.assign({ action: 'purchase' }, args));
} else if (name === 'query_purchases') {
// 复用现有 query 分支(本地查库 + 二次汇总),不依赖模型返回结果
results.push({ action: 'query', keywords: args.keywords || '' });
} else if (name === 'reply_chat') {
results.push({ action: 'chat', reply: args.reply || '好的。' });
} else {
// ignore 或未知工具 → 忽略
console.warn('⚠️ 未识别的工具名:', name);
results.push({ action: 'ignore' });
}
});
return results;
}
// 2) 纯文本/JSON 路径:query / delete / clear_all 等按旧逻辑解析
const content = (message && typeof message === 'object') ? (message.content || '') : (message || '');
console.log('🤖 AI 返回:', content.substring(0, 200));
try {
var cleaned = content.replace(/```json|```/g, '').trim();
if (!cleaned) { console.warn('⚠️ AI 返回空内容'); return { action: 'ignore' }; }
var parsed = JSON.parse(cleaned);
// 支持批量:AI 可能返回数组
if (Array.isArray(parsed)) {
parsed.forEach(function(item, i) {
normalize(item);
var v = validate(item);
if (!v.valid) console.warn('⚠️ AI 返回[' + i + ']校验失败:', v.error);
});
} else {
normalize(parsed);
var 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 -492
View File
@@ -1,526 +1,61 @@
// 入口文件 — 组装所有模块
const express = require('express'); const express = require('express');
const http = require('http'); const http = require('http');
const { WebSocketServer } = require('ws'); const { WebSocketServer } = require('ws');
const jwt = require('jsonwebtoken');
const multer = require('multer'); const multer = require('multer');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const db = require('./db'); const db = require('./db');
const wsModule = require('./ws');
const { authMiddleware } = require('./middleware/auth');
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
const wss = new WebSocketServer({ server }); const wss = new WebSocketServer({ server });
// 环境变量 // 中间件
const USERS = {}; app.use(express.json());
(process.env.USERS || '').split(',').forEach(u => { app.use(express.static(path.join(__dirname, 'public')));
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();
// 时区 // 上传目录
const TIMEZONE = 'Asia/Shanghai'; const uploadsDir = path.join(__dirname, 'data', 'uploads');
function timestamp() {
return new Date().toLocaleString('zh-CN', { timeZone: TIMEZONE, hour12: false });
}
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';
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
app.use('/uploads', express.static(uploadsDir));
// Multer 配置
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: uploadsDir, destination: uploadsDir,
filename: (req, file, cb) => { filename: (req, file, cb) => {
const ext = path.extname(file.originalname) || '.jpg'; const ext = path.extname(file.originalname);
const safeName = Date.now() + '-' + Math.random().toString(36).substring(2, 8) + ext; const safeName = uuidv4().replace(/-/g, '').substring(0, 12);
cb(null, safeName); cb(null, safeName + ext);
} }
}); });
const upload = multer({ const upload = multer({
storage, storage,
limits: { fileSize: 5 * 1024 * 1024 }, limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (req, file, cb) => { 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)); cb(null, allowed.includes(file.mimetype));
} }
}); });
app.use(express.json()); // 路由
app.use(express.static(path.join(__dirname, 'public'))); app.use('/api', require('./routes/auth'));
app.use('/uploads', express.static(uploadsDir)); 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) => { app.post('/api/upload', authMiddleware, upload.single('file'), (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 timestamp 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 timestamp ASC').all(req.params.roomId);
res.json(msgs);
});
app.post('/api/rooms/:roomId/messages', auth, async (req, res) => {
const { text, attachments } = req.body;
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 timestamp 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) => {
if (!req.file) return res.status(400).json({ error: '请上传图片' }); if (!req.file) return res.status(400).json({ error: '请上传图片' });
res.json({ path: '/uploads/' + req.file.filename }); 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 timestamp 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 // WebSocket
const clients = new Map(); wsModule.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); const PORT = process.env.PORT || 3000;
clients.forEach((info, ws) => { server.listen(PORT, () => console.log(`Server running on port ${PORT}`));
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 timestamp 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__"
- 删除物品名为空nullundefined 的记录delete_item="__NULL_NAME__"
- 不要再返回 action="ask" 来处理删除请求只要确定是删除意图就必须返回 action="delete" 并填好 delete_item
- 若用户描述的是特定记录名字叫XXX的 delete_item 填那个物品名
附件补充规则
- 如果用户发送图片并明确说"这是XX的图片""作为附件存到XX采购里"
你只需返回 purchase_item XX**绝对不要**返回 quantityunit_priceamountfreightpayment_method 等任何字段
- 当消息中只有图片且文本明确指出"这是XX的图片"提取 purchase_item XX其余字段全部省略
- 如果用户要求"把刚才/之前的图片作为附件存到XX采购"你可以返回 {"action":"purchase","purchase_item":"XX","use_recent_image":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 {
purchases = db.prepare('SELECT id, item, amount, payment_method, status, applicant, created_at FROM purchases WHERE room_id = ? AND item LIKE ?').all(roomId, `%${itemName}%`);
}
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') {
// 引用最近图片:用户要求把之前的图片关联到某采购
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 timestamp 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 = aiResult.created_time || now;
let 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, 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); });
if (usedRecentImage) {
replyText = `📎 已为「${aiResult.purchase_item}」关联 ${attachments.length} 个附件`;
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, `添加附件:${attachments.length}`, username, now);
console.log('✅ usedRecentImage 完成, 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}`));
+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 };