初始化小财项目
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/data/
|
||||||
|
/.env
|
||||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
xiaocai:
|
||||||
|
build: ./server
|
||||||
|
restart: unless-stopped
|
||||||
|
expose:
|
||||||
|
- "3000" # 仅内网暴露
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data # 持久化数据
|
||||||
|
- ./server/public:/app/public
|
||||||
|
environment:
|
||||||
|
- USERS=${USERS}
|
||||||
|
- ADMINS=${ADMINS}
|
||||||
|
- DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY}
|
||||||
|
- PORT=3000
|
||||||
|
- VIRTUAL_HOST=ai.foresh.com
|
||||||
|
- VIRTUAL_PORT=3000
|
||||||
|
- CERT_NAME=default
|
||||||
|
networks:
|
||||||
|
- my-app-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
my-app-network:
|
||||||
|
external: true
|
||||||
7
server/Dockerfile
Normal file
7
server/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json .
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
78
server/db.js
Normal file
78
server/db.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// 确保数据目录存在
|
||||||
|
const dataDir = '/app/data';
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = path.join(dataDir, 'xiaocai.db');
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
|
||||||
|
// 启用 WAL 模式提升并发
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
|
// 创建表
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
is_admin INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
white_list TEXT DEFAULT '', -- 逗号分隔用户名,空表示所有人
|
||||||
|
created_at TEXT DEFAULT (datetime('now','localtime'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
text TEXT,
|
||||||
|
attachments TEXT, -- JSON array of file paths
|
||||||
|
timestamp TEXT DEFAULT (datetime('now','localtime')),
|
||||||
|
FOREIGN KEY(room_id) REFERENCES rooms(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS purchases (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
item TEXT,
|
||||||
|
amount REAL,
|
||||||
|
method TEXT,
|
||||||
|
status TEXT DEFAULT '待处理',
|
||||||
|
applicant TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now','localtime')),
|
||||||
|
ai_notes TEXT,
|
||||||
|
FOREIGN KEY(room_id) REFERENCES rooms(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
purchase_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
timestamp TEXT DEFAULT (datetime('now','localtime')),
|
||||||
|
attachments TEXT,
|
||||||
|
FOREIGN KEY(purchase_id) REFERENCES purchases(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS purchase_attachments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
purchase_id TEXT NOT NULL,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
uploaded_by TEXT NOT NULL,
|
||||||
|
timestamp TEXT DEFAULT (datetime('now','localtime')),
|
||||||
|
FOREIGN KEY(purchase_id) REFERENCES purchases(id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
module.exports = db;
|
||||||
14
server/package.json
Normal file
14
server/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "xiaocai-ai",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "AI-powered internal chat with automatic purchase tracking",
|
||||||
|
"main": "server.js",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"ws": "^8.13.0",
|
||||||
|
"better-sqlite3": "^9.4.3",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"jsonwebtoken": "^9.0.0",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
675
server/public/index.html
Normal file
675
server/public/index.html
Normal file
@@ -0,0 +1,675 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<title>小财记账</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<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: #1a1a1a; color: #fff; padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
.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-time { font-size: 12px; color: #aaa; margin-left: 8px; white-space: nowrap; }
|
||||||
|
|
||||||
|
.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: #1a1a1a; color: #fff; padding: 12px 16px; display: flex; align-items: center; }
|
||||||
|
.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; }
|
||||||
|
.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; height: 40px; 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: #1a1a1a; color: #fff; padding: 12px 16px; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.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; }
|
||||||
|
.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; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app" id="app">
|
||||||
|
<!-- 登录 -->
|
||||||
|
<div id="login-page" class="modal-overlay">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>登录小财记账</h2>
|
||||||
|
<input type="text" id="login-user" placeholder="用户名">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主界面 -->
|
||||||
|
<div id="main-page" class="hidden" style="display:flex; flex-direction:column; height:100%;">
|
||||||
|
<!-- 群聊列表页 -->
|
||||||
|
<div id="list-page" style="display:flex; flex-direction:column; height:100%;">
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<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>
|
||||||
|
<button class="icon-btn" id="create-room-btn" style="display:none;" 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>
|
||||||
|
</div>
|
||||||
|
<div class="chat-list" id="chat-list-container"></div>
|
||||||
|
<div class="summary-card" onclick="openSummary()">
|
||||||
|
<div>
|
||||||
|
<h4>采购汇总</h4>
|
||||||
|
<div class="summary-preview" id="summary-preview">加载中...</div>
|
||||||
|
</div>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" stroke="#888" fill="none" stroke-width="2"><polyline points="9 18 15 12 9 6"></polyline></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 聊天窗口 -->
|
||||||
|
<div id="chat-page" class="chat-window hidden">
|
||||||
|
<div class="chat-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="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>
|
||||||
|
</div>
|
||||||
|
<div class="messages" id="messages-container"></div>
|
||||||
|
<div class="input-area">
|
||||||
|
<label class="file-upload-btn" title="上传图片">
|
||||||
|
<input type="file" id="file-input" accept="image/*" multiple style="display:none;" onchange="handleFileSelect(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="msg-input" placeholder="输入消息..." rows="1"></textarea>
|
||||||
|
<button class="send-btn" onclick="sendMessage()">
|
||||||
|
<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 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>
|
||||||
|
</button>
|
||||||
|
<h3>新建项目群聊</h3>
|
||||||
|
<input type="text" id="new-room-name" placeholder="群聊名称">
|
||||||
|
<input type="text" id="white-list" placeholder="白名单成员(逗号分隔,留空全员可见)">
|
||||||
|
<button class="primary-btn" onclick="confirmCreateRoom()">创建</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 采购详情弹窗 -->
|
||||||
|
<div id="detail-modal" class="modal-overlay hidden">
|
||||||
|
<div class="modal">
|
||||||
|
<button class="modal-close-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>
|
||||||
|
<h3 id="detail-title"></h3>
|
||||||
|
<div id="detail-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = '';
|
||||||
|
let ws;
|
||||||
|
let currentRoom = null;
|
||||||
|
let rooms = [];
|
||||||
|
let pendingUploads = [];
|
||||||
|
const sentMsgIds = new Set(); // 已发送成功的消息 ID 集合,防止重复
|
||||||
|
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
let 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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').style.display = 'flex';
|
||||||
|
}
|
||||||
|
initWebSocket();
|
||||||
|
loadRooms();
|
||||||
|
updateSummaryPreview();
|
||||||
|
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').style.display = 'flex';
|
||||||
|
initWebSocket();
|
||||||
|
loadRooms();
|
||||||
|
updateSummaryPreview();
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
document.getElementById('login-error').textContent = err.error || '登录失败';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket 重连管理
|
||||||
|
let wsReconnectTimer = null;
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
const MAX_RECONNECT_DELAY = 30000;
|
||||||
|
|
||||||
|
function initWebSocket() {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return;
|
||||||
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
ws = new WebSocket(`${protocol}//${location.host}?token=${token}`);
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log('WebSocket 已连接');
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
if (currentRoom) {
|
||||||
|
ws.send(JSON.stringify({ type: 'join', roomId: currentRoom }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.type === 'new_message') {
|
||||||
|
const msg = data.message;
|
||||||
|
// 去重:如果消息 ID 已在发送确认集合中,忽略广播
|
||||||
|
if (sentMsgIds.has(msg.id)) return;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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 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');
|
||||||
|
container.innerHTML = rooms.map(room => {
|
||||||
|
const lastMsg = room.last_message || '暂无消息';
|
||||||
|
const lastTime = room.last_time || '';
|
||||||
|
return `
|
||||||
|
<div class="chat-item" onclick="openChat('${room.id}')">
|
||||||
|
<div class="avatar">${room.name.charAt(0)}</div>
|
||||||
|
<div class="chat-info">
|
||||||
|
<div class="chat-name">${room.name}</div>
|
||||||
|
<div class="last-msg">${escapeHtml(lastMsg)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-time">${lastTime}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
document.getElementById('messages-container').innerHTML = '';
|
||||||
|
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(() => {
|
||||||
|
document.getElementById('purchase-panel-body').innerHTML = '<p style="text-align:center;color:#888;">加载中...</p>';
|
||||||
|
document.getElementById('purchase-panel-overlay').classList.remove('hidden');
|
||||||
|
loadPurchases();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(API + `/api/rooms/${roomId}/messages`, { headers: { 'Authorization': `Bearer ${token}` } });
|
||||||
|
const msgs = await res.json();
|
||||||
|
window._messagesCache = msgs;
|
||||||
|
renderMessages(msgs);
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'join', roomId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showList() {
|
||||||
|
document.getElementById('chat-page').classList.add('hidden');
|
||||||
|
document.getElementById('list-page').classList.remove('hidden');
|
||||||
|
currentRoom = null;
|
||||||
|
loadRooms();
|
||||||
|
updateSummaryPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessages(msgs) {
|
||||||
|
const container = document.getElementById('messages-container');
|
||||||
|
const currentUser = localStorage.getItem('currentUser');
|
||||||
|
container.innerHTML = msgs.map(msg => {
|
||||||
|
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" onload="scrollToBottom()">`;
|
||||||
|
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>`;
|
||||||
|
}).join('');
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
const container = document.getElementById('messages-container');
|
||||||
|
if (container) container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMessage(msg) {
|
||||||
|
if (!window._messagesCache) window._messagesCache = [];
|
||||||
|
window._messagesCache.push(msg);
|
||||||
|
renderMessages(window._messagesCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送消息(文本/图片统一入口)
|
||||||
|
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();
|
||||||
|
sentMsgIds.add(newMsg.id); // 记录已发送,避免广播重复
|
||||||
|
appendMessage(newMsg);
|
||||||
|
input.value = '';
|
||||||
|
pendingUploads = [];
|
||||||
|
} catch(e) {
|
||||||
|
alert('发送失败,请重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选图后自动压缩上传,然后调用 sendMessage,确保占位
|
||||||
|
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
|
||||||
|
};
|
||||||
|
if (!window._messagesCache) window._messagesCache = [];
|
||||||
|
window._messagesCache.push(placeholderMsg);
|
||||||
|
renderMessages(window._messagesCache);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// 上传完毕,移除占位并发送正式消息
|
||||||
|
window._messagesCache = window._messagesCache.filter(m => m.id !== placeholderId);
|
||||||
|
sendMessage(); // 会自动带上 pendingUploads
|
||||||
|
} catch(e) {
|
||||||
|
// 失败则移除占位
|
||||||
|
window._messagesCache = window._messagesCache.filter(m => m.id !== placeholderId);
|
||||||
|
renderMessages(window._messagesCache);
|
||||||
|
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.applicant || ''} / ${item.method || ''}</span>
|
||||||
|
<span class="status-badge ${item.status==='已完成'?'done':''}">${item.status}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonth(dateStr) {
|
||||||
|
const parts = dateStr.split('/');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const year = parts[0];
|
||||||
|
const month = parseInt(parts[1], 10);
|
||||||
|
return `${year}年${month}月`;
|
||||||
|
}
|
||||||
|
return dateStr.substring(0,7);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPurchasePanel() {
|
||||||
|
document.getElementById('purchase-panel-body').innerHTML = '<p style="text-align:center;color:#888;">加载中...</p>';
|
||||||
|
document.getElementById('purchase-panel-overlay').classList.remove('hidden');
|
||||||
|
loadPurchases();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePurchasePanel() {
|
||||||
|
document.getElementById('purchase-panel-overlay').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(pid) {
|
||||||
|
const token = getToken();
|
||||||
|
const res = await fetch(API + `/api/purchases/${pid}`, { headers: { 'Authorization': `Bearer ${token}` } });
|
||||||
|
const p = await res.json();
|
||||||
|
document.getElementById('detail-title').textContent = p.item;
|
||||||
|
let attachHtml = '';
|
||||||
|
if (p.attachments?.length) {
|
||||||
|
attachHtml = '<div class="attachments">' + p.attachments.map(a => `<img class="img-thumb" src="${a.file_path}" alt="${a.file_path}">`).join('') + '</div>';
|
||||||
|
}
|
||||||
|
const historyHtml = p.history?.map(h => `<li><span class="history-time">${h.timestamp}</span> ${h.user}: ${h.action}</li>`).join('') || '';
|
||||||
|
document.getElementById('detail-content').innerHTML = `
|
||||||
|
<p><strong>金额:</strong>¥${p.amount} | <strong>方式:</strong>${p.method}</p>
|
||||||
|
<p><strong>申请人:</strong>${p.applicant}</p>
|
||||||
|
${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();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.target.id === 'msg-input' && e.key === 'Enter' && !e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
396
server/server.js
Normal file
396
server/server.js
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
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 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();
|
||||||
|
|
||||||
|
// 初始化用户
|
||||||
|
const insertUser = db.prepare('INSERT OR IGNORE INTO users (username, password, is_admin) VALUES (?, ?, ?)');
|
||||||
|
for (const [username, password] of Object.entries(USERS)) {
|
||||||
|
const isAdmin = ADMINS.includes(username) ? 1 : 0;
|
||||||
|
insertUser.run(username, password, isAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传配置
|
||||||
|
const uploadsDir = '/app/data/uploads';
|
||||||
|
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||||
|
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 upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const allowed = ['image/jpeg','image/png','image/gif'];
|
||||||
|
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.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: '登录已过期' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 rooms = db.prepare('SELECT * FROM rooms').all();
|
||||||
|
const result = rooms.map(room => {
|
||||||
|
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY timestamp DESC LIMIT 1').get(room.id);
|
||||||
|
return {
|
||||||
|
...room,
|
||||||
|
last_message: lastMsg ? lastMsg.text : '',
|
||||||
|
last_time: lastMsg ? lastMsg.timestamp : ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/rooms', auth, (req, res) => {
|
||||||
|
if (!req.user.isAdmin) return res.status(403).json({ error: '仅管理员可创建' });
|
||||||
|
const { name, whiteList } = req.body;
|
||||||
|
const id = uuidv4();
|
||||||
|
const now = new Date().toLocaleString('zh-CN', { hour12: false });
|
||||||
|
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.get('/api/rooms/:roomId/messages', auth, (req, res) => {
|
||||||
|
const msgs = db.prepare('SELECT * FROM messages WHERE room_id = ? ORDER BY timestamp ASC').all(req.params.roomId);
|
||||||
|
res.json(msgs);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送消息(统一入口)
|
||||||
|
app.post('/api/rooms/:roomId/messages', auth, (req, res) => {
|
||||||
|
const { text, attachments } = req.body;
|
||||||
|
const roomId = req.params.roomId;
|
||||||
|
const username = req.user.username;
|
||||||
|
const timestamp = new Date().toLocaleString('zh-CN', { hour12: false });
|
||||||
|
const result = db.prepare('INSERT INTO messages (room_id, user, text, attachments, timestamp) VALUES (?,?,?,?,?)').run(
|
||||||
|
roomId, username, text || '', attachments ? JSON.stringify(attachments) : null, timestamp
|
||||||
|
);
|
||||||
|
const msg = {
|
||||||
|
id: result.lastInsertRowid,
|
||||||
|
room_id: roomId,
|
||||||
|
user: username,
|
||||||
|
text: text || '',
|
||||||
|
attachments: attachments || [],
|
||||||
|
timestamp
|
||||||
|
};
|
||||||
|
// 房间最后消息预览
|
||||||
|
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY timestamp DESC LIMIT 1').get(roomId);
|
||||||
|
broadcastToRoom(roomId, {
|
||||||
|
type: 'new_message',
|
||||||
|
message: msg,
|
||||||
|
room_preview: {
|
||||||
|
room_id: roomId,
|
||||||
|
last_message: lastMsg ? lastMsg.text : '',
|
||||||
|
last_time: lastMsg ? lastMsg.timestamp : ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 加入对话历史
|
||||||
|
addToHistory(roomId, 'user', `${username}: ${text || '图片'}`);
|
||||||
|
res.json(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/upload', auth, 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 timestamp ASC').all(req.params.id);
|
||||||
|
const attachments = db.prepare('SELECT * FROM purchase_attachments WHERE purchase_id = ?').all(req.params.id);
|
||||||
|
res.json({ ...pur, history, attachments });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/summary', auth, (req, res) => {
|
||||||
|
const rooms = db.prepare('SELECT id, name FROM rooms').all();
|
||||||
|
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.amount},"${p.method||''}","${p.status}","${p.applicant}"\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;
|
||||||
|
clients.set(ws, { username, roomId: null });
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
// 不再处理 message 类型,消息通过 HTTP API 发送
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
clients.delete(ws);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本消息存储并广播(用于 AI 回复等内部消息)
|
||||||
|
function storeAndBroadcastText(roomId, user, text) {
|
||||||
|
const timestamp = new Date().toLocaleString('zh-CN', { hour12: false });
|
||||||
|
const result = db.prepare('INSERT INTO messages (room_id, user, text, timestamp) VALUES (?,?,?,?)').run(roomId, user, text, timestamp);
|
||||||
|
const msg = { id: result.lastInsertRowid, room_id: roomId, user, text, attachments: [], timestamp };
|
||||||
|
const lastMsg = db.prepare('SELECT text, timestamp FROM messages WHERE room_id = ? ORDER BY timestamp DESC LIMIT 1').get(roomId);
|
||||||
|
broadcastToRoom(roomId, {
|
||||||
|
type: 'new_message',
|
||||||
|
message: msg,
|
||||||
|
room_preview: {
|
||||||
|
room_id: roomId,
|
||||||
|
last_message: lastMsg ? lastMsg.text : '',
|
||||||
|
last_time: lastMsg ? lastMsg.timestamp : ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对话历史
|
||||||
|
const 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 处理相关函数(保持不变)
|
||||||
|
async function analyzeWithDeepSeek(text, historyMessages, username) {
|
||||||
|
const systemPrompt = `你是智能财务助手“小财”。结合对话历史理解用户意图。只返回JSON。
|
||||||
|
|
||||||
|
意图分类:
|
||||||
|
- 采购/付款/发票相关:action="purchase",提取purchase_item, amount, method, status, applicant, created_time(可选,根据聊天中的时间推断,格式yyyy/MM/dd HH:mm:ss)
|
||||||
|
- 查询汇总:action="query"
|
||||||
|
- 聊天:action="chat",reply简短回复
|
||||||
|
- 删除:action="delete",delete_item物品名
|
||||||
|
- 忽略:action="ignore"
|
||||||
|
|
||||||
|
采购规则:
|
||||||
|
- 金额缺失=0,方式缺失="未指定",申请人缺失="${username}"。
|
||||||
|
- 状态:已付→已采购,发票→发票已收,否则待采购。
|
||||||
|
- 用户补充信息时更新已有记录。
|
||||||
|
- 时间提取:如果用户提到具体时间(如“6月30日”),请设置created_time字段,否则留空。`;
|
||||||
|
|
||||||
|
const messages = [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
...historyMessages.slice(-30),
|
||||||
|
{ role: 'user', content: `${username}: ${text}` }
|
||||||
|
];
|
||||||
|
|
||||||
|
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();
|
||||||
|
return JSON.parse(data.choices[0].message.content.replace(/```json|```/g, '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAIResult(aiResult, roomId, username, originalText, attachments) {
|
||||||
|
const now = new Date().toLocaleString('zh-CN', { hour12: false });
|
||||||
|
|
||||||
|
if (aiResult.action === 'ask') {
|
||||||
|
storeAndBroadcastText(roomId, '小财', aiResult.question);
|
||||||
|
addToHistory(roomId, 'assistant', aiResult.question);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (aiResult.action === 'delete') {
|
||||||
|
const itemName = aiResult.delete_item;
|
||||||
|
const purchases = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ?').all(roomId, `%${itemName}%`);
|
||||||
|
if (purchases.length === 0) {
|
||||||
|
storeAndBroadcastText(roomId, '小财', `没有找到与“${itemName}”相关的采购记录。`);
|
||||||
|
addToHistory(roomId, 'assistant', `没有找到与“${itemName}”相关的采购记录。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deleteAttachments = db.prepare('DELETE FROM purchase_attachments WHERE purchase_id = ?');
|
||||||
|
const deleteHistory = db.prepare('DELETE FROM purchase_history WHERE purchase_id = ?');
|
||||||
|
const deletePurchase = db.prepare('DELETE FROM purchases WHERE id = ?');
|
||||||
|
for (const p of purchases) {
|
||||||
|
deleteAttachments.run(p.id);
|
||||||
|
deleteHistory.run(p.id);
|
||||||
|
deletePurchase.run(p.id);
|
||||||
|
}
|
||||||
|
const reply = `已删除采购记录:${itemName}(共 ${purchases.length} 条)`;
|
||||||
|
storeAndBroadcastText(roomId, '小财', reply);
|
||||||
|
addToHistory(roomId, 'assistant', reply);
|
||||||
|
broadcastToRoom(roomId, { type: 'purchase_updated' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (aiResult.action === 'purchase') {
|
||||||
|
const createdTime = aiResult.created_time || now;
|
||||||
|
let purchase = db.prepare('SELECT * FROM purchases WHERE room_id = ? AND item LIKE ? AND status != ?').get(roomId, `%${aiResult.purchase_item}%`, '已完成');
|
||||||
|
let replyText = '';
|
||||||
|
if (!purchase) {
|
||||||
|
const id = uuidv4();
|
||||||
|
db.prepare('INSERT INTO purchases (id, room_id, item, amount, method, status, applicant, created_at) VALUES (?,?,?,?,?,?,?,?)').run(
|
||||||
|
id, roomId, aiResult.purchase_item, aiResult.amount, aiResult.method, aiResult.status || '待处理', aiResult.applicant || username, createdTime
|
||||||
|
);
|
||||||
|
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(id, 'AI 创建采购条目', '小财', now);
|
||||||
|
replyText = `✅ 已记录采购:${aiResult.purchase_item},金额 ¥${aiResult.amount},状态 ${aiResult.status || '待处理'}`;
|
||||||
|
purchase = { id };
|
||||||
|
} else {
|
||||||
|
db.prepare('UPDATE purchases SET amount = ?, method = ?, status = ?, updated_at = ? WHERE id = ?').run(
|
||||||
|
aiResult.amount, aiResult.method, aiResult.status, now, purchase.id
|
||||||
|
);
|
||||||
|
db.prepare('INSERT INTO purchase_history (purchase_id, action, user, timestamp) VALUES (?,?,?,?)').run(purchase.id, `更新采购信息(金额:${aiResult.amount},状态:${aiResult.status})`, username, now);
|
||||||
|
replyText = `🔄 已更新采购「${aiResult.purchase_item}」:金额 ¥${aiResult.amount},状态 ${aiResult.status}`;
|
||||||
|
}
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
storeAndBroadcastText(roomId, '小财', replyText);
|
||||||
|
addToHistory(roomId, 'assistant', replyText);
|
||||||
|
broadcastToRoom(roomId, { type: 'purchase_updated' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (aiResult.action === 'query') {
|
||||||
|
const purchaseData = db.prepare('SELECT item, amount, method, status FROM purchases WHERE room_id = ? ORDER BY created_at DESC').all(roomId)
|
||||||
|
.map(p => `${p.item} ¥${p.amount} ${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') {
|
||||||
|
const reply = aiResult.reply || '好的,有什么需要帮助的吗?';
|
||||||
|
storeAndBroadcastText(roomId, '小财', reply);
|
||||||
|
addToHistory(roomId, 'assistant', 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}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user