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