feat: MC Report System - MySQL + Express + Vanilla JS SPA
This commit is contained in:
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules/
|
||||||
|
data/config.json
|
||||||
|
data/uploads/*
|
||||||
|
data/*.db
|
||||||
|
data/*.db-*
|
||||||
|
*.log
|
||||||
|
*.log
|
||||||
|
o.log
|
||||||
|
e.log
|
||||||
|
err.log
|
||||||
|
out.log
|
||||||
35
AGENTS.md
Normal file
35
AGENTS.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Project Rules
|
||||||
|
|
||||||
|
## Shell & Process Rules
|
||||||
|
|
||||||
|
- **NEVER run `cmd /c` or `cmd.exe` directly.** Use `Invoke-WebRequest`, `Invoke-RestMethod`, `Start-Process`, or direct `node` commands via PowerShell instead.
|
||||||
|
- **NEVER run long-running processes (e.g. `node server.js`) in the foreground.** Always use `Start-Process -NoNewWindow` without `-Wait` to background them.
|
||||||
|
- **NEVER run long-running processes (e.g. `node server.js`) in the foreground.** Always use `Start-Process -NoNewWindow` without `-Wait` to background them.
|
||||||
|
- **Prefer `Invoke-WebRequest` / `Invoke-RestMethod`** over `curl.exe` for HTTP testing.
|
||||||
|
- When backgrounding a server process, redirect stdout/stderr to log files: `-RedirectStandardOutput "out.log" -RedirectStandardError "err.log"`.
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
This is a **Game Player Complaint/Suggestion/Appeal System** (MC Report System) built with:
|
||||||
|
- **Backend**: Node.js + Express + **MySQL** (mysql2)
|
||||||
|
- **Frontend**: Vanilla JS SPA with hash-based routing
|
||||||
|
- **Auth**: JWT with role-based access (owner/admin/player)
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
- `backend/db.js` — MySQL connection pool + schema init + config persistence
|
||||||
|
- `backend/server.js` — Express entry point, checks install status, conditional route loading
|
||||||
|
- `backend/routes/tickets.js` — Core ticket logic (report/suggestion/appeal)
|
||||||
|
- `backend/routes/install.js` — Installation wizard API
|
||||||
|
- `public/js/app.js` — Frontend router
|
||||||
|
- `data/config.json` — MySQL connection config (created during install)
|
||||||
|
|
||||||
|
### Installation Flow
|
||||||
|
1. Start server: `node backend/server.js`
|
||||||
|
2. Visit `http://localhost:3100/#/install`
|
||||||
|
3. Step 1: Enter MySQL credentials, test connection
|
||||||
|
4. Step 2: Create owner account
|
||||||
|
5. Restart server to load business routes
|
||||||
|
|
||||||
|
### After making backend changes
|
||||||
|
1. Stop existing server: `Get-Process -Name "node" -ErrorAction SilentlyContinue | Stop-Process -Force`
|
||||||
|
2. Restart: `Start-Process -FilePath "node" -ArgumentList "backend\server.js" -WorkingDirectory "C:\Users\canglan\Documents\Web\mc-report" -NoNewWindow -RedirectStandardOutput "o.log" -RedirectStandardError "e.log"`
|
||||||
160
INSTALL.md
Normal file
160
INSTALL.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
# MC Report System — 部署文档
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Node.js ≥ 18 / npm ≥ 9
|
||||||
|
- MySQL ≥ 5.7(或 MariaDB ≥ 10.2)
|
||||||
|
- 任意操作系统
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 2. 启动
|
||||||
|
node backend/server.js
|
||||||
|
|
||||||
|
# 3. 浏览器打开
|
||||||
|
# → 自动跳转安装向导
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `http://localhost:3100`,将自动进入安装向导。
|
||||||
|
|
||||||
|
## 安装向导
|
||||||
|
|
||||||
|
两步完成:
|
||||||
|
|
||||||
|
**第1步 — 数据库配置**
|
||||||
|
- MySQL 主机(默认 localhost)
|
||||||
|
- 端口(默认 3306)
|
||||||
|
- 用户名 / 密码
|
||||||
|
- 数据库名(不存在则自动创建)
|
||||||
|
|
||||||
|
**第2步 — 创建服主账号**
|
||||||
|
- 站点名称、站点地址
|
||||||
|
- 服主用户名、密码、邮箱、游戏名
|
||||||
|
|
||||||
|
点击完成后配置写入 `data/config.json`,**重启服务器**即可加载业务路由。
|
||||||
|
|
||||||
|
## 生产环境
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `PORT` | 监听端口 | `3100` |
|
||||||
|
| `CORS_ORIGIN` | 允许的跨域来源 | `http://localhost:3100` |
|
||||||
|
|
||||||
|
### 启动(生产)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux / macOS
|
||||||
|
PORT=8080 CORS_ORIGIN=https://your-domain.com node backend/server.js
|
||||||
|
|
||||||
|
# Windows PowerShell
|
||||||
|
$env:PORT="8080"
|
||||||
|
$env:CORS_ORIGIN="https://your-domain.com"
|
||||||
|
node backend/server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 持久化进程(推荐 pm2)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i -g pm2
|
||||||
|
pm2 start backend/server.js --name mc-report
|
||||||
|
pm2 save
|
||||||
|
pm2 startup
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx 反代
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name report.your-domain.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3100;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置文件
|
||||||
|
|
||||||
|
安装后生成 `data/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"db_host": "localhost",
|
||||||
|
"db_port": 3306,
|
||||||
|
"db_user": "root",
|
||||||
|
"db_pass": "",
|
||||||
|
"db_name": "mc_report",
|
||||||
|
"jwt_secret": "(随机64位)",
|
||||||
|
"api_key": "(随机48位)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `jwt_secret` 安装时随机生成,用于签发登录令牌
|
||||||
|
- `api_key` 安装时随机生成,前端自动注入请求头,保护 API 接口
|
||||||
|
|
||||||
|
## 数据库
|
||||||
|
|
||||||
|
MySQL 数据库安装时自动建表。如需备份:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mysqldump -u root -p mc_report > backup_$(date +%Y%m%d).sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## 上传文件
|
||||||
|
|
||||||
|
存储在 `data/uploads/`,通过 `/api/uploads/:filename` 鉴权下载。
|
||||||
|
|
||||||
|
| 限制 | 值 |
|
||||||
|
|------|----|
|
||||||
|
| 单文件上限 | 50MB |
|
||||||
|
| 每次最多 | 5 个 |
|
||||||
|
| 允许格式 | JPG/PNG/GIF/WebP/MP4/WebM |
|
||||||
|
|
||||||
|
## 邮件配置
|
||||||
|
|
||||||
|
后台"系统设置"页面配置 SMTP。未配置时注册用户自动激活。
|
||||||
|
|
||||||
|
## Webhook
|
||||||
|
|
||||||
|
后台"通知配置"页面添加 Discord 兼容 Webhook,可选触发事件。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
mc-report/
|
||||||
|
├── backend/
|
||||||
|
│ ├── server.js
|
||||||
|
│ ├── db.js # MySQL 连接池 + 建表
|
||||||
|
│ ├── mailer.js # SMTP 邮件
|
||||||
|
│ ├── captcha.js # 算术验证码
|
||||||
|
│ ├── webhook.js # Webhook 通知
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ ├── auth.js # JWT 认证
|
||||||
|
│ │ ├── upload.js # 文件上传安全
|
||||||
|
│ │ └── security.js # 限速 + XSS + API Key
|
||||||
|
│ └── routes/
|
||||||
|
│ ├── install.js # 安装向导 API
|
||||||
|
│ ├── auth.js # 注册/登录
|
||||||
|
│ ├── tickets.js # 工单核心
|
||||||
|
│ ├── users.js # 用户管理
|
||||||
|
│ ├── settings.js # 设置 + 邮件模板
|
||||||
|
│ ├── export.js # 数据导出
|
||||||
|
│ ├── captcha.js # 验证码
|
||||||
|
│ ├── notifications.js # Webhook 配置
|
||||||
|
│ └── uploads.js # 附件下载
|
||||||
|
├── public/ # 前端 SPA
|
||||||
|
├── data/
|
||||||
|
│ ├── config.json # 数据库连接 + 密钥
|
||||||
|
│ └── uploads/ # 上传文件
|
||||||
|
├── package.json
|
||||||
|
└── INSTALL.md
|
||||||
|
```
|
||||||
40
backend/captcha.js
Normal file
40
backend/captcha.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const { getDb } = require('./db');
|
||||||
|
|
||||||
|
function generate() {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare("DELETE FROM captchas WHERE datetime(created_at) < datetime('now','localtime','-5 minutes')").run();
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const ops = ['+', '-', '\u00d7'];
|
||||||
|
const op = ops[Math.floor(Math.random() * ops.length)];
|
||||||
|
let a, b, answer;
|
||||||
|
|
||||||
|
if (op === '\u00d7') {
|
||||||
|
a = Math.floor(Math.random() * 9) + 1;
|
||||||
|
b = Math.floor(Math.random() * 9) + 1;
|
||||||
|
} else {
|
||||||
|
a = Math.floor(Math.random() * 20) + 1;
|
||||||
|
b = Math.floor(Math.random() * 10) + 1;
|
||||||
|
if (op === '-' && a < b) [a, b] = [b, a];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (op === '+') answer = a + b;
|
||||||
|
else if (op === '-') answer = a - b;
|
||||||
|
else answer = a * b;
|
||||||
|
|
||||||
|
const question = `${a} ${op} ${b} = ?`;
|
||||||
|
db.prepare('INSERT INTO captchas(id, question, answer) VALUES (?,?,?)').run(id, question, String(answer));
|
||||||
|
|
||||||
|
return { id, question };
|
||||||
|
}
|
||||||
|
|
||||||
|
function verify(id, answer) {
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.prepare("SELECT * FROM captchas WHERE id = ? AND datetime(created_at) > datetime('now','localtime','-5 minutes')").get(id);
|
||||||
|
if (!row) return false;
|
||||||
|
db.prepare('DELETE FROM captchas WHERE id = ?').run(id);
|
||||||
|
return String(answer).trim() === row.answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generate, verify };
|
||||||
217
backend/db.js
Normal file
217
backend/db.js
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const CONFIG_PATH = path.join(__dirname, '..', 'data', 'config.json');
|
||||||
|
|
||||||
|
let pool = null;
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
if (fs.existsSync(CONFIG_PATH)) {
|
||||||
|
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfig(cfg) {
|
||||||
|
const dir = path.dirname(CONFIG_PATH);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInstalled() {
|
||||||
|
return fs.existsSync(CONFIG_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPool() {
|
||||||
|
if (!pool) {
|
||||||
|
const cfg = getConfig();
|
||||||
|
if (!cfg) throw new Error('SYSTEM_NOT_INSTALLED');
|
||||||
|
pool = mysql.createPool({
|
||||||
|
host: cfg.db_host || 'localhost',
|
||||||
|
port: cfg.db_port || 3306,
|
||||||
|
user: cfg.db_user,
|
||||||
|
password: cfg.db_pass,
|
||||||
|
database: cfg.db_name,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
charset: 'utf8mb4',
|
||||||
|
timezone: '+08:00',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function query(sql, params = []) {
|
||||||
|
const p = await getPool();
|
||||||
|
const [rows, fields] = await p.execute(sql, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRow(sql, params = []) {
|
||||||
|
const rows = await query(sql, params);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initSchema(connection) {
|
||||||
|
const db = connection || await getPool();
|
||||||
|
|
||||||
|
const createIfNotExists = async (tableName, sql) => {
|
||||||
|
const [rows] = await db.execute(`SELECT COUNT(*) as c FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ?`, [tableName]);
|
||||||
|
if (rows[0].c > 0) return; // Table exists
|
||||||
|
await db.execute(sql);
|
||||||
|
};
|
||||||
|
|
||||||
|
await createIfNotExists('users', `CREATE TABLE users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
email VARCHAR(100) NOT NULL,
|
||||||
|
game_name VARCHAR(50) NOT NULL,
|
||||||
|
game_uid VARCHAR(50) NOT NULL,
|
||||||
|
role ENUM('owner','admin','player') NOT NULL DEFAULT 'player',
|
||||||
|
active TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
email_verified TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
verify_token VARCHAR(255),
|
||||||
|
verify_expires DATETIME,
|
||||||
|
reset_token VARCHAR(255),
|
||||||
|
reset_expires DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
|
||||||
|
await createIfNotExists('tickets', `CREATE TABLE tickets (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT,
|
||||||
|
type ENUM('report','suggestion','appeal') NOT NULL,
|
||||||
|
title VARCHAR(100) NOT NULL,
|
||||||
|
status ENUM('pending','processing','awaiting_info','resolved','rejected','closed') NOT NULL DEFAULT 'pending',
|
||||||
|
priority ENUM('low','medium','high','urgent') NOT NULL DEFAULT 'medium',
|
||||||
|
reporter_game_name VARCHAR(50) NOT NULL,
|
||||||
|
reporter_game_uid VARCHAR(50) NOT NULL,
|
||||||
|
target_game_name VARCHAR(50),
|
||||||
|
target_game_uid VARCHAR(50),
|
||||||
|
reason VARCHAR(500),
|
||||||
|
description TEXT,
|
||||||
|
assigned_to INT,
|
||||||
|
claim_note TEXT,
|
||||||
|
claimed_at DATETIME,
|
||||||
|
tracking_token VARCHAR(255) UNIQUE,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_type (type),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_tracking (tracking_token),
|
||||||
|
INDEX idx_assigned (assigned_to)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
|
||||||
|
await createIfNotExists('ticket_transfers', `CREATE TABLE ticket_transfers (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
ticket_id INT NOT NULL,
|
||||||
|
from_user_id INT NOT NULL,
|
||||||
|
to_user_id INT NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_ticket (ticket_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
|
||||||
|
await createIfNotExists('attachments', `CREATE TABLE attachments (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
ticket_id INT NOT NULL,
|
||||||
|
original_name VARCHAR(255) NOT NULL,
|
||||||
|
stored_name VARCHAR(255) NOT NULL,
|
||||||
|
mime_type VARCHAR(100) NOT NULL,
|
||||||
|
size INT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_ticket (ticket_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
|
||||||
|
await createIfNotExists('responses', `CREATE TABLE responses (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
ticket_id INT NOT NULL,
|
||||||
|
user_id INT,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
is_staff TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_ticket (ticket_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
|
||||||
|
await createIfNotExists('email_templates', `CREATE TABLE email_templates (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
code VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
subject VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
|
||||||
|
await createIfNotExists('notification_configs', `CREATE TABLE notification_configs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
type ENUM('webhook','email') NOT NULL DEFAULT 'webhook',
|
||||||
|
webhook_url TEXT,
|
||||||
|
events VARCHAR(500) NOT NULL DEFAULT 'all',
|
||||||
|
active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
|
||||||
|
await createIfNotExists('settings', `CREATE TABLE settings (
|
||||||
|
k VARCHAR(100) PRIMARY KEY,
|
||||||
|
v TEXT NOT NULL,
|
||||||
|
label VARCHAR(255),
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
|
||||||
|
await createIfNotExists('captchas', `CREATE TABLE captchas (
|
||||||
|
id VARCHAR(255) PRIMARY KEY,
|
||||||
|
question VARCHAR(100) NOT NULL,
|
||||||
|
answer VARCHAR(20) NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
|
||||||
|
await createIfNotExists('audit_logs', `CREATE TABLE audit_logs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT,
|
||||||
|
username VARCHAR(50),
|
||||||
|
action VARCHAR(100) NOT NULL,
|
||||||
|
entity_type VARCHAR(50),
|
||||||
|
entity_id INT,
|
||||||
|
details TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_created (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||||
|
|
||||||
|
await seedTemplates(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedTemplates(db) {
|
||||||
|
const [rows] = await db.execute('SELECT COUNT(*) as c FROM email_templates');
|
||||||
|
if (rows[0].c > 0) return;
|
||||||
|
|
||||||
|
const temps = [
|
||||||
|
['verify_email', '邮箱验证', '【{{site_name}}】请验证您的邮箱',
|
||||||
|
'<p>您好 <strong>{{username}}</strong>(游戏名: {{game_name}},UID: {{game_uid}}),</p><p>感谢注册!请点击以下链接验证邮箱:</p><p><a href="{{verify_link}}">{{verify_link}}</a></p><p>链接有效期24小时。</p><p>— {{site_name}}</p>',
|
||||||
|
'变量: {{site_name}}, {{username}}, {{game_name}}, {{game_uid}}, {{verify_link}}'],
|
||||||
|
['ticket_created', '工单创建通知', '【{{site_name}}】{{ticket_type}}已提交 #{{ticket_id}}',
|
||||||
|
'<p><strong>{{reporter_game_name}}</strong>(UID: {{reporter_game_uid}}),您的{{ticket_type}}已提交。</p><p>工单编号: <strong>#{{ticket_id}}</strong></p><p>标题: {{ticket_title}}</p><p><a href="{{tracking_link}}">查看工单</a></p><p>— {{site_name}}</p>',
|
||||||
|
'变量: {{site_name}}, {{reporter_game_name}}, {{ticket_type}}, {{ticket_id}}, {{ticket_title}}, {{tracking_link}}'],
|
||||||
|
['ticket_updated', '工单状态更新', '【{{site_name}}】工单 #{{ticket_id}} 状态更新',
|
||||||
|
'<p>工单 <strong>#{{ticket_id}}</strong> 状态已更新为 <strong>{{ticket_status}}</strong>。</p><p>标题: {{ticket_title}}</p><p><a href="{{tracking_link}}">查看详情</a></p><p>— {{site_name}}</p>',
|
||||||
|
'变量: {{site_name}}, {{ticket_id}}, {{ticket_title}}, {{ticket_status}}, {{tracking_link}}'],
|
||||||
|
['ticket_claimed', '工单被认领', '【{{site_name}}】工单 #{{ticket_id}} 已被认领',
|
||||||
|
'<p>工单 <strong>#{{ticket_id}}</strong>({{ticket_title}})已被 <strong>{{assigned_to}}</strong> 认领。</p><p>— {{site_name}}</p>',
|
||||||
|
'变量: {{site_name}}, {{ticket_id}}, {{ticket_title}}, {{assigned_to}}'],
|
||||||
|
['ticket_transferred', '工单已转交', '【{{site_name}}】工单 #{{ticket_id}} 已转交',
|
||||||
|
'<p>工单 <strong>#{{ticket_id}}</strong>({{ticket_title}})已由 {{from_user}} 转交给 {{to_user}}。</p><p>— {{site_name}}</p>',
|
||||||
|
'变量: {{site_name}}, {{ticket_id}}, {{ticket_title}}, {{from_user}}, {{to_user}}'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const t of temps) {
|
||||||
|
await db.execute('INSERT INTO email_templates(code, name, subject, body, description) VALUES (?,?,?,?,?)', t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getPool, query, getRow, initSchema, isInstalled, getConfig, saveConfig, CONFIG_PATH };
|
||||||
59
backend/mailer.js
Normal file
59
backend/mailer.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const { getPool, query, getRow, getConfig } = require('./db');
|
||||||
|
|
||||||
|
async function getSmtpConfig() {
|
||||||
|
try {
|
||||||
|
const rows = await query("SELECT k, v FROM settings WHERE k LIKE 'smtp_%'");
|
||||||
|
const cfg = {};
|
||||||
|
for (const r of rows) cfg[r.k] = r.v;
|
||||||
|
return {
|
||||||
|
host: cfg.smtp_host || '',
|
||||||
|
port: parseInt(cfg.smtp_port) || 587,
|
||||||
|
secure: cfg.smtp_secure === '1',
|
||||||
|
user: cfg.smtp_user || '',
|
||||||
|
pass: cfg.smtp_pass || '',
|
||||||
|
from: cfg.smtp_from || 'noreply@example.com',
|
||||||
|
};
|
||||||
|
} catch { return { host: '', port: 587, secure: false, user: '', pass: '', from: '' }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSiteName() {
|
||||||
|
const row = await getRow("SELECT v FROM settings WHERE k = 'site_name'");
|
||||||
|
return row ? row.v : 'MC举报系统';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSiteUrl() {
|
||||||
|
const row = await getRow("SELECT v FROM settings WHERE k = 'site_url'");
|
||||||
|
return row ? row.v : 'http://localhost:3100';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTemplate(code) {
|
||||||
|
return getRow('SELECT * FROM email_templates WHERE code = ?', [code]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplate(template, vars) {
|
||||||
|
let { subject, body } = template;
|
||||||
|
for (const [k, v] of Object.entries(vars)) {
|
||||||
|
const re = new RegExp(`\\{\\{${k}\\}\\}`, 'g');
|
||||||
|
subject = subject.replace(re, v || '');
|
||||||
|
body = body.replace(re, v || '');
|
||||||
|
}
|
||||||
|
return { subject, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEmail(to, templateCode, vars) {
|
||||||
|
try {
|
||||||
|
const cfg = await getSmtpConfig();
|
||||||
|
if (!cfg.host || !cfg.user) { console.log('[Mailer] SMTP未配置'); return false; }
|
||||||
|
const template = await getTemplate(templateCode);
|
||||||
|
if (!template) { console.log('[Mailer] 模板不存在:', templateCode); return false; }
|
||||||
|
const allVars = { ...vars, site_name: await getSiteName(), site_url: await getSiteUrl() };
|
||||||
|
const { subject, body } = renderTemplate(template, allVars);
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({ host: cfg.host, port: cfg.port, secure: cfg.secure, auth: { user: cfg.user, pass: cfg.pass } });
|
||||||
|
await transporter.sendMail({ from: `"${await getSiteName()}" <${cfg.from}>`, to, subject, html: body });
|
||||||
|
return true;
|
||||||
|
} catch (err) { console.error('[Mailer]', err.message); return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sendEmail, renderTemplate, getTemplate, getSmtpConfig, getSiteName, getSiteUrl };
|
||||||
37
backend/middleware/auth.js
Normal file
37
backend/middleware/auth.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { getConfig } = require('../db');
|
||||||
|
|
||||||
|
function getSecret() {
|
||||||
|
try {
|
||||||
|
const cfg = getConfig();
|
||||||
|
return cfg?.jwt_secret || process.env.JWT_SECRET || 'mc-dev-secret';
|
||||||
|
} catch { return process.env.JWT_SECRET || 'mc-dev-secret'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateToken(user) {
|
||||||
|
return jwt.sign({ id: user.id, username: user.username, role: user.role, game_name: user.game_name, game_uid: user.game_uid, email: user.email }, getSecret(), { expiresIn: '72h' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyToken(token) { return jwt.verify(token, getSecret()); }
|
||||||
|
|
||||||
|
function authenticate(req, res, next) {
|
||||||
|
const h = req.headers.authorization;
|
||||||
|
if (!h || !h.startsWith('Bearer ')) return res.status(401).json({ error: '请先登录' });
|
||||||
|
try { req.user = verifyToken(h.split(' ')[1]); next(); } catch { return res.status(401).json({ error: '登录已过期' }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireRole(...roles) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!req.user) return res.status(401).json({ error: '请先登录' });
|
||||||
|
if (!roles.includes(req.user.role)) return res.status(403).json({ error: '权限不足' });
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalAuth(req, res, next) {
|
||||||
|
const h = req.headers.authorization;
|
||||||
|
if (h && h.startsWith('Bearer ')) { try { req.user = verifyToken(h.split(' ')[1]); } catch {} }
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generateToken, verifyToken, authenticate, requireRole, optionalAuth };
|
||||||
145
backend/middleware/security.js
Normal file
145
backend/middleware/security.js
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
const xss = require('xss');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const CONFIG_PATH = path.join(__dirname, '..', '..', 'data', 'config.json');
|
||||||
|
|
||||||
|
function readApiKey() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(CONFIG_PATH)) {
|
||||||
|
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')).api_key || null;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedApiKey = null;
|
||||||
|
function getApiKey() {
|
||||||
|
if (cachedApiKey === null) cachedApiKey = readApiKey();
|
||||||
|
return cachedApiKey;
|
||||||
|
}
|
||||||
|
function clearApiKeyCache() { cachedApiKey = null; }
|
||||||
|
|
||||||
|
const xssOptions = {
|
||||||
|
whiteList: {},
|
||||||
|
stripIgnoreTag: true,
|
||||||
|
stripIgnoreTagBody: ['script', 'style', 'xml', 'iframe', 'object', 'embed'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function sanitize(value) {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
return xss(value, xssOptions).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeBody(req, res, next) {
|
||||||
|
if (req.body) {
|
||||||
|
for (const key of Object.keys(req.body)) {
|
||||||
|
if (typeof req.body[key] === 'string') {
|
||||||
|
req.body[key] = sanitize(req.body[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLengths(rules) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
for (const [field, maxLen] of Object.entries(rules)) {
|
||||||
|
const val = req.body[field];
|
||||||
|
if (val && typeof val === 'string' && val.length > maxLen) {
|
||||||
|
return res.status(400).json({ error: `${field} 长度不能超过 ${maxLen} 个字符` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 8,
|
||||||
|
message: { error: '登录尝试过于频繁,请1分钟后再试' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: (req) => req.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 3,
|
||||||
|
message: { error: '注册请求过于频繁,请1分钟后再试' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ticketLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 10,
|
||||||
|
message: { error: '提交过于频繁,请稍后再试' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ticketAnonLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 5,
|
||||||
|
message: { error: '匿名提交过于频繁,请登录后再试或稍后重试' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 5,
|
||||||
|
message: { error: '上传请求过于频繁' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const generalLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 300,
|
||||||
|
message: { error: '请求过于频繁' },
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const captchaLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 30,
|
||||||
|
message: { error: '验证码请求过于频繁' },
|
||||||
|
});
|
||||||
|
|
||||||
|
function apiKeyGuard(req, res, next) {
|
||||||
|
const p = req.originalUrl;
|
||||||
|
if (p === '/api/health' || p.startsWith('/api/install')) return next();
|
||||||
|
const key = getApiKey();
|
||||||
|
if (!key) return next();
|
||||||
|
if (req.headers['x-api-key'] !== key) return res.status(401).json({ error: '无效的 API 密钥' });
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function methodGuard(allowed) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!allowed.includes(req.method)) {
|
||||||
|
return res.status(405).json({ error: `不允许 ${req.method} 方法` });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sanitize,
|
||||||
|
sanitizeBody,
|
||||||
|
validateLengths,
|
||||||
|
loginLimiter,
|
||||||
|
registerLimiter,
|
||||||
|
ticketLimiter,
|
||||||
|
ticketAnonLimiter,
|
||||||
|
uploadLimiter,
|
||||||
|
generalLimiter,
|
||||||
|
captchaLimiter,
|
||||||
|
apiKeyGuard,
|
||||||
|
methodGuard,
|
||||||
|
clearApiKeyCache,
|
||||||
|
};
|
||||||
75
backend/middleware/upload.js
Normal file
75
backend/middleware/upload.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const UPLOAD_DIR = path.join(__dirname, '..', '..', 'data', 'uploads');
|
||||||
|
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const ALLOWED_MIME = {
|
||||||
|
'image/jpeg': ['.jpg', '.jpeg'],
|
||||||
|
'image/png': ['.png'],
|
||||||
|
'image/gif': ['.gif'],
|
||||||
|
'image/webp': ['.webp'],
|
||||||
|
'video/mp4': ['.mp4'],
|
||||||
|
'video/webm': ['.webm'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALLOWED_EXT = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.mp4', '.webm'];
|
||||||
|
|
||||||
|
function validateMagicBytes(buffer, mime) {
|
||||||
|
const head = buffer.slice(0, 12);
|
||||||
|
const sigs = {
|
||||||
|
'image/jpeg': () => head[0] === 0xFF && head[1] === 0xD8,
|
||||||
|
'image/png': () => head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4E && head[3] === 0x47,
|
||||||
|
'image/gif': () => head.toString('ascii', 0, 6) === 'GIF89a' || head.toString('ascii', 0, 6) === 'GIF87a',
|
||||||
|
'image/webp': () => head.toString('ascii', 0, 4) === 'RIFF' && head.toString('ascii', 8, 12) === 'WEBP',
|
||||||
|
'video/mp4': () => buffer.includes(Buffer.from('ftyp')),
|
||||||
|
'video/webm': () => head[0] === 0x1A && head[1] === 0x45 && head[2] === 0xDF && head[3] === 0xA3,
|
||||||
|
};
|
||||||
|
if (!sigs[mime]) return false;
|
||||||
|
try { return sigs[mime](); } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
cb(null, crypto.randomUUID() + ext);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function fileFilter(req, file, cb) {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
if (!ALLOWED_EXT.includes(ext)) {
|
||||||
|
return cb(new Error('不支持的文件类型,仅允许: ' + ALLOWED_EXT.join(', ')));
|
||||||
|
}
|
||||||
|
cb(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter,
|
||||||
|
limits: {
|
||||||
|
fileSize: 50 * 1024 * 1024,
|
||||||
|
files: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function finalizeUpload(req, res, next) {
|
||||||
|
if (!req.files || req.files.length === 0) return next();
|
||||||
|
for (const file of req.files) {
|
||||||
|
const buf = fs.readFileSync(file.path);
|
||||||
|
if (!ALLOWED_MIME[file.mimetype]) {
|
||||||
|
fs.unlinkSync(file.path);
|
||||||
|
return res.status(400).json({ error: `不支持的文件类型: ${file.mimetype}` });
|
||||||
|
}
|
||||||
|
if (!validateMagicBytes(buf, file.mimetype)) {
|
||||||
|
fs.unlinkSync(file.path);
|
||||||
|
return res.status(400).json({ error: '文件内容与声明类型不符,可能是恶意文件' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { upload, finalizeUpload, validateMagicBytes, UPLOAD_DIR, ALLOWED_MIME };
|
||||||
203
backend/routes/auth.js
Normal file
203
backend/routes/auth.js
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { v4: uuid } = require('uuid');
|
||||||
|
const { query, getRow } = require('../db');
|
||||||
|
const { generateToken, authenticate } = require('../middleware/auth');
|
||||||
|
const { sendEmail } = require('../mailer');
|
||||||
|
const { verify: verifyCaptcha } = require('./captcha');
|
||||||
|
const { validateLengths } = require('../middleware/security');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
async function getSiteUrl() {
|
||||||
|
const row = await getRow("SELECT v FROM settings WHERE k = 'site_url'");
|
||||||
|
return row?.v || 'http://localhost:3100';
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/register', validateLengths({
|
||||||
|
username: 20, password: 100, email: 100, game_name: 50, game_uid: 50,
|
||||||
|
}), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, password, email, game_name, game_uid, captcha_id, captcha_answer } = req.body;
|
||||||
|
|
||||||
|
if (!username || !password || !email || !game_name || !game_uid) {
|
||||||
|
return res.status(400).json({ error: '所有字段均为必填' });
|
||||||
|
}
|
||||||
|
if (username.length < 2 || username.length > 20) {
|
||||||
|
return res.status(400).json({ error: '用户名需2-20个字符' });
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
return res.status(400).json({ error: '密码至少6位' });
|
||||||
|
}
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return res.status(400).json({ error: '邮箱格式不正确' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (captcha_id && captcha_answer) {
|
||||||
|
const captchaOk = await verifyCaptcha(captcha_id, captcha_answer);
|
||||||
|
if (!captchaOk) {
|
||||||
|
return res.status(400).json({ error: '人机验证失败' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await getRow('SELECT id FROM users WHERE username = ?', [username]);
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(400).json({ error: '用户名已存在' });
|
||||||
|
}
|
||||||
|
const existingEmail = await getRow('SELECT id FROM users WHERE email = ?', [email]);
|
||||||
|
if (existingEmail) {
|
||||||
|
return res.status(400).json({ error: '邮箱已被注册' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashed = await bcrypt.hash(password, 10);
|
||||||
|
const verifyToken = uuid();
|
||||||
|
const verifyExpires = new Date(Date.now() + 24 * 3600 * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
'INSERT INTO users(username, password, email, game_name, game_uid, active, email_verified, verify_token, verify_expires) VALUES (?,?,?,?,?,?,?,?,?)',
|
||||||
|
[username, hashed, email, game_name, game_uid, 0, 0, verifyToken, verifyExpires]
|
||||||
|
);
|
||||||
|
|
||||||
|
const siteUrl = await getSiteUrl();
|
||||||
|
const sent = await sendEmail(email, 'verify_email', {
|
||||||
|
username, game_name, game_uid,
|
||||||
|
verify_link: `${siteUrl}#/verify?token=${verifyToken}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!sent) {
|
||||||
|
await query('UPDATE users SET email_verified = 1, active = 1, verify_token = NULL, verify_expires = NULL WHERE username = ?', [username]);
|
||||||
|
return res.status(201).json({ message: '注册成功!SMTP未配置,账号已自动激活,请直接登录' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({ message: '注册成功,请查收验证邮件' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/verify-email', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { token } = req.query;
|
||||||
|
if (!token) return res.status(400).json({ error: '无效的验证链接' });
|
||||||
|
|
||||||
|
const user = await getRow('SELECT * FROM users WHERE verify_token = ? AND verify_expires > NOW()', [token]);
|
||||||
|
if (!user) return res.status(400).json({ error: '验证链接无效或已过期' });
|
||||||
|
|
||||||
|
await query('UPDATE users SET email_verified = 1, active = 1, verify_token = NULL, verify_expires = NULL WHERE id = ?', [user.id]);
|
||||||
|
await query(
|
||||||
|
'INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)',
|
||||||
|
[user.id, user.username, 'verify_email', 'user', user.id, '邮箱验证通过']
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: '邮箱验证成功,现在可以登录了' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
if (!username || !password) return res.status(400).json({ error: '请输入用户名和密码' });
|
||||||
|
|
||||||
|
const user = await getRow('SELECT * FROM users WHERE username = ?', [username]);
|
||||||
|
if (!user) return res.status(401).json({ error: '用户名或密码错误' });
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(password, user.password);
|
||||||
|
if (!valid) return res.status(401).json({ error: '用户名或密码错误' });
|
||||||
|
|
||||||
|
if (!user.active) return res.status(403).json({ error: '账号未激活,请先验证邮箱' });
|
||||||
|
|
||||||
|
const token = generateToken(user);
|
||||||
|
await query(
|
||||||
|
'INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)',
|
||||||
|
[user.id, user.username, 'login', 'user', user.id, '登录系统']
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
game_name: user.game_name,
|
||||||
|
game_uid: user.game_uid,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/me', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await getRow(
|
||||||
|
'SELECT id, username, email, game_name, game_uid, role, email_verified, created_at FROM users WHERE id = ?',
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
if (!user) return res.status(404).json({ error: '用户不存在' });
|
||||||
|
res.json(user);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/profile', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const fields = {};
|
||||||
|
const values = [];
|
||||||
|
if (req.body.game_name) {
|
||||||
|
fields.game_name = req.body.game_name;
|
||||||
|
}
|
||||||
|
if (req.body.game_uid) {
|
||||||
|
fields.game_uid = req.body.game_uid;
|
||||||
|
}
|
||||||
|
if (req.body.email) {
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(req.body.email)) {
|
||||||
|
return res.status(400).json({ error: '邮箱格式不正确' });
|
||||||
|
}
|
||||||
|
fields.email = req.body.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(fields).length === 0) {
|
||||||
|
return res.status(400).json({ error: '无更新内容' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sets = Object.keys(fields).map(k => `${k} = ?`).join(', ');
|
||||||
|
await query(`UPDATE users SET ${sets} WHERE id = ?`, [...Object.values(fields), req.user.id]);
|
||||||
|
|
||||||
|
const updated = await getRow(
|
||||||
|
'SELECT id, username, email, game_name, game_uid, role FROM users WHERE id = ?',
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
const token = generateToken(updated);
|
||||||
|
res.json({ message: '更新成功', token, user: updated });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/password', authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { oldPassword, newPassword } = req.body;
|
||||||
|
if (!oldPassword || !newPassword) {
|
||||||
|
return res.status(400).json({ error: '请提供新旧密码' });
|
||||||
|
}
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
return res.status(400).json({ error: '新密码至少6位' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getRow('SELECT * FROM users WHERE id = ?', [req.user.id]);
|
||||||
|
const valid = await bcrypt.compare(oldPassword, user.password);
|
||||||
|
if (!valid) return res.status(400).json({ error: '旧密码错误' });
|
||||||
|
|
||||||
|
const hashed = await bcrypt.hash(newPassword, 10);
|
||||||
|
await query('UPDATE users SET password = ? WHERE id = ?', [hashed, req.user.id]);
|
||||||
|
res.json({ message: '密码修改成功' });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
31
backend/routes/captcha.js
Normal file
31
backend/routes/captcha.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { query } = require('../db');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await query("DELETE FROM captchas WHERE created_at < NOW() - INTERVAL 5 MINUTE");
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const ops = ['+', '-', '\u00d7'];
|
||||||
|
const op = ops[Math.floor(Math.random() * ops.length)];
|
||||||
|
let a, b;
|
||||||
|
if (op === '\u00d7') { a = Math.floor(Math.random() * 9) + 1; b = Math.floor(Math.random() * 9) + 1; }
|
||||||
|
else { a = Math.floor(Math.random() * 20) + 1; b = Math.floor(Math.random() * 10) + 1; if (op === '-' && a < b) [a, b] = [b, a]; }
|
||||||
|
const answer = op === '+' ? a + b : op === '-' ? a - b : a * b;
|
||||||
|
const question = `${a} ${op} ${b} = ?`;
|
||||||
|
await query('INSERT INTO captchas(id, question, answer) VALUES (?,?,?)', [id, question, String(answer)]);
|
||||||
|
res.json({ id, question });
|
||||||
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function verify(id, answer) {
|
||||||
|
const rows = await query("SELECT * FROM captchas WHERE id = ? AND created_at > NOW() - INTERVAL 5 MINUTE", [id]);
|
||||||
|
if (!rows.length) return false;
|
||||||
|
await query('DELETE FROM captchas WHERE id = ?', [id]);
|
||||||
|
return String(answer).trim() === rows[0].answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
module.exports.verify = verify;
|
||||||
208
backend/routes/complaints.js
Normal file
208
backend/routes/complaints.js
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { getDb } = require('../db');
|
||||||
|
const { authenticate, requireRole, optionalAuth } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', optionalAuth, (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
let query = `SELECT c.*,
|
||||||
|
p.display_name as planner_name, i.display_name as implementer_name, r.display_name as reviewer_name
|
||||||
|
FROM complaints c
|
||||||
|
LEFT JOIN users p ON c.assigned_planner_id = p.id
|
||||||
|
LEFT JOIN users i ON c.assigned_implementer_id = i.id
|
||||||
|
LEFT JOIN users r ON c.assigned_reviewer_id = r.id`;
|
||||||
|
let conditions = [];
|
||||||
|
let params = [];
|
||||||
|
|
||||||
|
const { status, priority, category, search } = req.query;
|
||||||
|
|
||||||
|
if (!req.user) {
|
||||||
|
conditions.push("c.status != 'pending'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
conditions.push('c.status = ?');
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
if (priority) {
|
||||||
|
conditions.push('c.priority = ?');
|
||||||
|
params.push(priority);
|
||||||
|
}
|
||||||
|
if (category) {
|
||||||
|
conditions.push('c.category = ?');
|
||||||
|
params.push(category);
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
conditions.push("(c.title LIKE ? OR c.description LIKE ? OR c.player_name LIKE ?)");
|
||||||
|
const s = `%${search}%`;
|
||||||
|
params.push(s, s, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.user && req.user.role === 'planner') {
|
||||||
|
conditions.push('(c.assigned_planner_id = ? OR c.assigned_planner_id IS NULL)');
|
||||||
|
params.push(req.user.id);
|
||||||
|
}
|
||||||
|
if (req.user && req.user.role === 'implementer') {
|
||||||
|
conditions.push('c.assigned_implementer_id = ?');
|
||||||
|
params.push(req.user.id);
|
||||||
|
}
|
||||||
|
if (req.user && req.user.role === 'reviewer') {
|
||||||
|
conditions.push('c.assigned_reviewer_id = ?');
|
||||||
|
params.push(req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
query += ' WHERE ' + conditions.join(' AND ');
|
||||||
|
}
|
||||||
|
query += ' ORDER BY c.updated_at DESC';
|
||||||
|
|
||||||
|
const complaints = db.prepare(query).all(...params);
|
||||||
|
res.json(complaints);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/stats', authenticate, (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const total = db.prepare('SELECT COUNT(*) as count FROM complaints').get();
|
||||||
|
const byStatus = db.prepare('SELECT status, COUNT(*) as count FROM complaints GROUP BY status').all();
|
||||||
|
const byPriority = db.prepare('SELECT priority, COUNT(*) as count FROM complaints GROUP BY priority').all();
|
||||||
|
const today = db.prepare("SELECT COUNT(*) as count FROM complaints WHERE date(created_at) = date('now','localtime')").get();
|
||||||
|
|
||||||
|
let myStats = {};
|
||||||
|
if (['planner', 'implementer', 'reviewer'].includes(req.user.role)) {
|
||||||
|
const roleMap = { planner: 'assigned_planner_id', implementer: 'assigned_implementer_id', reviewer: 'assigned_reviewer_id' };
|
||||||
|
const col = roleMap[req.user.role];
|
||||||
|
myStats.total = db.prepare(`SELECT COUNT(*) as count FROM complaints WHERE ${col} = ?`).get(req.user.id).count;
|
||||||
|
myStats.pending = db.prepare(`SELECT COUNT(*) as count FROM complaints WHERE ${col} = ? AND status NOT IN ('resolved','closed','rejected')`).get(req.user.id).count;
|
||||||
|
myStats.completed = db.prepare(`SELECT COUNT(*) as count FROM complaints WHERE ${col} = ? AND status IN ('resolved','closed')`).get(req.user.id).count;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ total: total.count, byStatus, byPriority, today: today.count, myStats });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', optionalAuth, (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const complaint = db.prepare(`SELECT c.*,
|
||||||
|
p.display_name as planner_name, i.display_name as implementer_name, r.display_name as reviewer_name
|
||||||
|
FROM complaints c
|
||||||
|
LEFT JOIN users p ON c.assigned_planner_id = p.id
|
||||||
|
LEFT JOIN users i ON c.assigned_implementer_id = i.id
|
||||||
|
LEFT JOIN users r ON c.assigned_reviewer_id = r.id
|
||||||
|
WHERE c.id = ?`).get(req.params.id);
|
||||||
|
if (!complaint) return res.status(404).json({ error: '投诉不存在' });
|
||||||
|
|
||||||
|
const responses = db.prepare(`SELECT r.*, u.display_name as user_name, u.role as user_role
|
||||||
|
FROM responses r LEFT JOIN users u ON r.user_id = u.id
|
||||||
|
WHERE r.complaint_id = ? ORDER BY r.created_at ASC`).all(req.params.id);
|
||||||
|
complaint.responses = responses;
|
||||||
|
|
||||||
|
if (complaint.plan_id) {
|
||||||
|
const plan = db.prepare(`SELECT p.*, u.display_name as planner_name FROM plans p
|
||||||
|
LEFT JOIN users u ON p.planner_id = u.id WHERE p.id = ?`).get(complaint.plan_id);
|
||||||
|
complaint.plan = plan;
|
||||||
|
}
|
||||||
|
if (complaint.implementation_id) {
|
||||||
|
const impl = db.prepare(`SELECT i.*, u.display_name as implementer_name FROM implementations i
|
||||||
|
LEFT JOIN users u ON i.implementer_id = u.id WHERE i.id = ?`).get(complaint.implementation_id);
|
||||||
|
complaint.implementation = impl;
|
||||||
|
}
|
||||||
|
if (complaint.review_id) {
|
||||||
|
const review = db.prepare(`SELECT r.*, u.display_name as reviewer_name FROM reviews r
|
||||||
|
LEFT JOIN users u ON r.reviewer_id = u.id WHERE r.id = ?`).get(complaint.review_id);
|
||||||
|
complaint.review = review;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(complaint);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', optionalAuth, (req, res) => {
|
||||||
|
const { title, description, category, priority, player_name, player_email, player_contact } = req.body;
|
||||||
|
if (!title || !description || !player_name) {
|
||||||
|
return res.status(400).json({ error: '标题、描述和玩家名称为必填项' });
|
||||||
|
}
|
||||||
|
const db = getDb();
|
||||||
|
const result = db.prepare(`INSERT INTO complaints(title, description, category, priority, player_name, player_email, player_contact, status)
|
||||||
|
VALUES (?,?,?,?,?,?,?,'pending')`).run(
|
||||||
|
title, description, category || 'other', priority || 'medium', player_name, player_email || '', player_contact || ''
|
||||||
|
);
|
||||||
|
const complaintId = result.lastInsertRowid;
|
||||||
|
|
||||||
|
db.prepare(`INSERT INTO responses(complaint_id, user_id, response_type, content) VALUES (?,?,?,?)`).run(
|
||||||
|
complaintId, req.user?.id || null, 'system', '投诉已提交,等待处理'
|
||||||
|
);
|
||||||
|
db.prepare(`INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)`).run(
|
||||||
|
req.user?.id || null, req.user?.username || player_name, 'create', 'complaint', complaintId,
|
||||||
|
`投诉已创建: ${title}`
|
||||||
|
);
|
||||||
|
res.status(201).json({ id: complaintId, message: '投诉提交成功' });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', authenticate, (req, res) => {
|
||||||
|
const db = getDb();
|
||||||
|
const complaint = db.prepare('SELECT * FROM complaints WHERE id = ?').get(req.params.id);
|
||||||
|
if (!complaint) return res.status(404).json({ error: '投诉不存在' });
|
||||||
|
|
||||||
|
const allowedFields = {};
|
||||||
|
if (req.body.title) allowedFields.title = req.body.title;
|
||||||
|
if (req.body.description) allowedFields.description = req.body.description;
|
||||||
|
if (req.body.category) allowedFields.category = req.body.category;
|
||||||
|
if (req.body.priority) allowedFields.priority = req.body.priority;
|
||||||
|
if (req.body.status) allowedFields.status = req.body.status;
|
||||||
|
|
||||||
|
if (Object.keys(allowedFields).length === 0) {
|
||||||
|
return res.status(400).json({ error: '没有可更新的字段' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignments = {};
|
||||||
|
if (req.body.assigned_planner_id !== undefined) assignments.assigned_planner_id = req.body.assigned_planner_id;
|
||||||
|
if (req.body.assigned_implementer_id !== undefined) assignments.assigned_implementer_id = req.body.assigned_implementer_id;
|
||||||
|
if (req.body.assigned_reviewer_id !== undefined) assignments.assigned_reviewer_id = req.body.assigned_reviewer_id;
|
||||||
|
|
||||||
|
const allFields = { ...allowedFields, ...assignments };
|
||||||
|
const setClauses = Object.keys(allFields).map(k => `${k} = ?`).join(', ');
|
||||||
|
const values = Object.values(allFields);
|
||||||
|
|
||||||
|
db.prepare(`UPDATE complaints SET ${setClauses}, updated_at = datetime('now','localtime') WHERE id = ?`).run(...values, req.params.id);
|
||||||
|
|
||||||
|
if (req.body.status && req.body.status !== complaint.status) {
|
||||||
|
db.prepare(`INSERT INTO responses(complaint_id, user_id, response_type, content) VALUES (?,?,?,?)`).run(
|
||||||
|
req.params.id, req.user.id, 'system', `状态变更为: ${getStatusLabel(req.body.status)}`
|
||||||
|
);
|
||||||
|
db.prepare(`INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)`).run(
|
||||||
|
req.user.id, req.user.username, 'update_status', 'complaint', req.params.id,
|
||||||
|
`状态从 ${complaint.status} 变更为 ${req.body.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)`).run(
|
||||||
|
req.user.id, req.user.username, 'update', 'complaint', req.params.id, '更新投诉信息'
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: '更新成功' });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/response', authenticate, (req, res) => {
|
||||||
|
const { content, response_type } = req.body;
|
||||||
|
if (!content) return res.status(400).json({ error: '内容不能为空' });
|
||||||
|
const db = getDb();
|
||||||
|
const complaint = db.prepare('SELECT * FROM complaints WHERE id = ?').get(req.params.id);
|
||||||
|
if (!complaint) return res.status(404).json({ error: '投诉不存在' });
|
||||||
|
|
||||||
|
db.prepare(`INSERT INTO responses(complaint_id, user_id, response_type, content) VALUES (?,?,?,?)`).run(
|
||||||
|
req.params.id, req.user.id, response_type || 'comment', content
|
||||||
|
);
|
||||||
|
db.prepare(`UPDATE complaints SET updated_at = datetime('now','localtime') WHERE id = ?`).run(req.params.id);
|
||||||
|
|
||||||
|
res.status(201).json({ message: '回复添加成功' });
|
||||||
|
});
|
||||||
|
|
||||||
|
function getStatusLabel(status) {
|
||||||
|
const labels = {
|
||||||
|
pending: '待处理', under_review: '审查中', planned: '已计划',
|
||||||
|
in_progress: '实施中', in_review: '审核中', resolved: '已解决',
|
||||||
|
rejected: '已驳回', closed: '已关闭'
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
77
backend/routes/export.js
Normal file
77
backend/routes/export.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { query } = require('../db');
|
||||||
|
const { authenticate, requireRole } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const SL = { pending:'待处理', processing:'处理中', awaiting_info:'待补充', resolved:'已解决', rejected:'已驳回', closed:'已关闭' };
|
||||||
|
const TL = { report:'举报', suggestion:'建议', appeal:'申诉' };
|
||||||
|
|
||||||
|
function toCSV(headers, rows, filename) {
|
||||||
|
let csv = '\uFEFF' + headers.join(',') + '\r\n';
|
||||||
|
for (const r of rows) {
|
||||||
|
csv += headers.map(h => { const v = String(r[h] ?? ''); return /[,"\n\r]/.test(v) ? '"' + v.replace(/"/g,'""') + '"' : v; }).join(',') + '\r\n';
|
||||||
|
}
|
||||||
|
return { csv, filename };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/tickets', authenticate, requireRole('owner'), async (req, res) => {
|
||||||
|
const { type, status, start_date, end_date } = req.query;
|
||||||
|
let q = `SELECT id as '编号', type as '类型', title as '标题', reporter_game_name as '提交者游戏名', reporter_game_uid as '提交者UID', target_game_name as '对象游戏名', target_game_uid as '对象UID', reason as '原因', description as '描述', status as '状态', priority as '优先级', created_at as '创建时间', updated_at as '更新时间' FROM tickets WHERE 1=1`;
|
||||||
|
const p = [];
|
||||||
|
if (type) { q += ' AND type = ?'; p.push(type); }
|
||||||
|
if (status) { q += ' AND status = ?'; p.push(status); }
|
||||||
|
if (start_date) { q += ' AND DATE(created_at) >= ?'; p.push(start_date); }
|
||||||
|
if (end_date) { q += ' AND DATE(created_at) <= ?'; p.push(end_date); }
|
||||||
|
q += ' ORDER BY created_at DESC';
|
||||||
|
const rows = await query(q, p);
|
||||||
|
if (req.query.format === 'csv') {
|
||||||
|
for (const r of rows) { r['类型'] = TL[r['类型']]||r['类型']; r['状态'] = SL[r['状态']]||r['状态']; }
|
||||||
|
const { csv, filename } = toCSV(['编号','类型','标题','提交者游戏名','提交者UID','对象游戏名','对象UID','原因','描述','状态','优先级','创建时间','更新时间'], rows, `tickets_${new Date().toISOString().slice(0,10)}.csv`);
|
||||||
|
res.setHeader('Content-Type','text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition','attachment; filename='+filename);
|
||||||
|
return res.send(csv);
|
||||||
|
}
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/reported-players', authenticate, requireRole('owner'), async (req, res) => {
|
||||||
|
const rows = await query(`SELECT target_game_name as '被举报人游戏名', target_game_uid as '被举报人UID', COUNT(*) as '被举报次数', SUM(CASE WHEN status='resolved' THEN 1 ELSE 0 END) as '已处理', SUM(CASE WHEN status IN ('pending','processing','awaiting_info') THEN 1 ELSE 0 END) as '处理中', SUM(CASE WHEN status='rejected' THEN 1 ELSE 0 END) as '已驳回', MAX(created_at) as '最近举报时间', GROUP_CONCAT(reason SEPARATOR ' | ') as '常见原因' FROM tickets WHERE type='report' AND target_game_name IS NOT NULL AND target_game_name != '' GROUP BY target_game_name, target_game_uid ORDER BY COUNT(*) DESC`);
|
||||||
|
if (req.query.format === 'csv') {
|
||||||
|
const { csv, filename } = toCSV(['被举报人游戏名','被举报人UID','被举报次数','已处理','处理中','已驳回','最近举报时间','常见原因'], rows, `reported_players_${new Date().toISOString().slice(0,10)}.csv`);
|
||||||
|
res.setHeader('Content-Type','text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition','attachment; filename='+filename);
|
||||||
|
return res.send(csv);
|
||||||
|
}
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/audit-logs', authenticate, requireRole('owner'), async (req, res) => {
|
||||||
|
const { start_date, end_date } = req.query;
|
||||||
|
let q = `SELECT id as '编号', username as '用户', action as '操作', entity_type as '对象', entity_id as '对象ID', details as '详情', created_at as '时间' FROM audit_logs WHERE 1=1`;
|
||||||
|
const p = [];
|
||||||
|
if (start_date) { q += ' AND DATE(created_at) >= ?'; p.push(start_date); }
|
||||||
|
if (end_date) { q += ' AND DATE(created_at) <= ?'; p.push(end_date); }
|
||||||
|
q += ' ORDER BY created_at DESC LIMIT 5000';
|
||||||
|
const rows = await query(q, p);
|
||||||
|
if (req.query.format === 'csv') {
|
||||||
|
const { csv, filename } = toCSV(['编号','用户','操作','对象','对象ID','详情','时间'], rows, `audit_${new Date().toISOString().slice(0,10)}.csv`);
|
||||||
|
res.setHeader('Content-Type','text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition','attachment; filename='+filename);
|
||||||
|
return res.send(csv);
|
||||||
|
}
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/dashboard', authenticate, requireRole('owner'), async (req, res) => {
|
||||||
|
const [[{c:total}], byType, byStatus, [{c:today}], [{c:usersTotal}], monthly] = await Promise.all([
|
||||||
|
query('SELECT COUNT(*) as c FROM tickets'),
|
||||||
|
query('SELECT type, COUNT(*) as c FROM tickets GROUP BY type'),
|
||||||
|
query('SELECT status, COUNT(*) as c FROM tickets GROUP BY status'),
|
||||||
|
query("SELECT COUNT(*) as c FROM tickets WHERE DATE(created_at) = CURDATE()"),
|
||||||
|
query('SELECT COUNT(*) as c FROM users'),
|
||||||
|
query("SELECT DATE_FORMAT(created_at,'%Y-%m') as m, COUNT(*) as c FROM tickets GROUP BY m ORDER BY m DESC LIMIT 12"),
|
||||||
|
]);
|
||||||
|
res.json({ total, byType, byStatus, today, usersTotal, monthly });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
95
backend/routes/install.js
Normal file
95
backend/routes/install.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { isInstalled, saveConfig, getConfig, initSchema } = require('../db');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/status', (req, res) => {
|
||||||
|
res.json({ installed: isInstalled() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/check-db', async (req, res) => {
|
||||||
|
const { host, port, user, password, database } = req.body;
|
||||||
|
try {
|
||||||
|
const conn = await mysql.createConnection({
|
||||||
|
host: host || 'localhost',
|
||||||
|
port: parseInt(port) || 3306,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
charset: 'utf8mb4',
|
||||||
|
multipleStatements: true,
|
||||||
|
});
|
||||||
|
const [rows] = await conn.query(`SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?`, [database]);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
await conn.query(`CREATE DATABASE \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||||
|
}
|
||||||
|
await conn.end();
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(400).json({ error: '数据库连接失败: ' + e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/complete', async (req, res) => {
|
||||||
|
if (isInstalled()) return res.status(400).json({ error: '系统已安装' });
|
||||||
|
|
||||||
|
const { db_host, db_port, db_user, db_pass, db_name,
|
||||||
|
site_name, site_url, admin_username, admin_password, admin_email,
|
||||||
|
admin_game_name, admin_game_uid } = req.body;
|
||||||
|
|
||||||
|
if (!db_host || !db_user || !db_name || !admin_username || !admin_password || !admin_email) {
|
||||||
|
return res.status(400).json({ error: '必填字段不完整' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const conn = await mysql.createConnection({
|
||||||
|
host: db_host,
|
||||||
|
port: parseInt(db_port) || 3306,
|
||||||
|
user: db_user,
|
||||||
|
password: db_pass || '',
|
||||||
|
charset: 'utf8mb4',
|
||||||
|
multipleStatements: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [dbs] = await conn.query(`SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ?`, [db_name]);
|
||||||
|
if (dbs.length === 0) {
|
||||||
|
await conn.query(`CREATE DATABASE \`${db_name}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.query(`USE \`${db_name}\``);
|
||||||
|
await initSchema(conn);
|
||||||
|
|
||||||
|
const hashed = bcrypt.hashSync(admin_password, 10);
|
||||||
|
await conn.query(
|
||||||
|
'INSERT INTO users(username, password, email, game_name, game_uid, role, active, email_verified) VALUES (?,?,?,?,?,?,1,1)',
|
||||||
|
[admin_username, hashed, admin_email, admin_game_name || admin_username, admin_game_uid || 'OWNER001', 'owner']
|
||||||
|
);
|
||||||
|
|
||||||
|
const jwtSecret = crypto.randomBytes(32).toString('hex');
|
||||||
|
const apiKey = crypto.randomBytes(24).toString('hex');
|
||||||
|
|
||||||
|
for (const [k, v, l] of [
|
||||||
|
['site_name', site_name || 'MC举报系统', '站点名称'],
|
||||||
|
['site_url', site_url || 'http://localhost:3100', '站点地址'],
|
||||||
|
]) {
|
||||||
|
await conn.query("INSERT INTO settings(k, v, label) VALUES (?,?,?) ON DUPLICATE KEY UPDATE v=VALUES(v)", [k, v, l]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.end();
|
||||||
|
|
||||||
|
saveConfig({
|
||||||
|
db_host, db_port: parseInt(db_port) || 3306,
|
||||||
|
db_user, db_pass: db_pass || '', db_name,
|
||||||
|
jwt_secret: jwtSecret,
|
||||||
|
api_key: apiKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true, message: '安装完成,请重新启动服务器' });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: '安装失败: ' + e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
46
backend/routes/notifications.js
Normal file
46
backend/routes/notifications.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { query, getRow } = require('../db');
|
||||||
|
const { authenticate, requireRole } = require('../middleware/auth');
|
||||||
|
const { sendWebhook } = require('../webhook');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
res.json(await query('SELECT * FROM notification_configs ORDER BY id'));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
const { name, type, webhook_url, events } = req.body;
|
||||||
|
if (!name||!type) return res.status(400).json({ error: '名称和类型为必填' });
|
||||||
|
if (type==='webhook' && !webhook_url) return res.status(400).json({ error: 'Webhook地址必填' });
|
||||||
|
const r = await query('INSERT INTO notification_configs(name, type, webhook_url, events) VALUES (?,?,?,?)', [name, type, webhook_url||'', events||'all']);
|
||||||
|
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'create_notification', 'notification_config', r.insertId, `创建通知: ${name}`]);
|
||||||
|
res.status(201).json({ id: r.insertId, message: '创建成功' });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
const cfg = await getRow('SELECT * FROM notification_configs WHERE id = ?', [req.params.id]);
|
||||||
|
if (!cfg) return res.status(404).json({ error: '配置不存在' });
|
||||||
|
const fields = {};
|
||||||
|
if (req.body.name) fields.name = req.body.name;
|
||||||
|
if (req.body.webhook_url !== undefined) fields.webhook_url = req.body.webhook_url;
|
||||||
|
if (req.body.events) fields.events = req.body.events;
|
||||||
|
if (req.body.active !== undefined) fields.active = req.body.active?1:0;
|
||||||
|
if (!Object.keys(fields).length) return res.status(400).json({ error: '无更新内容' });
|
||||||
|
const sets = Object.keys(fields).map(k=>`${k}=?`).join(',');
|
||||||
|
await query(`UPDATE notification_configs SET ${sets} WHERE id = ?`, [...Object.values(fields), req.params.id]);
|
||||||
|
res.json({ message: '更新成功' });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:id', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
await query('DELETE FROM notification_configs WHERE id = ?', [req.params.id]);
|
||||||
|
res.json({ message: '已删除' });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/test', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
const cfg = await getRow('SELECT * FROM notification_configs WHERE id = ?', [req.params.id]);
|
||||||
|
if (!cfg||!cfg.webhook_url) return res.status(404).json({ error: '配置不存在' });
|
||||||
|
sendWebhook('ticket_created', { ticket_id:'TEST', ticket_title:'测试通知', ticket_type:'测试', reporter_game_name:'TestPlayer', reporter_game_uid:'TEST001', ticket_reason:'这是一条测试消息' }).then(() => res.json({ message:'测试消息已发送' })).catch(e => res.status(500).json({ error:e.message }));
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
46
backend/routes/settings.js
Normal file
46
backend/routes/settings.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { query, getRow } = require('../db');
|
||||||
|
const { authenticate, requireRole } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/email-templates', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
res.json(await query('SELECT * FROM email_templates ORDER BY id'));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/email-templates/:code', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
const t = await getRow('SELECT * FROM email_templates WHERE code = ?', [req.params.code]);
|
||||||
|
if (!t) return res.status(404).json({ error: '模板不存在' });
|
||||||
|
res.json(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/email-templates/:code', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
const { subject, body } = req.body;
|
||||||
|
if (!subject||!body) return res.status(400).json({ error: '标题和内容不能为空' });
|
||||||
|
const t = await getRow('SELECT * FROM email_templates WHERE code = ?', [req.params.code]);
|
||||||
|
if (!t) return res.status(404).json({ error: '模板不存在' });
|
||||||
|
await query('UPDATE email_templates SET subject = ?, body = ? WHERE code = ?', [subject, body, req.params.code]);
|
||||||
|
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'update_email_template', 'email_template', t.id, `更新模板: ${t.name}`]);
|
||||||
|
res.json({ message: '模板更新成功' });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/settings', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
const rows = await query('SELECT k, v, label FROM settings ORDER BY k');
|
||||||
|
const map = {};
|
||||||
|
for (const r of rows) map[r.k] = { value: r.v, label: r.label };
|
||||||
|
res.json(map);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/settings', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
const updates = req.body;
|
||||||
|
if (!updates||typeof updates!=='object') return res.status(400).json({ error: '无效数据' });
|
||||||
|
for (const [key, val] of Object.entries(updates)) {
|
||||||
|
const v = typeof val === 'object' ? val.value : String(val);
|
||||||
|
const l = typeof val === 'object' ? val.label : null;
|
||||||
|
await query("INSERT INTO settings(k, v, label) VALUES (?,?,?) ON DUPLICATE KEY UPDATE v = VALUES(v)", [key, v, l]);
|
||||||
|
}
|
||||||
|
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'update_settings', 'settings', 0, '更新系统设置']);
|
||||||
|
res.json({ message: '设置已保存' });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
262
backend/routes/tickets.js
Normal file
262
backend/routes/tickets.js
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { v4: uuid } = require('uuid');
|
||||||
|
const { getPool, query, getRow } = require('../db');
|
||||||
|
const { authenticate, requireRole, optionalAuth } = require('../middleware/auth');
|
||||||
|
const { upload, finalizeUpload } = require('../middleware/upload');
|
||||||
|
const { sendEmail } = require('../mailer');
|
||||||
|
const { sendWebhook } = require('../webhook');
|
||||||
|
const { validateLengths } = require('../middleware/security');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const SL = { pending:'待处理', processing:'处理中', awaiting_info:'待补充', resolved:'已解决', rejected:'已驳回', closed:'已关闭' };
|
||||||
|
const TL = { report:'举报', suggestion:'建议', appeal:'申诉' };
|
||||||
|
const SC = { pending:'#f59e0b', processing:'#7c3aed', awaiting_info:'#d97706', resolved:'#059669', rejected:'#dc2626', closed:'#6b7280' };
|
||||||
|
const SO = { processing:1, awaiting_info:2, pending:3, resolved:4, rejected:5, closed:6 };
|
||||||
|
|
||||||
|
async function siteUrl() { const r = await getRow("SELECT v FROM settings WHERE k='site_url'"); return r?.v || 'http://localhost:3100'; }
|
||||||
|
|
||||||
|
router.get('/', authenticate, async (req, res) => {
|
||||||
|
let q = `SELECT t.*, u.game_name as user_game_name, u.username as submitter, a.game_name as assignee_name
|
||||||
|
FROM tickets t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN users a ON t.assigned_to = a.id`;
|
||||||
|
const conds = [], params = [];
|
||||||
|
|
||||||
|
if (req.user.role === 'player') { conds.push('t.user_id = ?'); params.push(req.user.id); }
|
||||||
|
else if (req.user.role === 'admin') { conds.push("(t.assigned_to IS NULL OR t.assigned_to = ?) AND t.type != 'suggestion'"); params.push(req.user.id); }
|
||||||
|
|
||||||
|
if (req.query.type) { conds.push('t.type = ?'); params.push(req.query.type); }
|
||||||
|
if (req.query.status) { conds.push('t.status = ?'); params.push(req.query.status); }
|
||||||
|
if (req.query.priority) { conds.push('t.priority = ?'); params.push(req.query.priority); }
|
||||||
|
if (req.query.claimed === '1') conds.push('t.assigned_to IS NOT NULL');
|
||||||
|
if (req.query.claimed === '0') conds.push('t.assigned_to IS NULL');
|
||||||
|
if (req.query.search) { const s = `%${req.query.search}%`; conds.push("(t.title LIKE ? OR t.reporter_game_name LIKE ? OR t.reporter_game_uid LIKE ? OR t.target_game_name LIKE ? OR t.target_game_uid LIKE ?)"); params.push(s,s,s,s,s); }
|
||||||
|
|
||||||
|
if (conds.length) q += ' WHERE ' + conds.join(' AND ');
|
||||||
|
q += ` ORDER BY CASE t.status ${Object.entries(SO).map(([k,v])=>`WHEN '${k}' THEN ${v}`).join(' ')} ELSE 9 END, t.updated_at DESC LIMIT 100`;
|
||||||
|
try { res.json(await query(q, params)); } catch(e) { res.status(500).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/stats', authenticate, async (req, res) => {
|
||||||
|
const role = req.user.role;
|
||||||
|
const sql = role === 'player' ? 'SELECT type, status, COUNT(*) as count FROM tickets WHERE user_id = ? GROUP BY type, status' : 'SELECT type, status, COUNT(*) as count FROM tickets GROUP BY type, status';
|
||||||
|
const params = role === 'player' ? [req.user.id] : [];
|
||||||
|
const rows = await query(sql, params);
|
||||||
|
const bs = {}; let total = 0;
|
||||||
|
for (const r of rows) { bs[r.status] = (bs[r.status]||0) + r.count; total += r.count; }
|
||||||
|
res.json({ total, byStatus: bs });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/unclaimed', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
let q = `SELECT t.*, u.game_name as user_game_name, u.username as submitter, a.game_name as assignee_name
|
||||||
|
FROM tickets t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN users a ON t.assigned_to = a.id WHERE t.assigned_to IS NULL`;
|
||||||
|
const params = [];
|
||||||
|
if (req.user.role === 'admin') { q += " AND t.type != 'suggestion'"; }
|
||||||
|
if (req.query.type) { q += ' AND t.type = ?'; params.push(req.query.type); }
|
||||||
|
q += ` ORDER BY CASE t.status ${Object.entries(SO).map(([k,v])=>`WHEN '${k}' THEN ${v}`).join(' ')} ELSE 9 END, t.created_at ASC LIMIT 200`;
|
||||||
|
res.json(await query(q, params));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/reported-players', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
const rows = await query(`SELECT target_game_name as name, target_game_uid as uid,
|
||||||
|
COUNT(*) as total_reports,
|
||||||
|
SUM(CASE WHEN status IN ('pending','processing','awaiting_info') THEN 1 ELSE 0 END) as active_count,
|
||||||
|
SUM(CASE WHEN status='resolved' THEN 1 ELSE 0 END) as resolved_count,
|
||||||
|
SUM(CASE WHEN status='rejected' THEN 1 ELSE 0 END) as rejected_count,
|
||||||
|
MAX(created_at) as last_report,
|
||||||
|
GROUP_CONCAT(reason SEPARATOR ' | ') as reasons
|
||||||
|
FROM tickets WHERE type='report' AND target_game_name IS NOT NULL AND target_game_name != ''
|
||||||
|
GROUP BY target_game_name, target_game_uid ORDER BY total_reports DESC, last_report DESC`);
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/tracking', async (req, res) => {
|
||||||
|
const token = req.query.token;
|
||||||
|
if (!token) return res.status(400).json({ error: '缺少追踪标识' });
|
||||||
|
const rows = await query(`SELECT t.*, a.game_name as assignee_name FROM tickets t LEFT JOIN users a ON t.assigned_to = a.id WHERE t.tracking_token = ? ORDER BY t.created_at DESC`, [token]);
|
||||||
|
if (!rows.length) return res.status(404).json({ error: '未找到相关工单' });
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', authenticate, async (req, res) => {
|
||||||
|
const t = await getRow(`SELECT t.*, u.game_name as user_game_name, u.username as submitter, a.game_name as assignee_name, a.username as assignee_username
|
||||||
|
FROM tickets t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN users a ON t.assigned_to = a.id WHERE t.id = ?`, [req.params.id]);
|
||||||
|
if (!t) return res.status(404).json({ error: '工单不存在' });
|
||||||
|
if (req.user.role === 'player' && t.user_id !== req.user.id) return res.status(403).json({ error: '无权查看' });
|
||||||
|
t.responses = await query(`SELECT r.*, u.username, u.role FROM responses r LEFT JOIN users u ON r.user_id = u.id WHERE r.ticket_id = ? ORDER BY r.created_at ASC`, [req.params.id]);
|
||||||
|
t.attachments = await query('SELECT * FROM attachments WHERE ticket_id = ?', [req.params.id]);
|
||||||
|
t.transfers = await query(`SELECT tf.*, fu.username as from_username, fu.game_name as from_game_name, tu.username as to_username, tu.game_name as to_game_name
|
||||||
|
FROM ticket_transfers tf LEFT JOIN users fu ON tf.from_user_id = fu.id LEFT JOIN users tu ON tf.to_user_id = tu.id WHERE tf.ticket_id = ? ORDER BY tf.created_at ASC`, [req.params.id]);
|
||||||
|
res.json(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', optionalAuth, upload.array('files', 5), finalizeUpload, validateLengths({
|
||||||
|
title:100, reporter_game_name:50, reporter_game_uid:50, target_game_name:50, target_game_uid:50, reason:300, description:5000,
|
||||||
|
}), async (req, res) => {
|
||||||
|
const { type, title, reporter_game_name, reporter_game_uid, target_game_name, target_game_uid, reason, description } = req.body;
|
||||||
|
if (!type || !['report','suggestion','appeal'].includes(type)) return res.status(400).json({ error: '类型不正确' });
|
||||||
|
if (!title) return res.status(400).json({ error: '标题不能为空' });
|
||||||
|
const rgn = req.user?.game_name || reporter_game_name;
|
||||||
|
const rgu = req.user?.game_uid || reporter_game_uid;
|
||||||
|
if (!rgn || !rgu) return res.status(400).json({ error: '请填写游戏名称和UID' });
|
||||||
|
if (type === 'report') { if (!target_game_name && !target_game_uid) return res.status(400).json({ error: '举报需至少填写对方游戏名或UID之一' }); if (!reason) return res.status(400).json({ error: '请填写举报原因' }); }
|
||||||
|
if (type === 'suggestion' && !description) return res.status(400).json({ error: '建议内容不能为空' });
|
||||||
|
if (type === 'appeal') { if (!reason) return res.status(400).json({ error: '请填写申诉理由' }); if (!description) return res.status(400).json({ error: '请填写详细申诉内容' }); }
|
||||||
|
|
||||||
|
const countRow = await getRow("SELECT COUNT(*) as c FROM tickets WHERE (reporter_game_name = ? OR reporter_game_uid = ?) AND status IN ('pending','processing','awaiting_info')", [rgn, rgu]);
|
||||||
|
if (countRow.c >= 5) return res.status(400).json({ error: '您的待处理/处理中工单已达上限(5个),请等待已有工单处理完毕后再提交' });
|
||||||
|
|
||||||
|
const trackingToken = uuid();
|
||||||
|
const pool = await getPool();
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
let ticketId;
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
const [r] = await conn.execute(`INSERT INTO tickets(type,title,user_id,reporter_game_name,reporter_game_uid,
|
||||||
|
target_game_name,target_game_uid,reason,description,tracking_token) VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
[type, title, req.user?.id||null, rgn, rgu,
|
||||||
|
type==='report'?(target_game_name||''):null, type==='report'?(target_game_uid||''):null,
|
||||||
|
(type==='report'||type==='appeal')?reason:null, (type==='suggestion'||type==='appeal')?description:'', trackingToken]);
|
||||||
|
ticketId = r.insertId;
|
||||||
|
|
||||||
|
if (req.files?.length) {
|
||||||
|
for (const f of req.files) {
|
||||||
|
await conn.execute('INSERT INTO attachments(ticket_id, original_name, stored_name, mime_type, size) VALUES (?,?,?,?,?)', [ticketId, f.originalname, f.filename, f.mimetype, f.size]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const respContent = type === 'report'
|
||||||
|
? `举报已提交\n举报人: ${rgn} (UID: ${rgu})\n被举报人: ${target_game_name||'无'} (UID: ${target_game_uid||'无'})\n原因: ${reason}${description?'\n详情: '+description:''}`
|
||||||
|
: type === 'appeal'
|
||||||
|
? `申诉已提交\n申诉人: ${rgn} (UID: ${rgu})\n标题: ${title}\n申诉理由: ${reason}\n详细内容: ${description}`
|
||||||
|
: `建议已提交\n提交人: ${rgn} (UID: ${rgu})\n标题: ${title}\n内容: ${description}`;
|
||||||
|
|
||||||
|
await conn.execute("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,0)", [ticketId, req.user?.id||null, respContent]);
|
||||||
|
await conn.execute("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)",
|
||||||
|
[req.user?.id||null, req.user?.username||rgn, `create_${type}`, 'ticket', ticketId, `提交${TL[type]}: ${title}`]);
|
||||||
|
await conn.commit();
|
||||||
|
} catch (e) { await conn.rollback(); conn.release(); return res.status(500).json({ error: e.message }); }
|
||||||
|
conn.release();
|
||||||
|
|
||||||
|
const emailVars = { reporter_game_name: rgn, reporter_game_uid: rgu, ticket_type: TL[type], ticket_id: ticketId, ticket_title: title, ticket_reason: reason||'', ticket_description: description||'', tracking_link: `${await siteUrl()}#/ticket/${ticketId}`, date: new Date().toLocaleString('zh-CN') };
|
||||||
|
if (req.user?.email) sendEmail(req.user.email, 'ticket_created', emailVars).catch(()=>{});
|
||||||
|
sendWebhook('ticket_created', { ...emailVars, type }).catch(()=>{});
|
||||||
|
|
||||||
|
res.setHeader('Set-Cookie', `tracking_token=${trackingToken}; Path=/; SameSite=Lax; Max-Age=${365*24*3600}`);
|
||||||
|
res.status(201).json({ id: ticketId, tracking_token: trackingToken, message: '提交成功' });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
const t = await getRow('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
|
||||||
|
if (!t) return res.status(404).json({ error: '工单不存在' });
|
||||||
|
const fields = {};
|
||||||
|
if (req.body.status) fields.status = req.body.status;
|
||||||
|
if (req.body.priority) fields.priority = req.body.priority;
|
||||||
|
if (req.body.claim_note !== undefined) fields.claim_note = req.body.claim_note;
|
||||||
|
if (!Object.keys(fields).length) return res.status(400).json({ error: '无更新内容' });
|
||||||
|
const sets = Object.keys(fields).map(k => `${k} = ?`).join(', ');
|
||||||
|
await query(`UPDATE tickets SET ${sets} WHERE id = ?`, [...Object.values(fields), req.params.id]);
|
||||||
|
|
||||||
|
if (req.body.status && req.body.status !== t.status) {
|
||||||
|
await query("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,1)", [req.params.id, req.user.id, `状态变更为: ${SL[req.body.status]||req.body.status}`]);
|
||||||
|
await notifyStatusChange(t, req.body.status);
|
||||||
|
}
|
||||||
|
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'update_ticket', 'ticket', req.params.id, '更新工单']);
|
||||||
|
res.json({ message: '更新成功' });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/claim', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
const t = await getRow('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
|
||||||
|
if (!t) return res.status(404).json({ error: '工单不存在' });
|
||||||
|
if (t.assigned_to && t.assigned_to !== req.user.id && t.status !== 'pending') return res.status(400).json({ error: '该工单已被他人认领' });
|
||||||
|
if (t.type === 'suggestion' && req.user.role !== 'owner') return res.status(403).json({ error: '建议工单仅服主可处理' });
|
||||||
|
|
||||||
|
const claimNote = req.body.claim_note || '';
|
||||||
|
const initialReply = req.body.initial_reply || '';
|
||||||
|
await query("UPDATE tickets SET assigned_to = ?, status = 'processing', claim_note = ?, claimed_at = NOW() WHERE id = ?", [req.user.id, claimNote, req.params.id]);
|
||||||
|
|
||||||
|
const claimMsg = initialReply ? `【接单】${initialReply}${claimNote?'\n\n处理备注: '+claimNote:''}` : `【接单】已认领此工单${claimNote?'\n处理备注: '+claimNote:''}`;
|
||||||
|
await query("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,1)", [req.params.id, req.user.id, claimMsg]);
|
||||||
|
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'claim_ticket', 'ticket', req.params.id, '认领工单']);
|
||||||
|
|
||||||
|
const user = await getRow('SELECT username, game_name FROM users WHERE id = ?', [req.user.id]);
|
||||||
|
await sendEmailForTicket(t, 'ticket_claimed', { assigned_to: user?.game_name||req.user.username, claim_note: claimNote });
|
||||||
|
sendWebhook('ticket_claimed', { ticket_id: t.id, ticket_title: t.title, assigned_to: user?.game_name||req.user.username, claim_note: claimNote }).catch(()=>{});
|
||||||
|
res.json({ message: '认领成功' });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/transfer', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
const t = await getRow('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
|
||||||
|
if (!t) return res.status(404).json({ error: '工单不存在' });
|
||||||
|
if (t.assigned_to !== req.user.id && req.user.role !== 'owner') return res.status(403).json({ error: '只能转交自己负责的工单' });
|
||||||
|
const { to_user_id, reason } = req.body;
|
||||||
|
if (!to_user_id) return res.status(400).json({ error: '请选择转交目标' });
|
||||||
|
|
||||||
|
const toUser = await getRow("SELECT * FROM users WHERE id = ? AND role IN ('owner','admin')", [to_user_id]);
|
||||||
|
if (!toUser) return res.status(400).json({ error: '目标用户无效' });
|
||||||
|
if (to_user_id === req.user.id) return res.status(400).json({ error: '不能转交给自己' });
|
||||||
|
|
||||||
|
const pool = await getPool();
|
||||||
|
const conn = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await conn.beginTransaction();
|
||||||
|
await conn.execute('INSERT INTO ticket_transfers(ticket_id, from_user_id, to_user_id, reason) VALUES (?,?,?,?)', [req.params.id, req.user.id, to_user_id, reason||'']);
|
||||||
|
await conn.execute('UPDATE tickets SET assigned_to = ? WHERE id = ?', [to_user_id, req.params.id]);
|
||||||
|
await conn.execute("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,1)", [req.params.id, req.user.id, `【转单】工单已转交给 ${toUser.game_name||toUser.username}${reason?'\n原因: '+reason:''}`]);
|
||||||
|
await conn.execute("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'transfer_ticket', 'ticket', req.params.id, `转交工单给 ${toUser.username}`]);
|
||||||
|
await conn.commit();
|
||||||
|
} catch (e) { await conn.rollback(); conn.release(); return res.status(500).json({ error: e.message }); }
|
||||||
|
conn.release();
|
||||||
|
|
||||||
|
const fromUser = await getRow('SELECT game_name FROM users WHERE id = ?', [req.user.id]);
|
||||||
|
await sendEmailForTicket(t, 'ticket_transferred', { from_user: fromUser?.game_name||req.user.username, to_user: toUser.game_name||toUser.username, transfer_reason: reason||'', claim_note: t.claim_note||'无' });
|
||||||
|
sendWebhook('ticket_transferred', { ticket_id: t.id, ticket_title: t.title, from_user: fromUser?.game_name, to_user: toUser.game_name, reason: reason||'' }).catch(()=>{});
|
||||||
|
res.json({ message: '转交成功' });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/response', authenticate, async (req, res) => {
|
||||||
|
const { content } = req.body;
|
||||||
|
if (!content) return res.status(400).json({ error: '内容不能为空' });
|
||||||
|
const t = await getRow('SELECT * FROM tickets WHERE id = ?', [req.params.id]);
|
||||||
|
if (!t) return res.status(404).json({ error: '工单不存在' });
|
||||||
|
if (req.user.role === 'player' && t.user_id !== req.user.id) return res.status(403).json({ error: '无权限' });
|
||||||
|
|
||||||
|
const isStaff = req.user.role === 'owner' || req.user.role === 'admin';
|
||||||
|
await query("INSERT INTO responses(ticket_id, user_id, content, is_staff) VALUES (?,?,?,?)", [req.params.id, req.user.id, content, isStaff?1:0]);
|
||||||
|
await query('UPDATE tickets SET updated_at = NOW() WHERE id = ?', [req.params.id]);
|
||||||
|
|
||||||
|
if (isStaff && t.user_id) {
|
||||||
|
await sendEmailForTicket(t, 'ticket_updated', { update_note: content });
|
||||||
|
sendWebhook('ticket_updated', { ticket_id: t.id, ticket_title: t.title, responder: req.user.username, note: content }).catch(()=>{});
|
||||||
|
}
|
||||||
|
res.status(201).json({ message: '回复成功' });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function notifyStatusChange(ticket, newStatus) {
|
||||||
|
if (ticket.user_id) {
|
||||||
|
await sendEmailForTicket(ticket, 'ticket_updated', { ticket_status: SL[newStatus]||newStatus, ticket_status_color: SC[newStatus]||'#6b7280' });
|
||||||
|
}
|
||||||
|
sendWebhook('ticket_updated', { ticket_id: ticket.id, ticket_title: ticket.title, new_status: SL[newStatus]||newStatus, type: ticket.type }).catch(()=>{});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEmailForTicket(ticket, templateCode, extraVars = {}) {
|
||||||
|
const assignee = ticket.assigned_to ? await getRow('SELECT game_name, username FROM users WHERE id = ?', [ticket.assigned_to]) : null;
|
||||||
|
const vars = {
|
||||||
|
ticket_id: ticket.id, ticket_title: ticket.title, ticket_type: TL[ticket.type]||ticket.type,
|
||||||
|
ticket_status: SL[ticket.status]||ticket.status, ticket_status_color: SC[ticket.status]||'#6b7280',
|
||||||
|
reporter_game_name: ticket.reporter_game_name, reporter_game_uid: ticket.reporter_game_uid,
|
||||||
|
target_game_name: ticket.target_game_name||'', target_game_uid: ticket.target_game_uid||'',
|
||||||
|
ticket_reason: ticket.reason||'', ticket_description: ticket.description||'',
|
||||||
|
assigned_to: assignee?.game_name||assignee?.username||'未分配', claim_note: ticket.claim_note||'',
|
||||||
|
tracking_link: `${await siteUrl()}#/ticket/${ticket.id}`, date: new Date().toLocaleString('zh-CN'), ...extraVars,
|
||||||
|
};
|
||||||
|
if (ticket.user_id) {
|
||||||
|
const user = await getRow('SELECT email, username, game_name, game_uid FROM users WHERE id = ?', [ticket.user_id]);
|
||||||
|
if (user?.email) { vars.username = user.username; vars.game_name = user.game_name; vars.game_uid = user.game_uid; sendEmail(user.email, templateCode, vars).catch(()=>{}); }
|
||||||
|
}
|
||||||
|
if (ticket.assigned_to && templateCode !== 'ticket_created') {
|
||||||
|
const a = await getRow('SELECT email FROM users WHERE id = ?', [ticket.assigned_to]);
|
||||||
|
if (a?.email) sendEmail(a.email, templateCode, vars).catch(()=>{});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
23
backend/routes/uploads.js
Normal file
23
backend/routes/uploads.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { getRow } = require('../db');
|
||||||
|
const { authenticate } = require('../middleware/auth');
|
||||||
|
const { UPLOAD_DIR } = require('../middleware/upload');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/:storedName', authenticate, async (req, res) => {
|
||||||
|
const att = await getRow('SELECT * FROM attachments WHERE stored_name = ?', [req.params.storedName]);
|
||||||
|
if (!att) return res.status(404).json({ error: '文件不存在' });
|
||||||
|
const ticket = await getRow('SELECT * FROM tickets WHERE id = ?', [att.ticket_id]);
|
||||||
|
if (req.user.role === 'player' && ticket?.user_id !== req.user.id) return res.status(403).json({ error: '无权限' });
|
||||||
|
const filePath = path.join(UPLOAD_DIR, att.stored_name);
|
||||||
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: '文件不存在' });
|
||||||
|
res.setHeader('Content-Type', att.mime_type);
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(att.original_name)}"`);
|
||||||
|
res.setHeader('Cache-Control', 'private, max-age=86400');
|
||||||
|
fs.createReadStream(filePath).pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
48
backend/routes/users.js
Normal file
48
backend/routes/users.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { query, getRow } = require('../db');
|
||||||
|
const { authenticate, requireRole } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
res.json(await query('SELECT id, username, email, game_name, game_uid, role, active, email_verified, created_at FROM users ORDER BY created_at DESC'));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
const u = await getRow('SELECT id, username, email, game_name, game_uid, role, active, email_verified, created_at FROM users WHERE id = ?', [req.params.id]);
|
||||||
|
if (!u) return res.status(404).json({ error: '用户不存在' });
|
||||||
|
res.json(u);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
const { username, password, email, game_name, game_uid, role } = req.body;
|
||||||
|
if (!username||!password||!email||!game_name||!game_uid||!role) return res.status(400).json({ error: '所有字段必填' });
|
||||||
|
if (!['owner','admin','player'].includes(role)) return res.status(400).json({ error: '无效角色' });
|
||||||
|
if (password.length < 6) return res.status(400).json({ error: '密码至少6位' });
|
||||||
|
if (await getRow('SELECT id FROM users WHERE username = ?', [username])) return res.status(400).json({ error: '用户名已存在' });
|
||||||
|
const hashed = bcrypt.hashSync(password, 10);
|
||||||
|
const r = await query('INSERT INTO users(username, password, email, game_name, game_uid, role, active, email_verified) VALUES (?,?,?,?,?,?,1,1)', [username, hashed, email, game_name, game_uid, role]);
|
||||||
|
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'create_user', 'user', r.insertId, `创建用户 ${username}`]);
|
||||||
|
res.status(201).json({ id: r.insertId, message: '创建成功' });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id', authenticate, requireRole('owner','admin'), async (req, res) => {
|
||||||
|
const u = await getRow('SELECT * FROM users WHERE id = ?', [req.params.id]);
|
||||||
|
if (!u) return res.status(404).json({ error: '用户不存在' });
|
||||||
|
if (u.role === 'owner' && req.user.role !== 'owner') return res.status(403).json({ error: '无权修改服主' });
|
||||||
|
const fields = {};
|
||||||
|
if (req.body.email) fields.email = req.body.email;
|
||||||
|
if (req.body.game_name) fields.game_name = req.body.game_name;
|
||||||
|
if (req.body.game_uid) fields.game_uid = req.body.game_uid;
|
||||||
|
if (req.body.role) { if (!['owner','admin','player'].includes(req.body.role)) return res.status(400).json({ error: '无效角色' }); fields.role = req.body.role; }
|
||||||
|
if (req.body.active !== undefined) fields.active = req.body.active ? 1 : 0;
|
||||||
|
if (req.body.password) { if (req.body.password.length < 6) return res.status(400).json({ error: '密码至少6位' }); fields.password = bcrypt.hashSync(req.body.password, 10); }
|
||||||
|
if (!Object.keys(fields).length) return res.status(400).json({ error: '无更新内容' });
|
||||||
|
const sets = Object.keys(fields).map(k => `${k} = ?`).join(', ');
|
||||||
|
await query(`UPDATE users SET ${sets} WHERE id = ?`, [...Object.values(fields), req.params.id]);
|
||||||
|
await query("INSERT INTO audit_logs(user_id, username, action, entity_type, entity_id, details) VALUES (?,?,?,?,?,?)", [req.user.id, req.user.username, 'update_user', 'user', req.params.id, '更新用户']);
|
||||||
|
res.json({ message: '更新成功' });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
125
backend/server.js
Normal file
125
backend/server.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { isInstalled, getConfig } = require('./db');
|
||||||
|
const {
|
||||||
|
sanitizeBody, generalLimiter, loginLimiter, registerLimiter,
|
||||||
|
ticketLimiter, captchaLimiter, apiKeyGuard, methodGuard
|
||||||
|
} = require('./middleware/security');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
const PORT = process.env.PORT || 3100;
|
||||||
|
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
|
||||||
|
const HTML_PATH = path.join(PUBLIC_DIR, 'index.html');
|
||||||
|
|
||||||
|
let cachedHtml = null;
|
||||||
|
let htmlTimestamp = 0;
|
||||||
|
|
||||||
|
function getHtmlWithKey() {
|
||||||
|
const stat = fs.statSync(HTML_PATH);
|
||||||
|
const mtime = stat.mtimeMs;
|
||||||
|
if (cachedHtml && htmlTimestamp >= mtime) return cachedHtml;
|
||||||
|
let html = fs.readFileSync(HTML_PATH, 'utf-8');
|
||||||
|
const cfg = getConfig();
|
||||||
|
const key = cfg?.api_key || '';
|
||||||
|
const redirect = isInstalled() ? '' : '<script>location.hash="#/install"</script>';
|
||||||
|
html = html.replace('</head>', `<script>window.__API_KEY__=${JSON.stringify(key)}</script>${redirect}</head>`);
|
||||||
|
cachedHtml = html;
|
||||||
|
htmlTimestamp = mtime;
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
scriptSrc: ["'self'", "'unsafe-inline'", "https://cdnjs.cloudflare.com"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'", "https://cdnjs.cloudflare.com"],
|
||||||
|
fontSrc: ["'self'", "https://cdnjs.cloudflare.com"],
|
||||||
|
imgSrc: ["'self'", "data:", "blob:"],
|
||||||
|
mediaSrc: ["'self'"],
|
||||||
|
connectSrc: ["'self'"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use(cors({
|
||||||
|
origin: (origin, cb) => {
|
||||||
|
const allowed = process.env.CORS_ORIGIN;
|
||||||
|
if (!allowed || allowed === '*') return cb(null, true);
|
||||||
|
if (allowed === origin) return cb(null, true);
|
||||||
|
if (!allowed) {
|
||||||
|
const host = origin || '';
|
||||||
|
if (host.startsWith('http://localhost:') || host.startsWith('https://localhost:')) return cb(null, true);
|
||||||
|
}
|
||||||
|
cb(new Error('Not allowed by CORS'));
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET','POST','PUT','DELETE'],
|
||||||
|
allowedHeaders: ['Content-Type','Authorization','x-api-key'],
|
||||||
|
}));
|
||||||
|
app.disable('x-powered-by');
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
||||||
|
app.use(sanitizeBody);
|
||||||
|
app.use(generalLimiter);
|
||||||
|
app.use('/api', apiKeyGuard);
|
||||||
|
|
||||||
|
const staticOpts = { maxAge: '1h', setHeaders: (res, p) => { if (p.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache'); } };
|
||||||
|
app.use('/css', express.static(path.join(PUBLIC_DIR, 'css'), staticOpts));
|
||||||
|
app.use('/js', express.static(path.join(PUBLIC_DIR, 'js'), staticOpts));
|
||||||
|
|
||||||
|
const installRoutes = require('./routes/install');
|
||||||
|
app.use('/api/install', installRoutes);
|
||||||
|
|
||||||
|
if (isInstalled()) {
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
app.use('/api/auth/login', methodGuard(['POST']), loginLimiter);
|
||||||
|
app.use('/api/auth/register', methodGuard(['POST']), registerLimiter);
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
|
||||||
|
app.use('/api/captcha', methodGuard(['GET']), captchaLimiter, require('./routes/captcha'));
|
||||||
|
|
||||||
|
app.use('/api/tickets', methodGuard(['GET','POST','PUT']), ticketLimiter, require('./routes/tickets'));
|
||||||
|
app.use('/api/reports', methodGuard(['GET','POST','PUT']), ticketLimiter, require('./routes/tickets'));
|
||||||
|
|
||||||
|
app.use('/api/users', methodGuard(['GET','POST','PUT']), generalLimiter, require('./routes/users'));
|
||||||
|
app.use('/api/settings', methodGuard(['GET','PUT']), generalLimiter, require('./routes/settings'));
|
||||||
|
app.use('/api/export', methodGuard(['GET']), generalLimiter, require('./routes/export'));
|
||||||
|
app.use('/api/uploads', methodGuard(['GET']), generalLimiter, require('./routes/uploads'));
|
||||||
|
app.use('/api/notifications', methodGuard(['GET','POST','PUT','DELETE']), generalLimiter, require('./routes/notifications'));
|
||||||
|
|
||||||
|
app.get('/api/verify', (req, res) => res.redirect(`/#/verify?token=${req.query.token}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/health', (req, res) => res.json({ status: 'ok', installed: isInstalled() }));
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.type('html').send(getHtmlWithKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
if (req.path.startsWith('/api')) return res.status(404).json({ error: '接口不存在' });
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
res.type('html').send(getHtmlWithKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
if (err.message === 'SYSTEM_NOT_INSTALLED') return res.status(503).json({ error: '系统未安装' });
|
||||||
|
if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: '文件大小超过限制(50MB)' });
|
||||||
|
if (err.message?.includes('不支持的文件类型')) return res.status(400).json({ error: err.message });
|
||||||
|
if (err.message?.includes('文件内容与声明类型不符')) return res.status(400).json({ error: '文件类型校验失败' });
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: '服务器内部错误' });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isInstalled()) console.log(`Server running at http://localhost:${PORT}`);
|
||||||
|
else console.log(`System not installed. Visit http://localhost:${PORT}/#/install`);
|
||||||
|
|
||||||
|
app.listen(PORT);
|
||||||
29
backend/webhook.js
Normal file
29
backend/webhook.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const { query, getRow } = require('./db');
|
||||||
|
|
||||||
|
const EVENT_LABELS = { ticket_created:'工单创建', ticket_claimed:'工单认领', ticket_transferred:'工单转交', ticket_updated:'工单更新' };
|
||||||
|
|
||||||
|
async function sendWebhook(event, data) {
|
||||||
|
try {
|
||||||
|
const configs = await query("SELECT * FROM notification_configs WHERE type = 'webhook' AND active = 1");
|
||||||
|
for (const cfg of configs) {
|
||||||
|
const events = cfg.events || 'all';
|
||||||
|
if (events !== 'all' && !events.split(',').map(s=>s.trim()).includes(event)) continue;
|
||||||
|
const embed = buildEmbed(event, data);
|
||||||
|
try { await fetch(cfg.webhook_url, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(embed) }); } catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEmbed(event, data) {
|
||||||
|
const row = data._siteName ? { v: data._siteName } : null;
|
||||||
|
const siteName = row?.v || 'MC举报系统';
|
||||||
|
const colors = { ticket_created:0x4f46e5, ticket_claimed:0x10b981, ticket_transferred:0xf59e0b, ticket_updated:0x3b82f6 };
|
||||||
|
const embed = { username: siteName, embeds:[{ title:EVENT_LABELS[event]||event, color:colors[event]||0x4f46e5, timestamp:new Date().toISOString(), fields:[] }] };
|
||||||
|
if (event==='ticket_created') { embed.embeds[0].description=`**#${data.ticket_id}** - ${data.ticket_title||''}`; embed.embeds[0].fields.push({name:'类型',value:data.ticket_type||data.type||'?',inline:true},{name:'提交者',value:`${data.reporter_game_name||'?'} (UID:${data.reporter_game_uid||'?'})`,inline:true}); }
|
||||||
|
else if (event==='ticket_claimed') { embed.embeds[0].description=`**#${data.ticket_id}** - ${data.ticket_title}`; embed.embeds[0].fields.push({name:'认领人',value:data.assigned_to||'?',inline:true}); }
|
||||||
|
else if (event==='ticket_transferred') { embed.embeds[0].description=`**#${data.ticket_id}** - ${data.ticket_title}`; embed.embeds[0].fields.push({name:'转出',value:data.from_user||'?',inline:true},{name:'转入',value:data.to_user||'?',inline:true}); }
|
||||||
|
else if (event==='ticket_updated') { embed.embeds[0].description=`**#${data.ticket_id}** - ${data.ticket_title}`; if(data.new_status)embed.embeds[0].fields.push({name:'新状态',value:data.new_status,inline:true}); }
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sendWebhook };
|
||||||
3
nginx.inc
Normal file
3
nginx.inc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
client_max_body_size 55m;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
4
opencode.json
Normal file
4
opencode.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"instructions": ["AGENTS.md"]
|
||||||
|
}
|
||||||
1
opencode.txt
Normal file
1
opencode.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ses_0b2c2409dffegYwIl1HBMSlSLL
|
||||||
1383
package-lock.json
generated
Normal file
1383
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "game-complaint-system",
|
||||||
|
"version": "3.0.0",
|
||||||
|
"description": "Game Player Reporting & Suggestion System",
|
||||||
|
"main": "backend/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node backend/server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"mysql2": "^3.11.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express-validator": "^7.0.1",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"nodemailer": "^6.9.8",
|
||||||
|
"uuid": "^9.0.0",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"express-rate-limit": "^7.3.1",
|
||||||
|
"xss": "^1.0.15"
|
||||||
|
}
|
||||||
|
}
|
||||||
159
public/css/style.css
Normal file
159
public/css/style.css
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
:root {
|
||||||
|
--p: #4f46e5; --ph: #4338ca; --pl: #eef2ff;
|
||||||
|
--s: #10b981; --w: #f59e0b; --d: #ef4444; --i: #3b82f6;
|
||||||
|
--g50: #f9fafb; --g100: #f3f4f6; --g200: #e5e7eb; --g300: #d1d5db;
|
||||||
|
--g400: #9ca3af; --g500: #6b7280; --g600: #4b5563; --g700: #374151;
|
||||||
|
--g800: #1f2937; --g900: #111827;
|
||||||
|
--sw: 240px; --r: 8px; --sh: 0 1px 3px rgba(0,0,0,.1);
|
||||||
|
}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Noto Sans SC',sans-serif;background:var(--g50);color:var(--g800);line-height:1.6;min-height:100vh}
|
||||||
|
.hidden{display:none!important}
|
||||||
|
#app{min-height:100vh}
|
||||||
|
|
||||||
|
#public-page{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#1e1b4b 0%,#312e81 40%,#4c1d95 100%);padding:20px}
|
||||||
|
.pub-card{background:white;border-radius:16px;padding:36px 32px;width:440px;max-width:90vw;box-shadow:0 20px 60px rgba(0,0,0,.35)}
|
||||||
|
.pub-card .logo{text-align:center;margin-bottom:28px}
|
||||||
|
.pub-card .logo i{font-size:44px;color:var(--p)}
|
||||||
|
.pub-card .logo h2{margin-top:10px;font-size:20px;color:var(--g800)}
|
||||||
|
.pub-card .logo p{color:var(--g500);font-size:13px;margin-top:2px}
|
||||||
|
.pub-card.wide{width:620px}
|
||||||
|
|
||||||
|
.form-group{margin-bottom:14px}
|
||||||
|
.form-group label{display:block;font-size:13px;font-weight:600;color:var(--g700);margin-bottom:4px}
|
||||||
|
.form-group input,.form-group select,.form-group textarea{width:100%;padding:9px 12px;border:1px solid var(--g300);border-radius:var(--r);font-size:14px;transition:.2s;background:white;color:var(--g800)}
|
||||||
|
.form-group textarea{min-height:80px;resize:vertical}
|
||||||
|
.form-group input:focus,.form-group select:focus,.form-group textarea:focus{outline:none;border-color:var(--p);box-shadow:0 0 0 3px rgba(79,70,229,.1)}
|
||||||
|
.form-group .help{font-size:11px;color:var(--g400);margin-top:2px}
|
||||||
|
|
||||||
|
.btn{display:inline-flex;align-items:center;gap:6px;padding:9px 18px;border:none;border-radius:var(--r);font-size:14px;font-weight:500;cursor:pointer;transition:.2s;white-space:nowrap;text-decoration:none}
|
||||||
|
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||||||
|
.btn-p{background:var(--p);color:white}
|
||||||
|
.btn-p:hover:not(:disabled){background:var(--ph)}
|
||||||
|
.btn-s{background:var(--s);color:white}.btn-s:hover:not(:disabled){background:#059669}
|
||||||
|
.btn-w{background:var(--w);color:white}.btn-w:hover:not(:disabled){background:#d97706}
|
||||||
|
.btn-d{background:var(--d);color:white}.btn-d:hover:not(:disabled){background:#dc2626}
|
||||||
|
.btn-i{background:var(--i);color:white}.btn-i:hover:not(:disabled){background:#2563eb}
|
||||||
|
.btn-o{background:transparent;border:1px solid var(--g300);color:var(--g700)}
|
||||||
|
.btn-o:hover:not(:disabled){background:var(--g100)}
|
||||||
|
.btn-sm{padding:5px 10px;font-size:12px}.btn-lg{padding:11px 22px;font-size:15px}
|
||||||
|
.w-full{width:100%;justify-content:center}
|
||||||
|
|
||||||
|
#main-layout{display:flex;min-height:100vh}
|
||||||
|
#sidebar{width:var(--sw);background:var(--g900);color:white;display:flex;flex-direction:column;position:fixed;top:0;left:0;bottom:0;z-index:100}
|
||||||
|
.sidebar-header{padding:14px 18px;font-size:15px;font-weight:700;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--g700)}
|
||||||
|
.sidebar-header i{font-size:22px;color:var(--p)}
|
||||||
|
#sidebar-nav{flex:1;padding:8px 0;overflow-y:auto}
|
||||||
|
.nav-item{display:flex;align-items:center;gap:10px;padding:10px 18px;color:var(--g400);cursor:pointer;transition:.2s;font-size:13px;border-left:3px solid transparent}
|
||||||
|
.nav-item:hover{color:white;background:rgba(255,255,255,.05)}
|
||||||
|
.nav-item.active{color:white;background:rgba(79,70,229,.15);border-left-color:var(--p)}
|
||||||
|
.nav-item i{width:18px;text-align:center}
|
||||||
|
.sidebar-footer{padding:12px 14px;border-top:1px solid var(--g700)}
|
||||||
|
.user-info{display:flex;align-items:center;gap:8px;padding:6px 4px;margin-bottom:8px}
|
||||||
|
.user-info .av{width:32px;height:32px;border-radius:50%;background:var(--p);display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:600;flex-shrink:0}
|
||||||
|
.user-info .dt{flex:1;min-width:0}
|
||||||
|
.user-info .dt .nm{font-size:13px;font-weight:600;color:white}
|
||||||
|
.user-info .dt .rl{font-size:11px;color:var(--g400)}
|
||||||
|
|
||||||
|
#main-content{flex:1;margin-left:var(--sw)}
|
||||||
|
#page-header{display:flex;align-items:center;justify-content:space-between;padding:14px 22px;background:white;border-bottom:1px solid var(--g200);position:sticky;top:0;z-index:50}
|
||||||
|
#page-title{font-size:18px;font-weight:700}
|
||||||
|
#page-actions{display:flex;gap:8px;align-items:center}
|
||||||
|
#page-content{padding:20px 22px}
|
||||||
|
|
||||||
|
.card{background:white;border-radius:var(--r);box-shadow:var(--sh);margin-bottom:16px}
|
||||||
|
.card-h{padding:12px 18px;border-bottom:1px solid var(--g200);font-size:14px;font-weight:600;display:flex;align-items:center;justify-content:space-between}
|
||||||
|
.card-b{padding:16px 18px}
|
||||||
|
.card-f{padding:10px 18px;border-top:1px solid var(--g200);font-size:12px;color:var(--g500)}
|
||||||
|
|
||||||
|
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:20px}
|
||||||
|
.stat-card{background:white;border-radius:var(--r);padding:18px;box-shadow:var(--sh);display:flex;align-items:center;gap:14px}
|
||||||
|
.stat-card .si{width:44px;height:44px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0}
|
||||||
|
.stat-card .sv{font-size:26px;font-weight:700;line-height:1.2}
|
||||||
|
.stat-card .sl{font-size:12px;color:var(--g500)}
|
||||||
|
|
||||||
|
.table-wrap{overflow-x:auto}
|
||||||
|
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||||
|
thead th{background:var(--g50);padding:9px 12px;text-align:left;font-weight:600;font-size:11px;text-transform:uppercase;color:var(--g500);border-bottom:2px solid var(--g200);white-space:nowrap}
|
||||||
|
tbody td{padding:9px 12px;border-bottom:1px solid var(--g100);vertical-align:middle}
|
||||||
|
tbody tr:hover{background:var(--g50)}
|
||||||
|
|
||||||
|
.badge{display:inline-block;padding:2px 9px;border-radius:20px;font-size:11px;font-weight:600;white-space:nowrap}
|
||||||
|
.bg-pending{background:#fef3c7;color:#92400e}.bg-processing{background:#ede9fe;color:#5b21b6}
|
||||||
|
.bg-resolved{background:#d1fae5;color:#065f46}.bg-rejected{background:#fee2e2;color:#991b1b}
|
||||||
|
.bg-closed{background:var(--g200);color:var(--g700)}.bg-report{background:#fce7f3;color:#9d174d}
|
||||||
|
.bg-suggestion{background:#dbeafe;color:#1e40af}.bg-low{background:#d1fae5;color:#065f46}
|
||||||
|
.bg-medium{background:#fef3c7;color:#92400e}.bg-high{background:#fee2e2;color:#991b1b}
|
||||||
|
.bg-urgent{background:#fecaca;color:#7f1d1d}
|
||||||
|
.bg-owner{background:#ef4444;color:white}.bg-admin{background:#3b82f6;color:white}
|
||||||
|
.bg-player{background:var(--g300);color:var(--g700)}
|
||||||
|
|
||||||
|
.filter-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:14px}
|
||||||
|
.filter-bar select,.filter-bar input{padding:7px 10px;border:1px solid var(--g300);border-radius:var(--r);font-size:13px;background:white}
|
||||||
|
.filter-bar input{min-width:180px}
|
||||||
|
|
||||||
|
#modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:1000;padding:20px}
|
||||||
|
.modal-box{background:white;border-radius:12px;width:600px;max-width:100%;max-height:85vh;display:flex;flex-direction:column;box-shadow:0 8px 30px rgba(0,0,0,.2)}
|
||||||
|
.modal-h{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid var(--g200)}
|
||||||
|
.modal-h h3{font-size:15px;font-weight:600}
|
||||||
|
.modal-h button{background:none;border:none;font-size:22px;cursor:pointer;color:var(--g500);line-height:1}
|
||||||
|
.modal-h button:hover{color:var(--g800)}
|
||||||
|
.modal-b{padding:18px;overflow-y:auto;flex:1}
|
||||||
|
.modal-f{display:flex;gap:8px;justify-content:flex-end;padding:12px 18px;border-top:1px solid var(--g200)}
|
||||||
|
|
||||||
|
.detail-grid{display:grid;grid-template-columns:2fr 1fr;gap:18px}
|
||||||
|
.detail-section{margin-bottom:18px}
|
||||||
|
.detail-section h4{font-size:13px;font-weight:600;color:var(--g700);margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid var(--g200)}
|
||||||
|
.detail-row{display:flex;padding:5px 0;font-size:13px}
|
||||||
|
.detail-row .lbl{width:90px;color:var(--g500);flex-shrink:0}
|
||||||
|
.detail-row .val{flex:1}
|
||||||
|
|
||||||
|
.timeline{position:relative;padding-left:22px}
|
||||||
|
.timeline::before{content:'';position:absolute;left:7px;top:0;bottom:0;width:2px;background:var(--g200)}
|
||||||
|
.timeline-item{position:relative;margin-bottom:14px}
|
||||||
|
.timeline-item::before{content:'';position:absolute;left:-19px;top:18px;width:10px;height:10px;border-radius:50%;background:var(--p);border:2px solid white;box-shadow:var(--sh)}
|
||||||
|
.timeline-item .th{display:flex;align-items:center;gap:6px;margin-bottom:2px;font-size:11px;color:var(--g500)}
|
||||||
|
.timeline-item .th .tn{font-weight:600;color:var(--g700)}
|
||||||
|
.timeline-item .tc{background:var(--g50);padding:9px 12px;border-radius:var(--r);font-size:13px;line-height:1.6;white-space:pre-wrap}
|
||||||
|
.timeline-item.staff .tc{background:var(--pl)}
|
||||||
|
|
||||||
|
.empty{text-align:center;padding:50px 20px;color:var(--g500)}
|
||||||
|
.empty i{font-size:40px;margin-bottom:10px;color:var(--g300)}
|
||||||
|
|
||||||
|
.alert{padding:10px 14px;border-radius:var(--r);font-size:13px;margin-bottom:12px}
|
||||||
|
.alert-s{background:#d1fae5;color:#065f46;border:1px solid #a7f3d0}
|
||||||
|
.alert-e{background:#fee2e2;color:#991b1b;border:1px solid #fecaca}
|
||||||
|
.alert-i{background:#dbeafe;color:#1e40af;border:1px solid #bfdbfe}
|
||||||
|
|
||||||
|
.loading{text-align:center;padding:40px;color:var(--g500)}
|
||||||
|
.loading i{font-size:28px;animation:spin 1s linear infinite}
|
||||||
|
@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}}
|
||||||
|
|
||||||
|
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||||
|
.grid-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}
|
||||||
|
.mt-2{margin-top:8px}.mt-4{margin-top:16px}.mb-4{margin-bottom:16px}
|
||||||
|
.flex{display:flex}.g2{gap:8px}.g4{gap:16px}.aic{align-items:center}.jcb{justify-content:space-between}
|
||||||
|
.ts{font-size:12px}.tm{color:var(--g500)}.fb{font-weight:700}.tc{text-align:center}.tr{text-align:right}
|
||||||
|
|
||||||
|
.file-upload{border:2px dashed var(--g300);border-radius:var(--r);padding:24px;text-align:center;cursor:pointer;transition:.2s}
|
||||||
|
.file-upload:hover{border-color:var(--p);background:var(--pl)}
|
||||||
|
.file-upload i{font-size:28px;color:var(--g400);margin-bottom:6px}
|
||||||
|
.file-upload p{font-size:13px;color:var(--g500)}
|
||||||
|
.file-list{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}
|
||||||
|
.file-item{display:flex;align-items:center;gap:6px;background:var(--g100);padding:4px 10px;border-radius:var(--r);font-size:12px}
|
||||||
|
.file-item button{background:none;border:none;color:var(--d);cursor:pointer;font-size:14px}
|
||||||
|
|
||||||
|
.tab-nav{display:flex;gap:0;border-bottom:1px solid var(--g200);margin-bottom:14px}
|
||||||
|
.tab-nav button{padding:8px 18px;border:none;background:none;font-size:13px;font-weight:500;color:var(--g500);cursor:pointer;border-bottom:2px solid transparent;transition:.2s}
|
||||||
|
.tab-nav button:hover{color:var(--g700)}
|
||||||
|
.tab-nav button.active{color:var(--p);border-bottom-color:var(--p)}
|
||||||
|
|
||||||
|
@media(max-width:768px){
|
||||||
|
#sidebar{width:60px;overflow:hidden}
|
||||||
|
#sidebar .sidebar-header span,#sidebar .nav-item span,#sidebar .user-info .dt{display:none}
|
||||||
|
.sidebar-header{padding:14px 12px;justify-content:center}
|
||||||
|
.nav-item{padding:10px 12px;justify-content:center}
|
||||||
|
#main-content{margin-left:60px}
|
||||||
|
.detail-grid{grid-template-columns:1fr}
|
||||||
|
.grid-2,.grid-3{grid-template-columns:1fr}
|
||||||
|
}
|
||||||
55
public/index.html
Normal file
55
public/index.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MC举报系统</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="public-page" class="page hidden"></div>
|
||||||
|
<div id="main-layout" class="hidden">
|
||||||
|
<aside id="sidebar">
|
||||||
|
<div class="sidebar-header"><i class="fas fa-shield-halved"></i><span>举报系统</span></div>
|
||||||
|
<nav id="sidebar-nav"></nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div id="user-info"></div>
|
||||||
|
<button class="btn btn-sm btn-outline w-full" onclick="App.logout()"><i class="fas fa-sign-out-alt"></i> 退出</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main id="main-content">
|
||||||
|
<header id="page-header"><h1 id="page-title"></h1><div id="page-actions"></div></header>
|
||||||
|
<div id="page-alert"></div>
|
||||||
|
<div id="page-content"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-overlay" class="hidden">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-h"><h3 id="modal-title"></h3><button onclick="App.closeModal()">×</button></div>
|
||||||
|
<div class="modal-b" id="modal-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/utils.js"></script>
|
||||||
|
<script src="js/api.js"></script>
|
||||||
|
<script src="js/auth.js"></script>
|
||||||
|
<script src="js/pages/install.js"></script>
|
||||||
|
<script src="js/pages/login.js"></script>
|
||||||
|
<script src="js/pages/register.js"></script>
|
||||||
|
<script src="js/pages/home.js"></script>
|
||||||
|
<script src="js/pages/dashboard.js"></script>
|
||||||
|
<script src="js/pages/tickets.js"></script>
|
||||||
|
<script src="js/pages/ticket-create.js"></script>
|
||||||
|
<script src="js/pages/ticket-detail.js"></script>
|
||||||
|
<script src="js/pages/tracking.js"></script>
|
||||||
|
<script src="js/pages/users.js"></script>
|
||||||
|
<script src="js/pages/templates.js"></script>
|
||||||
|
<script src="js/pages/settings-page.js"></script>
|
||||||
|
<script src="js/pages/export-page.js"></script>
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
45
public/js/api.js
Normal file
45
public/js/api.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
const API = {
|
||||||
|
base: '/api',
|
||||||
|
_key: window.__API_KEY__ || '',
|
||||||
|
|
||||||
|
async req(method, path, data) {
|
||||||
|
const token = Auth.token();
|
||||||
|
const headers = {};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
if (this._key) headers['x-api-key'] = this._key;
|
||||||
|
const opts = { method, headers };
|
||||||
|
if (data instanceof FormData) opts.body = data;
|
||||||
|
else if (data && method !== 'GET') { headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(data); }
|
||||||
|
opts.headers = headers;
|
||||||
|
const res = await fetch(this.base + path, opts);
|
||||||
|
const ct = res.headers.get('content-type');
|
||||||
|
if (ct && ct.includes('application/json')) {
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) throw new Error(json.error || '请求失败');
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error('请求失败');
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
|
||||||
|
get(p) { return this.req('GET', p); },
|
||||||
|
post(p, d) { return this.req('POST', p, d); },
|
||||||
|
put(p, d) { return this.req('PUT', p, d); },
|
||||||
|
|
||||||
|
async download(path) {
|
||||||
|
const token = Auth.token();
|
||||||
|
const h = {};
|
||||||
|
if (token) h['Authorization'] = `Bearer ${token}`;
|
||||||
|
if (this._key) h['x-api-key'] = this._key;
|
||||||
|
const res = await fetch(this.base + path, { headers: h });
|
||||||
|
if (!res.ok) throw new Error('下载失败');
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
const disp = res.headers.get('content-disposition');
|
||||||
|
a.download = disp?.match(/filename=(.+)/)?.[1] || 'export.csv';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
106
public/js/app.js
Normal file
106
public/js/app.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
const App = {
|
||||||
|
navItems: [
|
||||||
|
{ id: 'dashboard', label: '控制台', icon: 'fa-chart-simple', roles: ['owner','admin','player'] },
|
||||||
|
{ id: 'unclaimed', label: '待处理工单', icon: 'fa-inbox', roles: ['owner','admin'] },
|
||||||
|
{ id: 'tickets', label: '工单列表', icon: 'fa-ticket-alt', roles: ['owner','admin','player'] },
|
||||||
|
{ id: 'tracking', label: '追踪工单', icon: 'fa-search', roles: ['owner','admin','player'] },
|
||||||
|
{ id: 'users', label: '用户管理', icon: 'fa-users', roles: ['owner','admin'] },
|
||||||
|
{ id: 'notifications', label: '通知配置', icon: 'fa-bell', roles: ['owner','admin'] },
|
||||||
|
{ id: 'templates', label: '邮件模板', icon: 'fa-envelope', roles: ['owner','admin'] },
|
||||||
|
{ id: 'settings', label: '系统设置', icon: 'fa-cog', roles: ['owner','admin'] },
|
||||||
|
{ id: 'export', label: '数据导出', icon: 'fa-download', roles: ['owner'] },
|
||||||
|
],
|
||||||
|
|
||||||
|
init() {
|
||||||
|
window.addEventListener('hashchange', () => this.route());
|
||||||
|
this.route();
|
||||||
|
},
|
||||||
|
|
||||||
|
afterLogin() { location.hash = '#/dashboard'; },
|
||||||
|
logout() { Auth.logout(); },
|
||||||
|
closeModal() { document.getElementById('modal-overlay').classList.add('hidden'); },
|
||||||
|
navigate(page, param) { location.hash = `#/${page}` + (param ? `/${param}` : ''); },
|
||||||
|
|
||||||
|
async route() {
|
||||||
|
const hash = location.hash || '#/home';
|
||||||
|
const parts = hash.replace('#/', '').split('/');
|
||||||
|
const page = parts[0];
|
||||||
|
const param = parts.slice(1).join('/');
|
||||||
|
|
||||||
|
if (page !== 'install') {
|
||||||
|
try {
|
||||||
|
const s = await API.get('/install/status');
|
||||||
|
if (!s.installed) { location.hash = '#/install'; return; }
|
||||||
|
} catch { location.hash = '#/install'; return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page === 'verify') {
|
||||||
|
const params = new URLSearchParams((location.hash.split('?')[1] || ''));
|
||||||
|
const token = params.get('token');
|
||||||
|
if (token) {
|
||||||
|
API.get(`/auth/verify-email?token=${token}`).then(r => { alert(r.message || '验证成功'); location.hash = '#/login'; }).catch(e => { alert(e.message); location.hash = '#/home'; });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page === 'install') { this.showPublic('install'); return; }
|
||||||
|
if (page === 'home' || page === 'login' || page === 'register') { this.showPublic(page); return; }
|
||||||
|
if (!Auth.logged()) { location.hash = '#/login'; return; }
|
||||||
|
|
||||||
|
this.showLayout();
|
||||||
|
this.renderSidebar(page);
|
||||||
|
|
||||||
|
switch (page) {
|
||||||
|
case 'dashboard': this.renderMain('控制台', Dashboard, param); break;
|
||||||
|
case 'unclaimed': this.renderMain('待处理工单', UnclaimedPage, param); break;
|
||||||
|
case 'tickets':
|
||||||
|
if (param === 'create') {
|
||||||
|
const q = new URLSearchParams((location.hash.split('?')[1] || ''));
|
||||||
|
document.getElementById('page-title').textContent = '创建工单';
|
||||||
|
document.getElementById('page-actions').innerHTML = '<button class="btn btn-o btn-sm" onclick="App.navigate(\'tickets\')"><i class="fas fa-arrow-left"></i> 返回</button>';
|
||||||
|
TicketCreate.render(document.getElementById('page-content'), { type: q.get('type') || 'report', prefill: { game_name: Auth.user()?.game_name, game_uid: Auth.user()?.game_uid }, backLink: "App.navigate('tickets')" });
|
||||||
|
} else this.renderMain('工单列表', TicketsPage, param);
|
||||||
|
break;
|
||||||
|
case 'ticket': this.renderMain('工单详情', TicketDetail, param); break;
|
||||||
|
case 'tracking': this.renderMain('追踪工单', TrackingPage, param); break;
|
||||||
|
case 'users': this.renderMain('用户管理', UsersPage, param); break;
|
||||||
|
case 'notifications': this.renderMain('通知配置', NotificationsPage, param); break;
|
||||||
|
case 'templates': this.renderMain('邮件模板', TemplatesPage, param); break;
|
||||||
|
case 'settings': this.renderMain('系统设置', SettingsPage, param); break;
|
||||||
|
case 'export': this.renderMain('数据导出', ExportPage, param); break;
|
||||||
|
default: this.renderMain('控制台', Dashboard, param);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
showPublic(page) {
|
||||||
|
document.getElementById('main-layout').classList.add('hidden');
|
||||||
|
const pub = document.getElementById('public-page');
|
||||||
|
pub.classList.remove('hidden');
|
||||||
|
const comp = page === 'login' ? LoginPage : page === 'register' ? RegisterPage : page === 'install' ? InstallPage : HomePage;
|
||||||
|
comp.render().then(html => { pub.innerHTML = html; if (comp.mount) comp.mount(); });
|
||||||
|
},
|
||||||
|
|
||||||
|
showLayout() {
|
||||||
|
document.getElementById('public-page').classList.add('hidden');
|
||||||
|
document.getElementById('main-layout').classList.remove('hidden');
|
||||||
|
const u = Auth.user();
|
||||||
|
document.getElementById('user-info').innerHTML = `<div class="av">${(u.game_name||u.username).charAt(0).toUpperCase()}</div><div class="dt"><div class="nm">${u.game_name||u.username}</div><div class="rl">${U.ROLE[u.role]}</div></div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderSidebar(current) {
|
||||||
|
const u = Auth.user();
|
||||||
|
document.getElementById('sidebar-nav').innerHTML = this.navItems
|
||||||
|
.filter(i => i.roles.includes(u.role))
|
||||||
|
.map(i => `<div class="nav-item ${current===i.id?'active':''}" onclick="App.navigate('${i.id}')"><i class="fas ${i.icon}"></i><span>${i.label}</span></div>`).join('');
|
||||||
|
},
|
||||||
|
|
||||||
|
async renderMain(title, page, param) {
|
||||||
|
document.getElementById('page-title').textContent = title;
|
||||||
|
document.getElementById('page-actions').innerHTML = '';
|
||||||
|
const ct = document.getElementById('page-content');
|
||||||
|
ct.innerHTML = await page.render(param);
|
||||||
|
if (page.mount) await page.mount(param);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => App.init());
|
||||||
30
public/js/auth.js
Normal file
30
public/js/auth.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const Auth = {
|
||||||
|
TK: 'mc_token',
|
||||||
|
US: 'mc_user',
|
||||||
|
|
||||||
|
token() { return localStorage.getItem(this.TK); },
|
||||||
|
user() { try { return JSON.parse(localStorage.getItem(this.US)); } catch { return null; } },
|
||||||
|
logged() { return !!this.token(); },
|
||||||
|
|
||||||
|
async login(username, password) {
|
||||||
|
const d = await API.post('/auth/login', { username, password });
|
||||||
|
localStorage.setItem(this.TK, d.token);
|
||||||
|
localStorage.setItem(this.US, JSON.stringify(d.user));
|
||||||
|
return d.user;
|
||||||
|
},
|
||||||
|
|
||||||
|
async register(data) {
|
||||||
|
return API.post('/auth/register', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
localStorage.removeItem(this.TK);
|
||||||
|
localStorage.removeItem(this.US);
|
||||||
|
location.hash = '#/login';
|
||||||
|
},
|
||||||
|
|
||||||
|
role(...r) { const u = this.user(); return u && r.includes(u.role); },
|
||||||
|
isStaff() { return this.role('owner', 'admin'); },
|
||||||
|
isAdmin() { return this.role('owner', 'admin'); },
|
||||||
|
isOwner() { return this.role('owner'); },
|
||||||
|
};
|
||||||
322
public/js/pages/complaint-detail.js
Normal file
322
public/js/pages/complaint-detail.js
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
const ComplaintDetailPage = {
|
||||||
|
complaintId: null,
|
||||||
|
|
||||||
|
async render(id) {
|
||||||
|
this.complaintId = id;
|
||||||
|
return '<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>';
|
||||||
|
},
|
||||||
|
|
||||||
|
async mount() {
|
||||||
|
const container = document.getElementById('page-content');
|
||||||
|
const backBtn = '<button class="btn btn-outline btn-sm" onclick="App.navigate(\'complaints\')"><i class="fas fa-arrow-left"></i> 返回</button>';
|
||||||
|
document.getElementById('page-title').textContent = `投诉 #${this.complaintId}`;
|
||||||
|
document.getElementById('page-actions').innerHTML = backBtn;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const complaint = await API.get(`/complaints/${this.complaintId}`);
|
||||||
|
this.renderDetail(container, complaint);
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = `<div class="alert alert-danger">加载失败: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderDetail(container, c) {
|
||||||
|
const user = Auth.getUser();
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span>基本信息</span>
|
||||||
|
<div>${Utils.statusBadge(c.status)} ${Utils.priorityBadge(c.priority)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="detail-row"><span class="label">标题</span><span class="value font-bold">${Utils.escapeHtml(c.title)}</span></div>
|
||||||
|
<div class="detail-row"><span class="label">描述</span><span class="value">${Utils.escapeHtml(c.description)}</span></div>
|
||||||
|
<div class="detail-row"><span class="label">分类</span><span class="value">${Utils.categoryBadge(c.category)}</span></div>
|
||||||
|
<div class="detail-row"><span class="label">玩家</span><span class="value">${Utils.escapeHtml(c.player_name)}</span></div>
|
||||||
|
${c.player_email ? `<div class="detail-row"><span class="label">邮箱</span><span class="value">${Utils.escapeHtml(c.player_email)}</span></div>` : ''}
|
||||||
|
${c.player_contact ? `<div class="detail-row"><span class="label">联系方式</span><span class="value">${Utils.escapeHtml(c.player_contact)}</span></div>` : ''}
|
||||||
|
<div class="detail-row"><span class="label">创建时间</span><span class="value">${Utils.formatDate(c.created_at)}</span></div>
|
||||||
|
<div class="detail-row"><span class="label">更新时间</span><span class="value">${Utils.formatDate(c.updated_at)}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${c.plan ? this.renderPlanSection(c) : ''}
|
||||||
|
${c.implementation ? this.renderImplementationSection(c) : ''}
|
||||||
|
${c.review ? this.renderReviewSection(c) : ''}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">沟通记录</div>
|
||||||
|
<div class="card-body">
|
||||||
|
${c.responses && c.responses.length > 0 ? `
|
||||||
|
<div class="timeline">
|
||||||
|
${c.responses.map(r => `
|
||||||
|
<div class="timeline-item ${r.response_type === 'system' ? 'system' : ''}">
|
||||||
|
<div class="timeline-header">
|
||||||
|
<span class="name">${Utils.escapeHtml(r.user_name) || '系统'}</span>
|
||||||
|
${r.user_role ? `<span class="badge badge-${r.user_role}">${Utils.roleLabels[r.user_role]}</span>` : ''}
|
||||||
|
<span>${Utils.formatDate(r.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-content">${Utils.escapeHtml(r.content)}</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
` : '<p class="text-muted">暂无记录</p>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">添加回复</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="add-response-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<textarea id="response-content" rows="3" placeholder="输入回复内容..." required></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm"><i class="fas fa-reply"></i> 发送</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">人员分配</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="detail-row"><span class="label">规划者</span><span class="value">${c.planner_name || '<span class="text-muted">未分配</span>'}</span></div>
|
||||||
|
<div class="detail-row"><span class="label">实施者</span><span class="value">${c.implementer_name || '<span class="text-muted">未分配</span>'}</span></div>
|
||||||
|
<div class="detail-row"><span class="label">审查者</span><span class="value">${c.reviewer_name || '<span class="text-muted">未分配</span>'}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">操作</div>
|
||||||
|
<div class="card-body" style="display:flex;flex-direction:column;gap:8px">
|
||||||
|
${user && (user.role === 'planner' || user.role === 'admin') && !c.plan ? `
|
||||||
|
<button class="btn btn-primary btn-sm w-full" onclick="ComplaintDetailPage.createPlan(${c.id})">
|
||||||
|
<i class="fas fa-clipboard-list"></i> 创建处理计划
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
${user && (user.role === 'planner' || user.role === 'admin') && c.plan && c.plan.status === 'draft' ? `
|
||||||
|
<button class="btn btn-success btn-sm w-full" onclick="ComplaintDetailPage.assignImplementer(${c.id}, ${c.plan.id})">
|
||||||
|
<i class="fas fa-user-tag"></i> 分配实施者
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
${user && (user.role === 'implementer' || user.role === 'admin') && c.plan && c.implementer_name === user.display_name && !c.implementation ? `
|
||||||
|
<button class="btn btn-info btn-sm w-full" onclick="ComplaintDetailPage.startImplementation(${c.id}, ${c.plan.id})">
|
||||||
|
<i class="fas fa-code"></i> 开始实施
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
${user && (user.role === 'reviewer' || user.role === 'admin') && c.implementation && c.implementation.status === 'completed' && !c.review ? `
|
||||||
|
<button class="btn btn-warning btn-sm w-full" onclick="ComplaintDetailPage.startReview(${c.id}, ${c.implementation.id})">
|
||||||
|
<i class="fas fa-search"></i> 开始审查
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
${user && (user.role === 'admin') ? `
|
||||||
|
<button class="btn btn-outline btn-sm w-full" onclick="ComplaintDetailPage.showUpdateStatusModal(${c.id})">
|
||||||
|
<i class="fas fa-edit"></i> 修改状态
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('add-response-form')?.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const content = document.getElementById('response-content').value.trim();
|
||||||
|
if (!content) return;
|
||||||
|
try {
|
||||||
|
await API.post(`/complaints/${c.id}/response`, { content });
|
||||||
|
document.getElementById('response-content').value = '';
|
||||||
|
this.mount();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
renderPlanSection(c) {
|
||||||
|
const p = c.plan;
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span>处理计划</span>
|
||||||
|
<span>${Utils.statusBadge(p.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="detail-row"><span class="label">标题</span><span class="value font-bold">${Utils.escapeHtml(p.title)}</span></div>
|
||||||
|
<div class="detail-row"><span class="label">描述</span><span class="value">${Utils.escapeHtml(p.description)}</span></div>
|
||||||
|
${p.technical_approach ? `<div class="detail-row"><span class="label">技术方案</span><span class="value" style="white-space:pre-wrap">${Utils.escapeHtml(p.technical_approach)}</span></div>` : ''}
|
||||||
|
${p.expected_effort ? `<div class="detail-row"><span class="label">预计工时</span><span class="value">${p.expected_effort}</span></div>` : ''}
|
||||||
|
${p.reject_reason ? `<div class="detail-row"><span class="label">驳回原因</span><span class="value" style="color:var(--danger)">${Utils.escapeHtml(p.reject_reason)}</span></div>` : ''}
|
||||||
|
<div class="detail-row"><span class="label">规划者</span><span class="value">${p.planner_name || '-'}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderImplementationSection(c) {
|
||||||
|
const i = c.implementation;
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span>实施情况</span>
|
||||||
|
<span>${Utils.statusBadge(i.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
${i.code_reference ? `<div class="detail-row"><span class="label">代码参考</span><span class="value" style="white-space:pre-wrap">${Utils.escapeHtml(i.code_reference)}</span></div>` : ''}
|
||||||
|
${i.implementation_notes ? `<div class="detail-row"><span class="label">实施说明</span><span class="value" style="white-space:pre-wrap">${Utils.escapeHtml(i.implementation_notes)}</span></div>` : ''}
|
||||||
|
${i.reject_reason ? `<div class="detail-row"><span class="label">驳回原因</span><span class="value" style="color:var(--danger)">${Utils.escapeHtml(i.reject_reason)}</span></div>` : ''}
|
||||||
|
<div class="detail-row"><span class="label">实施者</span><span class="value">${i.implementer_name || '-'}</span></div>
|
||||||
|
${i.status === 'in_progress' ? `
|
||||||
|
<div class="mt-4">
|
||||||
|
<form id="complete-implementation-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>代码参考/PR链接</label>
|
||||||
|
<input type="text" id="impl-code" placeholder="Git提交号/PR链接等">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>实施说明</label>
|
||||||
|
<textarea id="impl-notes" rows="3" placeholder="实施过程中的说明..."></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success btn-sm"><i class="fas fa-check"></i> 标记完成</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
renderReviewSection(c) {
|
||||||
|
const r = c.review;
|
||||||
|
return `
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span>审查结果</span>
|
||||||
|
<span>${Utils.statusBadge(r.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
${r.review_notes ? `<div class="detail-row"><span class="label">审查意见</span><span class="value" style="white-space:pre-wrap">${Utils.escapeHtml(r.review_notes)}</span></div>` : ''}
|
||||||
|
${r.issues_found ? `<div class="detail-row"><span class="label">发现的问题</span><span class="value" style="white-space:pre-wrap">${Utils.escapeHtml(r.issues_found)}</span></div>` : ''}
|
||||||
|
${r.reject_reason ? `<div class="detail-row"><span class="label">驳回原因</span><span class="value" style="color:var(--danger)">${Utils.escapeHtml(r.reject_reason)}</span></div>` : ''}
|
||||||
|
<div class="detail-row"><span class="label">审查者</span><span class="value">${r.reviewer_name || '-'}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
await this.mount();
|
||||||
|
},
|
||||||
|
|
||||||
|
async createPlan(complaintId) {
|
||||||
|
Utils.openModal('创建处理计划', `
|
||||||
|
<form id="create-plan-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>计划标题 *</label>
|
||||||
|
<input type="text" id="plan-title" required placeholder="例如: 修复闪退问题">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>计划描述 *</label>
|
||||||
|
<textarea id="plan-description" required rows="4" placeholder="详细描述处理方案..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>技术方案</label>
|
||||||
|
<textarea id="plan-tech" rows="3" placeholder="技术实现方案..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>预计工时</label>
|
||||||
|
<input type="text" id="plan-effort" placeholder="例如: 2小时">
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="padding:16px 0 0;border-top:1px solid var(--gray-200)">
|
||||||
|
<button type="button" class="btn btn-outline" onclick="App.closeModal()">取消</button>
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> 创建</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`);
|
||||||
|
document.getElementById('create-plan-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await API.post('/plans', {
|
||||||
|
complaint_id: complaintId,
|
||||||
|
title: document.getElementById('plan-title').value,
|
||||||
|
description: document.getElementById('plan-description').value,
|
||||||
|
technical_approach: document.getElementById('plan-tech').value,
|
||||||
|
expected_effort: document.getElementById('plan-effort').value,
|
||||||
|
});
|
||||||
|
App.closeModal();
|
||||||
|
this.refresh();
|
||||||
|
} catch (err) { alert(err.message); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async assignImplementer(complaintId, planId) {
|
||||||
|
const users = await API.get('/users/implementers');
|
||||||
|
if (!users.length) { alert('暂无可用实施者'); return; }
|
||||||
|
Utils.openModal('分配实施者', `
|
||||||
|
<form id="assign-impl-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>选择实施者</label>
|
||||||
|
<select id="assign-impl-user">
|
||||||
|
${users.map(u => `<option value="${u.id}">${u.display_name} (${u.username})</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="padding:16px 0 0;border-top:1px solid var(--gray-200)">
|
||||||
|
<button type="button" class="btn btn-outline" onclick="App.closeModal()">取消</button>
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="fas fa-user-tag"></i> 分配</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`);
|
||||||
|
document.getElementById('assign-impl-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await API.post(`/plans/${planId}/assign`, { implementer_id: parseInt(document.getElementById('assign-impl-user').value) });
|
||||||
|
App.closeModal();
|
||||||
|
this.refresh();
|
||||||
|
} catch (err) { alert(err.message); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async startImplementation(complaintId, planId) {
|
||||||
|
try {
|
||||||
|
await API.post('/implementations', { plan_id: planId });
|
||||||
|
this.refresh();
|
||||||
|
} catch (err) { alert(err.message); }
|
||||||
|
},
|
||||||
|
|
||||||
|
async startReview(complaintId, implementationId) {
|
||||||
|
try {
|
||||||
|
await API.post('/reviews', { implementation_id: implementationId });
|
||||||
|
this.refresh();
|
||||||
|
} catch (err) { alert(err.message); }
|
||||||
|
},
|
||||||
|
|
||||||
|
showUpdateStatusModal(complaintId) {
|
||||||
|
Utils.openModal('修改状态', `
|
||||||
|
<form id="update-status-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>新状态</label>
|
||||||
|
<select id="new-status">
|
||||||
|
${Object.entries(Utils.statusLabels).map(([k,v]) => `<option value="${k}">${v}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="padding:16px 0 0;border-top:1px solid var(--gray-200)">
|
||||||
|
<button type="button" class="btn btn-outline" onclick="App.closeModal()">取消</button>
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> 保存</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`);
|
||||||
|
document.getElementById('update-status-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await API.put(`/complaints/${complaintId}`, { status: document.getElementById('new-status').value });
|
||||||
|
App.closeModal();
|
||||||
|
this.refresh();
|
||||||
|
} catch (err) { alert(err.message); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
188
public/js/pages/complaints.js
Normal file
188
public/js/pages/complaints.js
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
const ComplaintsPage = {
|
||||||
|
async render() {
|
||||||
|
return '<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>';
|
||||||
|
},
|
||||||
|
|
||||||
|
async mount() {
|
||||||
|
const container = document.getElementById('page-content');
|
||||||
|
document.getElementById('page-title').textContent = '投诉列表';
|
||||||
|
document.getElementById('page-actions').innerHTML = '<button class="btn btn-primary" onclick="ComplaintsPage.showCreateModal()"><i class="fas fa-plus"></i> 提交投诉</button>';
|
||||||
|
|
||||||
|
await this.loadList(container);
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadList(container, filters = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.status) params.set('status', filters.status);
|
||||||
|
if (filters.priority) params.set('priority', filters.priority);
|
||||||
|
if (filters.category) params.set('category', filters.category);
|
||||||
|
if (filters.search) params.set('search', filters.search);
|
||||||
|
const qs = params.toString();
|
||||||
|
|
||||||
|
container.innerHTML = '<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const complaints = await API.get('/complaints' + (qs ? '?' + qs : ''));
|
||||||
|
this.renderList(container, complaints, filters);
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = `<div class="alert alert-danger">加载失败: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderList(container, complaints, filters = {}) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="filter-bar">
|
||||||
|
<select id="filter-status" onchange="ComplaintsPage.applyFilters()">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
${Object.entries(Utils.statusLabels).map(([k,v]) => `<option value="${k}" ${filters.status === k ? 'selected' : ''}>${v}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<select id="filter-priority" onchange="ComplaintsPage.applyFilters()">
|
||||||
|
<option value="">全部优先级</option>
|
||||||
|
${Object.entries(Utils.priorityLabels).map(([k,v]) => `<option value="${k}" ${filters.priority === k ? 'selected' : ''}>${v}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<select id="filter-category" onchange="ComplaintsPage.applyFilters()">
|
||||||
|
<option value="">全部分类</option>
|
||||||
|
${Object.entries(Utils.categoryLabels).map(([k,v]) => `<option value="${k}" ${filters.category === k ? 'selected' : ''}>${v}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<input id="filter-search" placeholder="搜索标题/描述/玩家..." value="${filters.search || ''}" onkeyup="if(event.key==='Enter')ComplaintsPage.applyFilters()">
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="ComplaintsPage.applyFilters()"><i class="fas fa-search"></i> 搜索</button>
|
||||||
|
<button class="btn btn-outline btn-sm" onclick="ComplaintsPage.resetFilters()"><i class="fas fa-undo"></i> 重置</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${complaints.length === 0 ? `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-inbox"></i>
|
||||||
|
<p>暂无投诉记录</p>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>标题</th>
|
||||||
|
<th>玩家</th>
|
||||||
|
<th>分类</th>
|
||||||
|
<th>优先级</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>规划者</th>
|
||||||
|
<th>实施者</th>
|
||||||
|
<th>审查者</th>
|
||||||
|
<th>更新时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${complaints.map(c => `
|
||||||
|
<tr>
|
||||||
|
<td>${c.id}</td>
|
||||||
|
<td><strong><a href="#" onclick="App.navigate('complaint',${c.id});return false" style="color:var(--primary);text-decoration:none">${Utils.escapeHtml(c.title)}</a></strong></td>
|
||||||
|
<td>${Utils.escapeHtml(c.player_name)}</td>
|
||||||
|
<td>${Utils.categoryBadge(c.category)}</td>
|
||||||
|
<td>${Utils.priorityBadge(c.priority)}</td>
|
||||||
|
<td>${Utils.statusBadge(c.status)}</td>
|
||||||
|
<td>${c.planner_name || '-'}</td>
|
||||||
|
<td>${c.implementer_name || '-'}</td>
|
||||||
|
<td>${c.reviewer_name || '-'}</td>
|
||||||
|
<td class="date">${Utils.formatDate(c.updated_at)}</td>
|
||||||
|
<td><button class="btn btn-sm btn-outline" onclick="App.navigate('complaint',${c.id})"><i class="fas fa-eye"></i></button></td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
applyFilters() {
|
||||||
|
const filters = {
|
||||||
|
status: document.getElementById('filter-status').value,
|
||||||
|
priority: document.getElementById('filter-priority').value,
|
||||||
|
category: document.getElementById('filter-category').value,
|
||||||
|
search: document.getElementById('filter-search').value,
|
||||||
|
};
|
||||||
|
const container = document.getElementById('page-content');
|
||||||
|
this.loadList(container, filters);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetFilters() {
|
||||||
|
const container = document.getElementById('page-content');
|
||||||
|
this.loadList(container, {});
|
||||||
|
},
|
||||||
|
|
||||||
|
showCreateModal() {
|
||||||
|
Utils.openModal('提交投诉', `
|
||||||
|
<form id="create-complaint-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>标题 *</label>
|
||||||
|
<input type="text" id="c-title" required placeholder="投诉标题">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>描述 *</label>
|
||||||
|
<textarea id="c-description" required placeholder="请详细描述问题..." rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>分类</label>
|
||||||
|
<select id="c-category">
|
||||||
|
${Object.entries(Utils.categoryLabels).map(([k,v]) => `<option value="${k}">${v}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>优先级</label>
|
||||||
|
<select id="c-priority">
|
||||||
|
<option value="low">低</option>
|
||||||
|
<option value="medium" selected>中</option>
|
||||||
|
<option value="high">高</option>
|
||||||
|
<option value="critical">紧急</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>玩家名称 *</label>
|
||||||
|
<input type="text" id="c-player_name" required placeholder="游戏内昵称">
|
||||||
|
</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>邮箱</label>
|
||||||
|
<input type="email" id="c-player_email" placeholder="选填">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>联系方式</label>
|
||||||
|
<input type="text" id="c-player_contact" placeholder="手机/微信/QQ">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="padding:16px 0 0;border-top:1px solid var(--gray-200)">
|
||||||
|
<button type="button" class="btn btn-outline" onclick="App.closeModal()">取消</button>
|
||||||
|
<button type="submit" class="btn btn-primary"><i class="fas fa-paper-plane"></i> 提交</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`);
|
||||||
|
document.getElementById('create-complaint-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = e.target.querySelector('button[type="submit"]');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 提交中...';
|
||||||
|
try {
|
||||||
|
await API.post('/complaints', {
|
||||||
|
title: document.getElementById('c-title').value,
|
||||||
|
description: document.getElementById('c-description').value,
|
||||||
|
category: document.getElementById('c-category').value,
|
||||||
|
priority: document.getElementById('c-priority').value,
|
||||||
|
player_name: document.getElementById('c-player_name').value,
|
||||||
|
player_email: document.getElementById('c-player_email').value,
|
||||||
|
player_contact: document.getElementById('c-player_contact').value,
|
||||||
|
});
|
||||||
|
App.closeModal();
|
||||||
|
this.mount();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-paper-plane"></i> 提交';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
47
public/js/pages/dashboard.js
Normal file
47
public/js/pages/dashboard.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
const Dashboard = {
|
||||||
|
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||||
|
|
||||||
|
async mount() {
|
||||||
|
const ct = document.getElementById('page-content');
|
||||||
|
document.getElementById('page-title').textContent = '控制台';
|
||||||
|
document.getElementById('page-actions').innerHTML = `
|
||||||
|
<button class="btn btn-p btn-sm" onclick="App.navigate('tickets','create?type=report')"><i class="fas fa-flag"></i> 举报</button>
|
||||||
|
<button class="btn btn-s btn-sm" onclick="App.navigate('tickets','create?type=suggestion')"><i class="fas fa-lightbulb"></i> 建议</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [stats, exports] = await Promise.all([
|
||||||
|
API.get('/tickets/stats'),
|
||||||
|
Auth.isAdmin() ? API.get('/export/dashboard') : null,
|
||||||
|
]);
|
||||||
|
this.renderContent(ct, stats, exports);
|
||||||
|
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
renderContent(ct, stats, exports) {
|
||||||
|
const byStatus = stats.byStatus || {};
|
||||||
|
|
||||||
|
ct.innerHTML = `
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card"><div class="si" style="background:#eef2ff;color:var(--p)"><i class="fas fa-ticket-alt"></i></div><div><div class="sv">${stats.total}</div><div class="sl">工单总数</div></div></div>
|
||||||
|
<div class="stat-card"><div class="si" style="background:#fef3c7;color:#d97706"><i class="fas fa-clock"></i></div><div><div class="sv">${byStatus.pending || 0}</div><div class="sl">待处理</div></div></div>
|
||||||
|
<div class="stat-card"><div class="si" style="background:#ede9fe;color:#7c3aed"><i class="fas fa-cogs"></i></div><div><div class="sv">${byStatus.processing || 0}</div><div class="sl">处理中</div></div></div>
|
||||||
|
<div class="stat-card"><div class="si" style="background:#d1fae5;color:var(--s)"><i class="fas fa-check-circle"></i></div><div><div class="sv">${byStatus.resolved || 0}</div><div class="sl">已解决</div></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${Auth.isAdmin() && exports ? `
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card"><div class="card-h">类型分布</div><div class="card-b">
|
||||||
|
${(exports.byType||[]).map(r => `<div class="detail-row"><span class="lbl">${U.TYPE[r.type]||r.type}</span><span class="val fb">${r.c} 条</span></div>`).join('')}
|
||||||
|
</div></div>
|
||||||
|
<div class="card"><div class="card-h">状态分布</div><div class="card-b">
|
||||||
|
${(exports.byStatus||[]).map(r => `<div class="detail-row"><span class="lbl">${U.STATUS[r.status]||r.status}</span><span class="val fb">${r.c} 条</span></div>`).join('')}
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
${exports.monthly?.length ? `
|
||||||
|
<div class="card"><div class="card-h">月度趋势</div><div class="card-b table-wrap"><table><thead><tr><th>月份</th><th>数量</th></tr></thead><tbody>${exports.monthly.map(m=>`<tr><td>${m.m}</td><td>${m.c}</td></tr>`).join('')}</tbody></table></div></div>` : ''}
|
||||||
|
` : ''}
|
||||||
|
<p class="tm ts tc">提示: 可在"工单列表"中查看和管理所有工单</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
};
|
||||||
88
public/js/pages/export-page.js
Normal file
88
public/js/pages/export-page.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
const ExportPage = {
|
||||||
|
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
const ct = document.getElementById('page-content');
|
||||||
|
document.getElementById('page-title').textContent = '数据导出';
|
||||||
|
document.getElementById('page-actions').innerHTML = '';
|
||||||
|
|
||||||
|
ct.innerHTML = `
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card"><div class="card-h"><i class="fas fa-file-csv"></i> 导出工单</div><div class="card-b">
|
||||||
|
<form id="exp-tickets">
|
||||||
|
<div class="grid-2"><div class="form-group"><label>类型</label><select id="et-type"><option value="">全部</option>${Object.entries(U.TYPE).map(([k,v])=>`<option value="${k}">${v}</option>`).join('')}</select></div>
|
||||||
|
<div class="form-group"><label>状态</label><select id="et-status"><option value="">全部</option>${Object.entries(U.STATUS).map(([k,v])=>`<option value="${k}">${v}</option>`).join('')}</select></div></div>
|
||||||
|
<div class="grid-2"><div class="form-group"><label>开始日期</label><input type="date" id="et-start"></div><div class="form-group"><label>结束日期</label><input type="date" id="et-end"></div></div>
|
||||||
|
<div class="form-group"><label>格式</label><select id="et-fmt"><option value="csv">CSV (Excel)</option><option value="json">JSON</option></select></div>
|
||||||
|
<button type="submit" class="btn btn-p btn-sm"><i class="fas fa-download"></i> 导出</button>
|
||||||
|
</form>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="card"><div class="card-h"><i class="fas fa-user-slash"></i> 被举报人统计导出</div><div class="card-b">
|
||||||
|
<p class="tm mb-4">导出所有被举报玩家列表,含被举报次数、处理结果、常见原因等</p>
|
||||||
|
<form id="exp-reported">
|
||||||
|
<div class="form-group"><label>格式</label><select id="er-fmt"><option value="csv">CSV (Excel)</option><option value="json">JSON</option></select></div>
|
||||||
|
<button type="submit" class="btn btn-w btn-sm"><i class="fas fa-download"></i> 导出被举报人统计</button>
|
||||||
|
</form>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="card"><div class="card-h"><i class="fas fa-history"></i> 导出操作日志</div><div class="card-b">
|
||||||
|
<form id="exp-logs">
|
||||||
|
<div class="grid-2"><div class="form-group"><label>开始日期</label><input type="date" id="el-start"></div><div class="form-group"><label>结束日期</label><input type="date" id="el-end"></div></div>
|
||||||
|
<div class="form-group"><label>格式</label><select id="el-fmt"><option value="csv">CSV (Excel)</option><option value="json">JSON</option></select></div>
|
||||||
|
<button type="submit" class="btn btn-i btn-sm"><i class="fas fa-download"></i> 导出</button>
|
||||||
|
</form>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="card"><div class="card-h"><i class="fas fa-chart-bar"></i> 统计报表</div><div class="card-b">
|
||||||
|
<p class="tm mb-4">查看系统整体统计和趋势数据</p>
|
||||||
|
<button class="btn btn-p btn-sm" onclick="ExportPage.viewStats()"><i class="fas fa-chart-simple"></i> 查看统计</button>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('exp-tickets').onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const p = new URLSearchParams({
|
||||||
|
format: document.getElementById('et-fmt').value,
|
||||||
|
type: document.getElementById('et-type').value,
|
||||||
|
status: document.getElementById('et-status').value,
|
||||||
|
start_date: document.getElementById('et-start').value,
|
||||||
|
end_date: document.getElementById('et-end').value,
|
||||||
|
});
|
||||||
|
try { await API.download(`/export/tickets?${p}`); } catch (ex) { alert(ex.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('exp-reported').onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const p = new URLSearchParams({ format: document.getElementById('er-fmt').value });
|
||||||
|
try { await API.download(`/export/reported-players?${p}`); } catch (ex) { alert(ex.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('exp-logs').onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const p = new URLSearchParams({
|
||||||
|
format: document.getElementById('el-fmt').value,
|
||||||
|
start_date: document.getElementById('el-start').value,
|
||||||
|
end_date: document.getElementById('el-end').value,
|
||||||
|
});
|
||||||
|
try { await API.download(`/export/audit-logs?${p}`); } catch (ex) { alert(ex.message); }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async viewStats() {
|
||||||
|
try {
|
||||||
|
const d = await API.get('/export/dashboard');
|
||||||
|
U.modal('系统统计', `
|
||||||
|
<div class="detail-section"><h4>概览</h4>
|
||||||
|
<div class="detail-row"><span class="lbl">工单总数</span><span class="val fb">${d.total}</span></div>
|
||||||
|
<div class="detail-row"><span class="lbl">今日新增</span><span class="val fb">${d.today}</span></div>
|
||||||
|
<div class="detail-row"><span class="lbl">用户数</span><span class="val fb">${d.usersTotal}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-section"><h4>状态分布</h4>${(d.byStatus||[]).map(r=>`<div class="detail-row"><span class="lbl">${U.STATUS[r.status]||r.status}</span><span class="val fb">${r.c}</span></div>`).join('')}</div>
|
||||||
|
<div class="detail-section"><h4>类型分布</h4>${(d.byType||[]).map(r=>`<div class="detail-row"><span class="lbl">${U.TYPE[r.type]||r.type}</span><span class="val fb">${r.c}</span></div>`).join('')}</div>
|
||||||
|
${d.monthly?.length?`<div class="detail-section"><h4>月度趋势</h4>${d.monthly.map(m=>`<div class="detail-row"><span class="lbl">${m.m}</span><span class="val fb">${m.c} 条</span></div>`).join('')}</div>`:''}
|
||||||
|
`, '600px');
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
}
|
||||||
|
};
|
||||||
82
public/js/pages/home.js
Normal file
82
public/js/pages/home.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
const HomePage = {
|
||||||
|
async render() {
|
||||||
|
return `<div id="home-content" style="max-width:800px;margin:0 auto;padding:30px 0">
|
||||||
|
<div class="tab-nav">
|
||||||
|
<button class="active" data-tab="report"><i class="fas fa-flag"></i> 举报</button>
|
||||||
|
<button data-tab="suggestion"><i class="fas fa-lightbulb"></i> 建议</button>
|
||||||
|
<button data-tab="appeal"><i class="fas fa-gavel"></i> 申诉</button>
|
||||||
|
<button data-tab="tracking"><i class="fas fa-search"></i> 追踪</button>
|
||||||
|
</div>
|
||||||
|
<div id="home-tab"></div>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
const tabBtns = document.querySelectorAll('.tab-nav button');
|
||||||
|
const tabContent = document.getElementById('home-tab');
|
||||||
|
let currentTab = 'report';
|
||||||
|
|
||||||
|
function switchTab(tab) {
|
||||||
|
currentTab = tab;
|
||||||
|
tabBtns.forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
|
||||||
|
if (tab === 'tracking') renderTracking(tabContent);
|
||||||
|
else TicketCreate.render(tabContent, { type: tab, prefill: {}, backLink: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTracking(ct) {
|
||||||
|
const token = document.cookie.split('; ').find(r => r.startsWith('tracking_token='))?.split('=')[1];
|
||||||
|
ct.innerHTML = `
|
||||||
|
<div class="card"><div class="card-b">
|
||||||
|
<div class="form-group"><label>追踪标识 (Cookie自动读取)</label>
|
||||||
|
<input id="track-token" value="${token || ''}" placeholder="粘贴追踪标识">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-p" onclick="HomePage.searchTracking()"><i class="fas fa-search"></i> 查找我的工单</button>
|
||||||
|
<div id="track-result" class="mt-4"></div>
|
||||||
|
</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
tabBtns.forEach(b => b.onclick = () => switchTab(b.dataset.tab));
|
||||||
|
switchTab('report');
|
||||||
|
|
||||||
|
this.searchTracking = async () => {
|
||||||
|
const token = document.getElementById('track-token').value.trim();
|
||||||
|
if (!token) return alert('请输入追踪标识');
|
||||||
|
const ct = document.getElementById('track-result');
|
||||||
|
ct.innerHTML = '<div class="loading"><i class="fas fa-spinner"></i></div>';
|
||||||
|
try {
|
||||||
|
const tickets = await API.get(`/tickets/tracking?token=${token}`);
|
||||||
|
ct.innerHTML = tickets.length ? `
|
||||||
|
<div class="table-wrap"><table><thead><tr>
|
||||||
|
<th>#</th><th>类型</th><th>标题</th><th>状态</th><th>时间</th><th>操作</th>
|
||||||
|
</tr></thead><tbody>${tickets.map(t => `
|
||||||
|
<tr><td>${t.id}</td><td>${U.badge(t.type,'type')}</td><td>${U.esc(t.title)}</td>
|
||||||
|
<td>${U.badge(t.status,'status')}</td><td>${U.date(t.created_at)}</td>
|
||||||
|
<td><button class="btn btn-o btn-sm" onclick="App.navigate('tracking');App.showTrackDetail(${t.id},'${token}')">查看</button></td></tr>
|
||||||
|
`).join('')}</tbody></table></div>` : '<div class="empty"><i class="fas fa-inbox"></i><p>未找到工单</p></div>';
|
||||||
|
} catch (ex) { ct.innerHTML = `<div class="alert alert-e">${ex.message}</div>`; }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
showTrackDetail(id, token) {
|
||||||
|
U.modal(`工单 #${id}`, `<div id="track-detail-content"></div>`, '700px');
|
||||||
|
const ct = document.getElementById('track-detail-content');
|
||||||
|
U.loading(ct);
|
||||||
|
API.get(`/tickets/tracking?token=${token}`).then(tickets => {
|
||||||
|
const t = tickets.find(x => x.id === id);
|
||||||
|
if (!t) { ct.innerHTML = '<p>未找到</p>'; return; }
|
||||||
|
ct.innerHTML = `
|
||||||
|
<div class="detail-section"><h4>基本信息</h4>
|
||||||
|
<div class="detail-row"><span class="lbl">标题</span><span class="val fb">${U.esc(t.title)}</span></div>
|
||||||
|
<div class="detail-row"><span class="lbl">类型</span><span class="val">${U.badge(t.type,'type')}</span></div>
|
||||||
|
<div class="detail-row"><span class="lbl">状态</span><span class="val">${U.badge(t.status,'status')}</span></div>
|
||||||
|
<div class="detail-row"><span class="lbl">提交者</span><span class="val">${U.esc(t.reporter_game_name)} (UID: ${U.esc(t.reporter_game_uid)})</span></div>
|
||||||
|
${t.type==='report'?`
|
||||||
|
<div class="detail-row"><span class="lbl">被举报人</span><span class="val">${U.esc(t.target_game_name||'?')} (UID: ${U.esc(t.target_game_uid||'?')})</span></div>
|
||||||
|
<div class="detail-row"><span class="lbl">原因</span><span class="val">${U.esc(t.reason)}</span></div>`:''}
|
||||||
|
${t.description?`<div class="detail-row"><span class="lbl">详情</span><span class="val">${U.esc(t.description)}</span></div>`:''}
|
||||||
|
<div class="detail-row"><span class="lbl">时间</span><span class="val">${U.date(t.created_at)}</span></div>
|
||||||
|
</div>
|
||||||
|
<p class="tm ts">如需回复,请<a href="#/register" style="color:var(--p)">注册账号</a>后关联此工单。</p>`;
|
||||||
|
}).catch(e => { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; });
|
||||||
|
}
|
||||||
|
};
|
||||||
62
public/js/pages/implementations.js
Normal file
62
public/js/pages/implementations.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const ImplementationsPage = {
|
||||||
|
async render() {
|
||||||
|
return '<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>';
|
||||||
|
},
|
||||||
|
|
||||||
|
async mount() {
|
||||||
|
const container = document.getElementById('page-content');
|
||||||
|
document.getElementById('page-title').textContent = '实施管理';
|
||||||
|
document.getElementById('page-actions').innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const impls = await API.get('/implementations');
|
||||||
|
this.renderList(container, impls);
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = `<div class="alert alert-danger">加载失败: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderList(container, impls) {
|
||||||
|
container.innerHTML = `
|
||||||
|
${impls.length === 0 ? `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-code"></i>
|
||||||
|
<p>暂无实施记录</p>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>关联投诉</th>
|
||||||
|
<th>关联计划</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>实施者</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${impls.map(i => `
|
||||||
|
<tr>
|
||||||
|
<td>${i.id}</td>
|
||||||
|
<td><a href="#" onclick="App.navigate('complaint',${i.complaint_id});return false" style="color:var(--primary)">#${i.complaint_id} ${Utils.escapeHtml(i.complaint_title)}</a></td>
|
||||||
|
<td class="text-sm text-muted">${Utils.escapeHtml(i.plan_title)}</td>
|
||||||
|
<td>${Utils.statusBadge(i.status)}</td>
|
||||||
|
<td>${i.implementer_name || '-'}</td>
|
||||||
|
<td class="date">${Utils.formatDate(i.created_at)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline" onclick="App.navigate('complaint',${i.complaint_id})"><i class="fas fa-eye"></i></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
};
|
||||||
109
public/js/pages/install.js
Normal file
109
public/js/pages/install.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
const InstallPage = {
|
||||||
|
step: 1,
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
const status = await API.get('/install/status');
|
||||||
|
if (status.installed) { location.hash = '#/login'; return ''; }
|
||||||
|
return `<div style="max-width:600px;margin:0 auto;padding:30px 0"><div id="install-steps"></div></div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
this.showStep(1);
|
||||||
|
},
|
||||||
|
|
||||||
|
showStep(n) {
|
||||||
|
this.step = n;
|
||||||
|
const ct = document.getElementById('install-steps');
|
||||||
|
if (n === 1) this.renderDBStep(ct);
|
||||||
|
else if (n === 2) this.renderAdminStep(ct);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderDBStep(ct) {
|
||||||
|
ct.innerHTML = `
|
||||||
|
<div class="card"><div class="card-h"><i class="fas fa-database"></i> 第1步:数据库配置</div><div class="card-b">
|
||||||
|
<form id="install-db">
|
||||||
|
<div class="grid-2"><div class="form-group"><label>MySQL 主机</label><input id="db-host" value="localhost"></div>
|
||||||
|
<div class="form-group"><label>端口</label><input id="db-port" value="3306" type="number"></div></div>
|
||||||
|
<div class="grid-2"><div class="form-group"><label>用户名</label><input id="db-user" value="root" required></div>
|
||||||
|
<div class="form-group"><label>密码</label><input type="password" id="db-pass"></div></div>
|
||||||
|
<div class="form-group"><label>数据库名</label><input id="db-name" value="mc_report" required><span class="help">如不存在将自动创建</span></div>
|
||||||
|
<div id="db-err" class="alert alert-e hidden"></div>
|
||||||
|
<button type="submit" class="btn btn-p"><i class="fas fa-check"></i> 测试连接并继续</button>
|
||||||
|
</form>
|
||||||
|
</div></div>
|
||||||
|
`;
|
||||||
|
document.getElementById('install-db').onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = e.target.querySelector('button');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 测试中...';
|
||||||
|
try {
|
||||||
|
await API.post('/install/check-db', {
|
||||||
|
host: document.getElementById('db-host').value,
|
||||||
|
port: document.getElementById('db-port').value,
|
||||||
|
user: document.getElementById('db-user').value,
|
||||||
|
password: document.getElementById('db-pass').value,
|
||||||
|
database: document.getElementById('db-name').value,
|
||||||
|
});
|
||||||
|
this.dbConfig = {
|
||||||
|
host: document.getElementById('db-host').value,
|
||||||
|
port: document.getElementById('db-port').value,
|
||||||
|
user: document.getElementById('db-user').value,
|
||||||
|
pass: document.getElementById('db-pass').value,
|
||||||
|
name: document.getElementById('db-name').value,
|
||||||
|
};
|
||||||
|
this.showStep(2);
|
||||||
|
} catch (ex) {
|
||||||
|
document.getElementById('db-err').textContent = ex.message;
|
||||||
|
document.getElementById('db-err').classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-check"></i> 测试连接并继续';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
renderAdminStep(ct) {
|
||||||
|
ct.innerHTML = `
|
||||||
|
<div class="card"><div class="card-h"><i class="fas fa-user-crown"></i> 第2步:创建服主账号</div><div class="card-b">
|
||||||
|
<form id="install-admin">
|
||||||
|
<div class="grid-2"><div class="form-group"><label>站点名称</label><input id="ia-sitename" value="MC举报系统"></div>
|
||||||
|
<div class="form-group"><label>站点地址</label><input id="ia-siteurl" value="${location.origin}"></div></div>
|
||||||
|
<div class="grid-2"><div class="form-group"><label>服主用户名 *</label><input id="ia-username" required placeholder="登录用"></div>
|
||||||
|
<div class="form-group"><label>邮箱 *</label><input type="email" id="ia-email" required></div></div>
|
||||||
|
<div class="grid-2"><div class="form-group"><label>密码 *</label><input type="password" id="ia-password" required minlength="6"></div>
|
||||||
|
<div class="form-group"><label>游戏名称</label><input id="ia-game" placeholder="Minecraft ID"></div></div>
|
||||||
|
<div class="form-group"><label>游戏UID</label><input id="ia-uid" placeholder="游戏唯一ID"></div>
|
||||||
|
<div id="ia-err" class="alert alert-e hidden"></div>
|
||||||
|
<div id="ia-warn" class="alert alert-i"><strong>注意:</strong>安装完成后需要重启服务器才能加载业务路由。请记好账号密码。</div>
|
||||||
|
<button type="submit" class="btn btn-p btn-lg w-full"><i class="fas fa-check"></i> 完成安装</button>
|
||||||
|
</form>
|
||||||
|
</div></div>
|
||||||
|
`;
|
||||||
|
document.getElementById('install-admin').onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = e.target.querySelector('button');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 安装中...';
|
||||||
|
try {
|
||||||
|
await API.post('/install/complete', {
|
||||||
|
...this.dbConfig,
|
||||||
|
db_host: this.dbConfig.host, db_port: this.dbConfig.port,
|
||||||
|
db_user: this.dbConfig.user, db_pass: this.dbConfig.pass, db_name: this.dbConfig.name,
|
||||||
|
site_name: document.getElementById('ia-sitename').value,
|
||||||
|
site_url: document.getElementById('ia-siteurl').value,
|
||||||
|
admin_username: document.getElementById('ia-username').value,
|
||||||
|
admin_password: document.getElementById('ia-password').value,
|
||||||
|
admin_email: document.getElementById('ia-email').value,
|
||||||
|
admin_game_name: document.getElementById('ia-game').value,
|
||||||
|
admin_game_uid: document.getElementById('ia-uid').value,
|
||||||
|
});
|
||||||
|
document.getElementById('install-admin').innerHTML = '<div class="alert alert-s"><h3><i class="fas fa-check-circle"></i> 安装成功!</h3><p>配置已保存。请重启服务器后刷新页面。</p></div>';
|
||||||
|
} catch (ex) {
|
||||||
|
document.getElementById('ia-err').textContent = ex.message;
|
||||||
|
document.getElementById('ia-err').classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-check"></i> 完成安装';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
41
public/js/pages/login.js
Normal file
41
public/js/pages/login.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
const LoginPage = {
|
||||||
|
render() {
|
||||||
|
return `
|
||||||
|
<div class="pub-card" style="background:white;border-radius:16px;padding:36px 32px;width:420px;max-width:90vw;box-shadow:0 20px 60px rgba(0,0,0,.35)">
|
||||||
|
<div class="logo"><i class="fas fa-shield-halved"></i><h2>MC举报系统</h2><p>登录账号</p></div>
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="form-group"><input id="lu" placeholder="用户名" required autofocus></div>
|
||||||
|
<div class="form-group"><input type="password" id="lp" placeholder="密码" required></div>
|
||||||
|
<div id="le" class="alert alert-e hidden"></div>
|
||||||
|
<button type="submit" class="btn btn-p btn-lg w-full"><i class="fas fa-sign-in-alt"></i> 登录</button>
|
||||||
|
</form>
|
||||||
|
<div style="margin-top:16px;text-align:center;font-size:13px">
|
||||||
|
<a href="#/register" style="color:var(--p);text-decoration:none">还没有账号?立即注册</a>
|
||||||
|
<span style="color:var(--g300);margin:0 8px">|</span>
|
||||||
|
<a href="#/home" style="color:var(--g500);text-decoration:none">匿名提交</a>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--g200);font-size:11px;color:var(--g400);text-align:center">
|
||||||
|
演示: owner/admin123 admin/admin123 player1/player123
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
document.getElementById('login-form').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = e.target.querySelector('button');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 登录中...';
|
||||||
|
try {
|
||||||
|
await Auth.login(document.getElementById('lu').value.trim(), document.getElementById('lp').value.trim());
|
||||||
|
App.afterLogin();
|
||||||
|
} catch (err) {
|
||||||
|
const el = document.getElementById('le');
|
||||||
|
el.textContent = err.message;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-sign-in-alt"></i> 登录';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
72
public/js/pages/notifications.js
Normal file
72
public/js/pages/notifications.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
const NotificationsPage = {
|
||||||
|
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||||
|
|
||||||
|
async mount() {
|
||||||
|
document.getElementById('page-title').textContent = '通知配置';
|
||||||
|
document.getElementById('page-actions').innerHTML = '<button class="btn btn-p btn-sm" onclick="NotificationsPage.showCreate()"><i class="fas fa-plus"></i> 添加Webhook</button>';
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
const ct = document.getElementById('page-content');
|
||||||
|
try {
|
||||||
|
const cfgs = await API.get('/notifications');
|
||||||
|
ct.innerHTML = cfgs.length ? cfgs.map(c => `
|
||||||
|
<div class="card"><div class="card-h"><span><i class="fas fa-${c.type==='webhook'?'link':'envelope'}"></i> ${U.esc(c.name)}</span><span>${c.active?`<span class="badge bg-resolved">启用</span>`:`<span class="badge bg-rejected">禁用</span>`}</span></div><div class="card-b">
|
||||||
|
<div class="detail-row"><span class="lbl">类型</span><span class="val">${c.type==='webhook'?'Webhook':'邮件'}</span></div>
|
||||||
|
${c.webhook_url?`<div class="detail-row"><span class="lbl">地址</span><span class="val ts" style="word-break:break-all">${U.esc(c.webhook_url)}</span></div>`:''}
|
||||||
|
<div class="detail-row"><span class="lbl">事件</span><span class="val">${EventsLabel(c.events)}</span></div>
|
||||||
|
<div class="mt-2 flex g2">
|
||||||
|
<button class="btn btn-o btn-sm" onclick="NotificationsPage.showEdit(${c.id},'${U.esc(c.name)}','${c.type}','${U.esc(c.webhook_url)}','${c.events}',${c.active})"><i class="fas fa-edit"></i> 编辑</button>
|
||||||
|
<button class="btn btn-i btn-sm" onclick="NotificationsPage.test(${c.id})"><i class="fas fa-paper-plane"></i> 测试</button>
|
||||||
|
<button class="btn btn-d btn-sm" onclick="NotificationsPage.del(${c.id})"><i class="fas fa-trash"></i> 删除</button>
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
`).join('') : '<div class="empty"><i class="fas fa-bell-slash"></i><p>暂无通知配置</p><p class="ts tm">添加Webhook后可自动推送工单状态变更通知</p></div>';
|
||||||
|
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
showCreate() {
|
||||||
|
U.modal('添加Webhook', `
|
||||||
|
<form id="nc-form"><div class="form-group"><label>名称 *</label><input id="nc-name" placeholder="如: Discord通知" required></div>
|
||||||
|
<div class="form-group"><label>类型 *</label><select id="nc-type"><option value="webhook">Webhook</option></select></div>
|
||||||
|
<div class="form-group"><label>Webhook URL *</label><input id="nc-url" placeholder="https://discord.com/api/webhooks/..." required></div>
|
||||||
|
<div class="form-group"><label>触发事件</label>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:4px">${EventOptions('all')}</div>
|
||||||
|
<input type="hidden" id="nc-events" value="all">
|
||||||
|
</div>
|
||||||
|
<div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">创建</button></div></form>
|
||||||
|
`);
|
||||||
|
bindEventToggles();
|
||||||
|
document.getElementById('nc-form').onsubmit = async e => { e.preventDefault();
|
||||||
|
try { await API.post('/notifications',{name:document.getElementById('nc-name').value,type:'webhook',webhook_url:document.getElementById('nc-url').value,events:document.getElementById('nc-events').value}); App.closeModal(); this.load(); } catch(ex){alert(ex.message);}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
showEdit(id,name,type,url,events,active) {
|
||||||
|
U.modal('编辑通知', `
|
||||||
|
<form id="ne-form"><div class="form-group"><label>名称</label><input id="ne-name" value="${name}"></div>
|
||||||
|
<div class="form-group"><label>Webhook URL</label><input id="ne-url" value="${url}"></div>
|
||||||
|
<div class="form-group"><label>触发事件</label>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-top:4px">${EventOptions(events)}</div>
|
||||||
|
<input type="hidden" id="ne-events" value="${events}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>状态</label><select id="ne-active"><option value="1" ${active?'selected':''}>启用</option><option value="0" ${!active?'selected':''}>禁用</option></select></div>
|
||||||
|
<div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">保存</button></div></form>
|
||||||
|
`);
|
||||||
|
bindEventToggles();
|
||||||
|
document.getElementById('ne-form').onsubmit = async e => { e.preventDefault();
|
||||||
|
try { await API.put(`/notifications/${id}`,{name:document.getElementById('ne-name').value,webhook_url:document.getElementById('ne-url').value,events:document.getElementById('ne-events').value,active:document.getElementById('ne-active').value==='1'}); App.closeModal(); this.load(); } catch(ex){alert(ex.message);}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async test(id) { try { alert((await API.post(`/notifications/${id}/test`)).message); } catch(ex){alert(ex.message);} },
|
||||||
|
async del(id) { if(await U.confirm('确认删除此通知配置?')){ await API.req('DELETE',`/notifications/${id}`); this.load(); } }
|
||||||
|
};
|
||||||
|
|
||||||
|
function EventOptions(current) { return ['all','ticket_created','ticket_claimed','ticket_transferred','ticket_updated'].map(e=>`<label style="cursor:pointer;font-size:12px"><input type="checkbox" value="${e}" class="ev-check" ${current.split(',').map(s=>s.trim()).includes(e)?'checked':''}> ${EventsLabelSingle(e)}</label>`).join(''); }
|
||||||
|
function EventsLabel(v) { return v==='all'?'全部事件':v.split(',').map(s=>s.trim()).map(EventsLabelSingle).join(', '); }
|
||||||
|
function EventsLabelSingle(e) { return {all:'全部',ticket_created:'工单创建',ticket_claimed:'工单认领',ticket_transferred:'工单转交',ticket_updated:'工单更新'}[e]||e; }
|
||||||
|
function bindEventToggles() {
|
||||||
|
document.querySelectorAll('.ev-check').forEach(cb=>{ cb.onchange=()=>{ const all=document.querySelectorAll('.ev-check'); const v=Array.from(all).filter(c=>c.checked).map(c=>c.value).join(','); const hid=cb.closest('form').querySelector('input[type="hidden"]'); if(hid)hid.value=v; }; });
|
||||||
|
}
|
||||||
62
public/js/pages/plans.js
Normal file
62
public/js/pages/plans.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const PlansPage = {
|
||||||
|
async render() {
|
||||||
|
return '<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>';
|
||||||
|
},
|
||||||
|
|
||||||
|
async mount() {
|
||||||
|
const container = document.getElementById('page-content');
|
||||||
|
document.getElementById('page-title').textContent = '计划管理';
|
||||||
|
document.getElementById('page-actions').innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const plans = await API.get('/plans');
|
||||||
|
this.renderList(container, plans);
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = `<div class="alert alert-danger">加载失败: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderList(container, plans) {
|
||||||
|
container.innerHTML = `
|
||||||
|
${plans.length === 0 ? `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-clipboard-list"></i>
|
||||||
|
<p>暂无计划</p>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>计划标题</th>
|
||||||
|
<th>关联投诉</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>规划者</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${plans.map(p => `
|
||||||
|
<tr>
|
||||||
|
<td>${p.id}</td>
|
||||||
|
<td><strong>${Utils.escapeHtml(p.title)}</strong></td>
|
||||||
|
<td><a href="#" onclick="App.navigate('complaint',${p.complaint_id});return false" style="color:var(--primary)">#${p.complaint_id} ${Utils.escapeHtml(p.complaint_title)}</a></td>
|
||||||
|
<td>${Utils.statusBadge(p.status)}</td>
|
||||||
|
<td>${p.planner_name || '-'}</td>
|
||||||
|
<td class="date">${Utils.formatDate(p.created_at)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline" onclick="App.navigate('complaint',${p.complaint_id})"><i class="fas fa-eye"></i></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
};
|
||||||
69
public/js/pages/register.js
Normal file
69
public/js/pages/register.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
const RegisterPage = {
|
||||||
|
captcha: null,
|
||||||
|
|
||||||
|
async render() {
|
||||||
|
try { this.captcha = await API.get('/captcha'); } catch { this.captcha = null; }
|
||||||
|
return `
|
||||||
|
<div class="pub-card wide" style="background:white;border-radius:16px;padding:36px 32px;width:540px;max-width:90vw;box-shadow:0 20px 60px rgba(0,0,0,.35)">
|
||||||
|
<div class="logo"><i class="fas fa-user-plus"></i><h2>注册账号</h2><p>加入MC举报系统</p></div>
|
||||||
|
<form id="reg-form">
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="form-group"><label>站点用户名 *</label><input id="ru" required placeholder="登录用"></div>
|
||||||
|
<div class="form-group"><label>邮箱 *</label><input type="email" id="re" required placeholder="接收通知"></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="form-group"><label>游戏名称 *</label><input id="rgn" required placeholder="Minecraft ID"></div>
|
||||||
|
<div class="form-group"><label>游戏UID *</label><input id="rgu" required placeholder="游戏唯一ID"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>密码 *</label><input type="password" id="rp" required placeholder="至少6位"></div>
|
||||||
|
${this.captcha ? `
|
||||||
|
<div class="form-group" style="display:flex;align-items:center;gap:10px">
|
||||||
|
<div style="flex-shrink:0;background:var(--g100);padding:6px 14px;border-radius:var(--r);font-weight:700;font-size:16px;letter-spacing:1px;user-select:none" id="captcha-q">${this.captcha.question}</div>
|
||||||
|
<input type="text" id="captcha-a" placeholder="输入答案" required style="max-width:100px">
|
||||||
|
<input type="hidden" id="captcha-id" value="${this.captcha.id}">
|
||||||
|
</div>` : ''}
|
||||||
|
<div id="regerr" class="alert alert-e hidden"></div>
|
||||||
|
<div id="regok" class="alert alert-s hidden"></div>
|
||||||
|
<button type="submit" class="btn btn-p btn-lg w-full"><i class="fas fa-user-plus"></i> 注册</button>
|
||||||
|
</form>
|
||||||
|
<div style="margin-top:14px;text-align:center;font-size:13px">
|
||||||
|
<a href="#/login" style="color:var(--p);text-decoration:none">已有账号?登录</a>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
document.getElementById('reg-form').addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = e.target.querySelector('button');
|
||||||
|
const err = document.getElementById('regerr');
|
||||||
|
const ok = document.getElementById('regok');
|
||||||
|
err.classList.add('hidden');
|
||||||
|
ok.classList.add('hidden');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 注册中...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Auth.register({
|
||||||
|
username: document.getElementById('ru').value.trim(),
|
||||||
|
password: document.getElementById('rp').value,
|
||||||
|
email: document.getElementById('re').value.trim(),
|
||||||
|
game_name: document.getElementById('rgn').value.trim(),
|
||||||
|
game_uid: document.getElementById('rgu').value.trim(),
|
||||||
|
captcha_id: document.getElementById('captcha-id')?.value,
|
||||||
|
captcha_answer: document.getElementById('captcha-a')?.value.trim(),
|
||||||
|
});
|
||||||
|
ok.textContent = '注册成功!请查收验证邮件(如SMTP未配置则自动激活)';
|
||||||
|
ok.classList.remove('hidden');
|
||||||
|
btn.innerHTML = '<i class="fas fa-user-plus"></i> 注册';
|
||||||
|
btn.disabled = false;
|
||||||
|
setTimeout(() => location.hash = '#/login', 3000);
|
||||||
|
} catch (ex) {
|
||||||
|
err.textContent = ex.message;
|
||||||
|
err.classList.remove('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-user-plus"></i> 注册';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
62
public/js/pages/reviews.js
Normal file
62
public/js/pages/reviews.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const ReviewsPage = {
|
||||||
|
async render() {
|
||||||
|
return '<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>';
|
||||||
|
},
|
||||||
|
|
||||||
|
async mount() {
|
||||||
|
const container = document.getElementById('page-content');
|
||||||
|
document.getElementById('page-title').textContent = '审查管理';
|
||||||
|
document.getElementById('page-actions').innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reviews = await API.get('/reviews');
|
||||||
|
this.renderList(container, reviews);
|
||||||
|
} catch (err) {
|
||||||
|
container.innerHTML = `<div class="alert alert-danger">加载失败: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderList(container, reviews) {
|
||||||
|
container.innerHTML = `
|
||||||
|
${reviews.length === 0 ? `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
<p>暂无审查记录</p>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="card">
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>关联投诉</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>审查者</th>
|
||||||
|
<th>审查意见</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${reviews.map(r => `
|
||||||
|
<tr>
|
||||||
|
<td>${r.id}</td>
|
||||||
|
<td><a href="#" onclick="App.navigate('complaint',${r.complaint_id});return false" style="color:var(--primary)">#${r.complaint_id} ${Utils.escapeHtml(r.complaint_title)}</a></td>
|
||||||
|
<td>${Utils.statusBadge(r.status)}</td>
|
||||||
|
<td>${r.reviewer_name || '-'}</td>
|
||||||
|
<td class="text-sm text-muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${r.review_notes ? Utils.escapeHtml(r.review_notes.slice(0, 50)) : '-'}</td>
|
||||||
|
<td class="date">${Utils.formatDate(r.created_at)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline" onclick="App.navigate('complaint',${r.complaint_id})"><i class="fas fa-eye"></i></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
};
|
||||||
61
public/js/pages/settings-page.js
Normal file
61
public/js/pages/settings-page.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
const SettingsPage = {
|
||||||
|
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||||
|
|
||||||
|
async mount() {
|
||||||
|
document.getElementById('page-title').textContent = '系统设置';
|
||||||
|
document.getElementById('page-actions').innerHTML = '';
|
||||||
|
try {
|
||||||
|
const settings = await API.get('/settings/settings');
|
||||||
|
const ct = document.getElementById('page-content');
|
||||||
|
ct.innerHTML = `
|
||||||
|
<div class="card"><div class="card-h">站点设置</div><div class="card-b">
|
||||||
|
<form id="site-form">
|
||||||
|
<div class="grid-2"><div class="form-group"><label>站点名称</label><input id="s-name" value="${U.esc(settings.site_name?.value||'')}"></div>
|
||||||
|
<div class="form-group"><label>站点地址</label><input id="s-url" value="${U.esc(settings.site_url?.value||'')}"></div></div>
|
||||||
|
<button type="submit" class="btn btn-p btn-sm"><i class="fas fa-save"></i> 保存站点设置</button>
|
||||||
|
</form>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="card"><div class="card-h">SMTP 邮件设置</div><div class="card-b">
|
||||||
|
<form id="smtp-form">
|
||||||
|
<div class="grid-2"><div class="form-group"><label>SMTP服务器</label><input id="s-host" value="${U.esc(settings.smtp_host?.value||'')}"></div>
|
||||||
|
<div class="form-group"><label>端口</label><input id="s-port" value="${U.esc(settings.smtp_port?.value||'587')}"></div></div>
|
||||||
|
<div class="grid-2"><div class="form-group"><label>用户名</label><input id="s-user" value="${U.esc(settings.smtp_user?.value||'')}"></div>
|
||||||
|
<div class="form-group"><label>密码</label><input type="password" id="s-pass" value="${U.esc(settings.smtp_pass?.value||'')}"></div></div>
|
||||||
|
<div class="grid-2"><div class="form-group"><label>发件地址</label><input id="s-from" value="${U.esc(settings.smtp_from?.value||'')}"></div>
|
||||||
|
<div class="form-group"><label>SSL</label><select id="s-secure"><option value="0" ${settings.smtp_secure?.value!=='1'?'selected':''}>否(587)</option><option value="1" ${settings.smtp_secure?.value==='1'?'selected':''}>是(465)</option></select></div></div>
|
||||||
|
<button type="submit" class="btn btn-p btn-sm"><i class="fas fa-save"></i> 保存SMTP设置</button>
|
||||||
|
</form>
|
||||||
|
</div></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('site-form').onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await API.put('/settings/settings', {
|
||||||
|
site_name: {value: document.getElementById('s-name').value, label:'站点名称'},
|
||||||
|
site_url: {value: document.getElementById('s-url').value, label:'站点地址'},
|
||||||
|
});
|
||||||
|
alert('已保存');
|
||||||
|
} catch (ex) { alert(ex.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('smtp-form').onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await API.put('/settings/settings', {
|
||||||
|
smtp_host: {value: document.getElementById('s-host').value, label:'SMTP服务器'},
|
||||||
|
smtp_port: {value: document.getElementById('s-port').value, label:'SMTP端口'},
|
||||||
|
smtp_secure: {value: document.getElementById('s-secure').value, label:'SSL'},
|
||||||
|
smtp_user: {value: document.getElementById('s-user').value, label:'SMTP用户名'},
|
||||||
|
smtp_pass: {value: document.getElementById('s-pass').value, label:'SMTP密码'},
|
||||||
|
smtp_from: {value: document.getElementById('s-from').value, label:'发件地址'},
|
||||||
|
});
|
||||||
|
alert('已保存');
|
||||||
|
} catch (ex) { alert(ex.message); }
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('page-content').innerHTML = `<div class="alert alert-e">${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
48
public/js/pages/templates.js
Normal file
48
public/js/pages/templates.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
const TemplatesPage = {
|
||||||
|
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||||
|
|
||||||
|
async mount() {
|
||||||
|
document.getElementById('page-title').textContent = '邮件模板';
|
||||||
|
document.getElementById('page-actions').innerHTML = '';
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
const ct = document.getElementById('page-content');
|
||||||
|
try {
|
||||||
|
const templates = await API.get('/settings/email-templates');
|
||||||
|
ct.innerHTML = templates.map(t => `
|
||||||
|
<div class="card"><div class="card-h"><span>${U.esc(t.name)}</span><code class="ts tm">${t.code}</code></div><div class="card-b">
|
||||||
|
<div class="detail-row"><span class="lbl">主题</span><span class="val">${U.esc(t.subject)}</span></div>
|
||||||
|
${t.description ? `<div class="detail-row"><span class="lbl">说明</span><span class="val tm">${U.esc(t.description)}</span></div>` : ''}
|
||||||
|
<div class="mt-2"><button class="btn btn-p btn-sm" onclick="TemplatesPage.edit('${t.code}', '${U.esc(t.name)}')"><i class="fas fa-edit"></i> 编辑模板</button></div>
|
||||||
|
</div></div>
|
||||||
|
`).join('');
|
||||||
|
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async edit(code, name) {
|
||||||
|
try {
|
||||||
|
const t = await API.get(`/settings/email-templates/${code}`);
|
||||||
|
U.modal(`编辑: ${name}`, `
|
||||||
|
<form id="et-form">
|
||||||
|
<div class="form-group"><label>邮件主题</label><input id="et-subject" value="${U.esc(t.subject).replace(/"/g,'"')}" required></div>
|
||||||
|
<div class="form-group"><label>邮件正文 (HTML)</label><textarea id="et-body" rows="14" required>${U.esc(t.body).replace(/"/g,'"')}</textarea></div>
|
||||||
|
<div class="alert alert-i ts">可用变量: ${t.description?.match(/\{\{[^}]+\}\}/g)?.join(', ') || '{{site_name}}'}</div>
|
||||||
|
<div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">保存</button></div>
|
||||||
|
</form>
|
||||||
|
`, '800px');
|
||||||
|
document.getElementById('et-form').onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await API.put(`/settings/email-templates/${code}`, {
|
||||||
|
subject: document.getElementById('et-subject').value,
|
||||||
|
body: document.getElementById('et-body').value,
|
||||||
|
});
|
||||||
|
App.closeModal();
|
||||||
|
this.load();
|
||||||
|
} catch (ex) { alert(ex.message); }
|
||||||
|
};
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
}
|
||||||
|
};
|
||||||
152
public/js/pages/ticket-create.js
Normal file
152
public/js/pages/ticket-create.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
const TicketCreate = {
|
||||||
|
files: [],
|
||||||
|
|
||||||
|
render(container, { type, prefill, backLink }) {
|
||||||
|
const user = Auth.user();
|
||||||
|
const isReport = type === 'report';
|
||||||
|
const isSuggestion = type === 'suggestion';
|
||||||
|
const isAppeal = type === 'appeal';
|
||||||
|
const typeLabel = isReport ? '举报' : isAppeal ? '申诉' : '建议';
|
||||||
|
this.files = [];
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<form id="ticket-form" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="type" value="${type}">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-h">提交${typeLabel}</div>
|
||||||
|
<div class="card-b">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>${typeLabel}标题 *</label>
|
||||||
|
<input name="title" required placeholder="${isReport ? '如:玩家破坏建筑' : isAppeal ? '如:申请解除封禁' : '如:增加领地保护功能'}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>您的游戏名称 *</label>
|
||||||
|
<input name="reporter_game_name" required placeholder="Minecraft ID" value="${U.esc(prefill?.game_name || user?.game_name || '')}" ${prefill?.game_name ? 'readonly' : ''}>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>您的游戏UID *</label>
|
||||||
|
<input name="reporter_game_uid" required placeholder="游戏UID" value="${U.esc(prefill?.game_uid || user?.game_uid || '')}" ${prefill?.game_uid ? 'readonly' : ''}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${isReport ? `
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="form-group"><label>对方游戏名称 (至少一项)</label><input name="target_game_name" placeholder="被举报者Minecraft ID"></div>
|
||||||
|
<div class="form-group"><label>对方游戏UID (至少一项)</label><input name="target_game_uid" placeholder="被举报者UID"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>举报原因 *</label>
|
||||||
|
<input name="reason" list="reason-list" required placeholder="选择或自行输入举报原因">
|
||||||
|
<datalist id="reason-list">
|
||||||
|
<option value="破坏建筑"><option value="盗窃物品"><option value="外挂作弊">
|
||||||
|
<option value="语言骚扰"><option value="恶意PVP"><option value="利用漏洞">
|
||||||
|
<option value="其他违规"><option value="诈骗"><option value="恶意辱骂">
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>详细描述</label><textarea name="description" rows="4" placeholder="请详细描述事件经过..."></textarea></div>
|
||||||
|
` : isAppeal ? `
|
||||||
|
<div class="form-group"><label>申诉理由 *</label><input name="reason" required placeholder="如:误封、证据不足被封、违规行为已纠正等"></div>
|
||||||
|
<div class="form-group"><label>详细说明 *</label><textarea name="description" rows="5" placeholder="请详细说明情况,包括被封时间、可能原因、为什么应解封等..." required></textarea></div>
|
||||||
|
` : `
|
||||||
|
<div class="form-group"><label>建议内容 *</label><textarea name="description" rows="5" placeholder="请详细描述您的建议..." required></textarea></div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-h">上传文件 (图片/视频,每个最大20MB)</div>
|
||||||
|
<div class="card-b">
|
||||||
|
<div class="file-upload" id="file-drop">
|
||||||
|
<i class="fas fa-cloud-upload-alt"></i>
|
||||||
|
<p>点击选择文件或拖拽到此处</p>
|
||||||
|
<p class="help">支持: JPG/PNG/GIF/WebP/MP4/WebM</p>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="file-input" multiple accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm" style="display:none">
|
||||||
|
<div class="file-list" id="file-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||||
|
${backLink ? `<button type="button" class="btn btn-o" onclick="${backLink}">返回</button>` : ''}
|
||||||
|
<button type="submit" class="btn btn-p btn-lg"><i class="fas fa-paper-plane"></i> 提交</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.bindFileUpload(container);
|
||||||
|
this.bindSubmit(container, type);
|
||||||
|
},
|
||||||
|
|
||||||
|
bindFileUpload(container) {
|
||||||
|
const drop = container.querySelector('#file-drop');
|
||||||
|
const input = container.querySelector('#file-input');
|
||||||
|
const list = container.querySelector('#file-list');
|
||||||
|
|
||||||
|
drop.onclick = () => input.click();
|
||||||
|
input.onchange = () => this.addFiles(input.files, list);
|
||||||
|
drop.ondragover = e => { e.preventDefault(); drop.style.borderColor = 'var(--p)'; };
|
||||||
|
drop.ondragleave = () => { drop.style.borderColor = 'var(--g300)'; };
|
||||||
|
drop.ondrop = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
drop.style.borderColor = 'var(--g300)';
|
||||||
|
this.addFiles(e.dataTransfer.files, list);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addFiles(fileList, listEl) {
|
||||||
|
const allowed = ['image/jpeg','image/png','image/gif','image/webp','video/mp4','video/webm'];
|
||||||
|
for (const f of fileList) {
|
||||||
|
if (!allowed.includes(f.type)) { alert(`不支持的文件类型: ${f.name}`); continue; }
|
||||||
|
if (f.size > 20 * 1024 * 1024) { alert(`文件过大: ${f.name}`); continue; }
|
||||||
|
if (this.files.length >= 5) { alert('最多5个文件'); break; }
|
||||||
|
this.files.push(f);
|
||||||
|
}
|
||||||
|
this.refreshFileList(listEl);
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshFileList(listEl) {
|
||||||
|
listEl.innerHTML = this.files.map((f, i) => `
|
||||||
|
<div class="file-item">
|
||||||
|
<i class="fas fa-${f.type.startsWith('video') ? 'video' : 'image'}"></i>
|
||||||
|
${U.esc(f.name)} (${(f.size / 1024 / 1024).toFixed(2)}MB)
|
||||||
|
<button type="button" onclick="TicketCreate.removeFile(${i})"><i class="fas fa-times"></i></button>
|
||||||
|
</div>`).join('');
|
||||||
|
},
|
||||||
|
|
||||||
|
removeFile(i) { this.files.splice(i, 1); this.refreshFileList(document.getElementById('file-list')); },
|
||||||
|
|
||||||
|
async bindSubmit(container, type) {
|
||||||
|
const form = container.querySelector('#ticket-form');
|
||||||
|
form.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const fd = new FormData(form);
|
||||||
|
|
||||||
|
if (type === 'report') {
|
||||||
|
const tgn = fd.get('target_game_name');
|
||||||
|
const tgu = fd.get('target_game_uid');
|
||||||
|
if (!tgn && !tgu) { return alert('举报需至少填写对方游戏名或UID之一'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const f of this.files) fd.append('files', f);
|
||||||
|
|
||||||
|
const btn = form.querySelector('[type="submit"]');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 提交中...';
|
||||||
|
try {
|
||||||
|
const res = await API.req('POST', '/tickets', fd);
|
||||||
|
if (res.tracking_token) {
|
||||||
|
document.cookie = `tracking_token=${res.tracking_token};path=/;max-age=${365*24*3600};SameSite=Lax`;
|
||||||
|
}
|
||||||
|
alert('提交成功!工单编号: #' + res.id);
|
||||||
|
if (Auth.logged()) App.navigate('tickets');
|
||||||
|
else App.navigate('home');
|
||||||
|
} catch (ex) {
|
||||||
|
alert(ex.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-paper-plane"></i> 提交';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
106
public/js/pages/ticket-detail.js
Normal file
106
public/js/pages/ticket-detail.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
const TicketDetail = {
|
||||||
|
id: null,
|
||||||
|
|
||||||
|
async render(id) { this.id = id; return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||||
|
|
||||||
|
async mount() {
|
||||||
|
const id = this.id;
|
||||||
|
document.getElementById('page-title').textContent = `工单 #${id}`;
|
||||||
|
document.getElementById('page-actions').innerHTML = `<button class="btn btn-o btn-sm" onclick="App.navigate('tickets')"><i class="fas fa-arrow-left"></i> 返回列表</button>`;
|
||||||
|
try { this.render(document.getElementById('page-content'), await API.get(`/tickets/${id}`)); } catch (e) { document.getElementById('page-content').innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
render(ct, t) {
|
||||||
|
const isStaff = Auth.isAdmin();
|
||||||
|
const isOwner = Auth.isOwner();
|
||||||
|
const isRep = t.type === 'report';
|
||||||
|
const canClaim = isStaff && (!t.assigned_to || t.status === 'pending');
|
||||||
|
const canTransfer = isStaff && t.assigned_to === Auth.user().id && t.status === 'processing';
|
||||||
|
const isMyTicket = t.assigned_to === Auth.user().id;
|
||||||
|
|
||||||
|
ct.innerHTML = `
|
||||||
|
<div class="detail-grid"><div>
|
||||||
|
<div class="card"><div class="card-h"><span>基本信息</span><div>${U.badge(t.status,'status')} ${U.badge(t.type,'type')} ${U.badge(t.priority,'priority')}</div></div><div class="card-b">
|
||||||
|
<div class="detail-row"><span class="lbl">标题</span><span class="val fb">${U.esc(t.title)}</span></div>
|
||||||
|
<div class="detail-row"><span class="lbl">提交者</span><span class="val">${U.esc(t.reporter_game_name)} (UID: ${U.esc(t.reporter_game_uid)})</span></div>
|
||||||
|
${isRep?`<div class="detail-row"><span class="lbl">被举报人</span><span class="val">${U.esc(t.target_game_name||'?')} (UID: ${U.esc(t.target_game_uid||'?')})</span></div>
|
||||||
|
<div class="detail-row"><span class="lbl">原因</span><span class="val">${U.esc(t.reason)}</span></div>`:''}
|
||||||
|
${t.description?`<div class="detail-row"><span class="lbl">${isRep?'详情':'建议'}</span><span class="val" style="white-space:pre-wrap">${U.esc(t.description)}</span></div>`:''}
|
||||||
|
<div class="detail-row"><span class="lbl">提交时间</span><span class="val">${U.date(t.created_at)}</span></div>
|
||||||
|
<div class="detail-row"><span class="lbl">更新时间</span><span class="val">${U.date(t.updated_at)}</span></div>
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
${t.claim_note?`<div class="card"><div class="card-h">处理备注</div><div class="card-b" style="white-space:pre-wrap">${U.esc(t.claim_note)}</div></div>`:''}
|
||||||
|
|
||||||
|
${t.attachments?.length?`<div class="card"><div class="card-h">附件 (${t.attachments.length})</div><div class="card-b" style="display:flex;flex-wrap:wrap;gap:10px">
|
||||||
|
${t.attachments.map(a=>a.mime_type.startsWith('image')?`<a href="/api/uploads/${a.stored_name}" target="_blank"><img src="/api/uploads/${a.stored_name}" style="max-width:150px;max-height:120px;border-radius:6px;border:1px solid var(--g200)" title="${U.esc(a.original_name)}"></a>`:`<a href="/api/uploads/${a.stored_name}" target="_blank" class="btn btn-o btn-sm"><i class="fas fa-video"></i> ${U.esc(a.original_name)}</a>`).join('')}
|
||||||
|
</div></div>`:''}
|
||||||
|
|
||||||
|
<div class="card"><div class="card-h">沟通记录</div><div class="card-b">
|
||||||
|
${t.responses?.length?`<div class="timeline">${t.responses.map(r=>`<div class="timeline-item ${r.is_staff?'staff':''}"><div class="th"><span class="tn">${U.esc(r.username||'系统')}</span>${r.role?`<span class="badge bg-${r.role}">${U.ROLE[r.role]}</span>`:''}<span>${U.date(r.created_at)}</span></div><div class="tc">${U.esc(r.content)}</div></div>`).join('')}</div>`:'<p class="tm">暂无记录</p>'}
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
<div class="card"><div class="card-h">${isStaff?'回复 (公开)':'补充信息'}</div><div class="card-b">
|
||||||
|
<form id="reply-form"><div class="form-group"><textarea id="reply-content" rows="3" placeholder="${isStaff?'输入回复内容...':'补充说明...'}" required></textarea></div><button type="submit" class="btn btn-p btn-sm"><i class="fas fa-reply"></i> 发送</button></form>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="card"><div class="card-h">处理信息</div><div class="card-b">
|
||||||
|
<div class="detail-row"><span class="lbl">提交账号</span><span class="val">${t.submitter?`${U.esc(t.submitter)} (${U.esc(t.user_game_name||'')})`:'匿名'}</span></div>
|
||||||
|
<div class="detail-row"><span class="lbl">负责人</span><span class="val">${t.assignee_name?`<span class="badge bg-admin">${U.esc(t.assignee_name)}</span>`:'<span class="tm">待认领</span>'}</span></div>
|
||||||
|
${t.claimed_at?`<div class="detail-row"><span class="lbl">认领时间</span><span class="val">${U.date(t.claimed_at)}</span></div>`:''}
|
||||||
|
</div></div>
|
||||||
|
|
||||||
|
${isStaff?`
|
||||||
|
<div class="card"><div class="card-h">管理操作</div><div class="card-b" style="display:flex;flex-direction:column;gap:6px">
|
||||||
|
${canClaim?`<button class="btn btn-s btn-sm w-full" onclick="TicketDetail.claimTicket(${t.id})"><i class="fas fa-hand-paper"></i> 接单认领</button>`:''}
|
||||||
|
${isMyTicket?`<button class="btn btn-p btn-sm w-full" onclick="TicketDetail.editNote(${t.id})"><i class="fas fa-pen"></i> 更新处理备注</button>`:''}
|
||||||
|
${canTransfer?`<button class="btn btn-w btn-sm w-full" onclick="TicketDetail.transferTicket(${t.id})"><i class="fas fa-exchange-alt"></i> 转单</button>`:''}
|
||||||
|
${isOwner||isMyTicket?`<button class="btn btn-o btn-sm w-full" onclick="TicketDetail.changeStatus(${t.id})"><i class="fas fa-tag"></i> 变更状态</button>`:''}
|
||||||
|
</div></div>
|
||||||
|
`:''}
|
||||||
|
|
||||||
|
${t.transfers?.length?`
|
||||||
|
<div class="card"><div class="card-h">转交历史</div><div class="card-b">
|
||||||
|
<div class="timeline">${t.transfers.map(tf=>`<div class="timeline-item"><div class="th"><span class="tn">${U.esc(tf.from_game_name||tf.from_username)}</span><i class="fas fa-arrow-right ts tm"></i><span class="tn">${U.esc(tf.to_game_name||tf.to_username)}</span><span>${U.date(tf.created_at)}</span></div>${tf.reason?`<div class="tc">${U.esc(tf.reason)}</div>`:''}</div>`).join('')}</div>
|
||||||
|
</div></div>`:''}
|
||||||
|
</div></div>`;
|
||||||
|
|
||||||
|
document.getElementById('reply-form')?.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault(); const c = document.getElementById('reply-content').value.trim(); if(!c)return;
|
||||||
|
try { await API.post(`/tickets/${id}/response`, {content:c}); this.mount(); } catch(ex){alert(ex.message);}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async claimTicket(id) {
|
||||||
|
U.modal('接单认领', `<form id="claim-form"><div class="form-group"><label>初始回复 (玩家可见)</label><textarea id="claim-reply" rows="3" placeholder="接单时发送给玩家的回复,如:已收到举报,正在核查中..."></textarea></div><div class="form-group"><label>处理备注 (内部)</label><textarea id="claim-note" rows="2" placeholder="内部处理备注,玩家不可见"></textarea></div><div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-s"><i class="fas fa-hand-paper"></i> 确认接单</button></div></form>`);
|
||||||
|
document.getElementById('claim-form').onsubmit = async e => { e.preventDefault();
|
||||||
|
try { await API.post(`/tickets/${id}/claim`, {claim_note: document.getElementById('claim-note').value, initial_reply: document.getElementById('claim-reply').value}); App.closeModal(); this.mount(); } catch(ex){alert(ex.message);}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async editNote(id) {
|
||||||
|
const t = await API.get(`/tickets/${id}`);
|
||||||
|
U.modal('更新处理备注', `<form id="note-form"><div class="form-group"><textarea id="upd-note" rows="4">${U.esc(t.claim_note||'')}</textarea></div><div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">保存</button></div></form>`);
|
||||||
|
document.getElementById('note-form').onsubmit = async e => { e.preventDefault();
|
||||||
|
try { await API.put(`/tickets/${id}`, {claim_note: document.getElementById('upd-note').value}); App.closeModal(); this.mount(); } catch(ex){alert(ex.message);}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async transferTicket(id) {
|
||||||
|
const users = await API.get('/users');
|
||||||
|
const staff = users.filter(u => (u.role==='owner'||u.role==='admin') && u.id !== Auth.user().id);
|
||||||
|
U.modal('转单', `<form id="tf-form"><div class="form-group"><label>转交给</label><select id="tf-to">${staff.map(u=>`<option value="${u.id}">${U.esc(u.game_name||u.username)} (${U.esc(u.username)})</option>`).join('')}</select></div><div class="form-group"><label>转交原因</label><textarea id="tf-reason" rows="2" placeholder="说明转交原因..."></textarea></div><div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-w"><i class="fas fa-exchange-alt"></i> 确认转交</button></div></form>`);
|
||||||
|
document.getElementById('tf-form').onsubmit = async e => { e.preventDefault();
|
||||||
|
try { await API.post(`/tickets/${id}/transfer`, {to_user_id: parseInt(document.getElementById('tf-to').value), reason: document.getElementById('tf-reason').value}); App.closeModal(); this.mount(); } catch(ex){alert(ex.message);}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async changeStatus(id) {
|
||||||
|
U.modal('变更状态', `<form id="cs-form"><div class="form-group"><label>新状态</label><select id="cs-status">${Object.entries(U.STATUS).map(([k,v])=>`<option value="${k}">${v}</option>`).join('')}</select></div><div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">保存</button></div></form>`);
|
||||||
|
document.getElementById('cs-form').onsubmit = async e => { e.preventDefault();
|
||||||
|
try { await API.put(`/tickets/${id}`, {status:document.getElementById('cs-status').value}); App.closeModal(); this.mount(); } catch(ex){alert(ex.message);}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
108
public/js/pages/tickets.js
Normal file
108
public/js/pages/tickets.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
const TicketsPage = {
|
||||||
|
tab: 'all',
|
||||||
|
filters: {},
|
||||||
|
|
||||||
|
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||||
|
|
||||||
|
async mount() {
|
||||||
|
document.getElementById('page-title').textContent = '工单列表';
|
||||||
|
document.getElementById('page-actions').innerHTML = `
|
||||||
|
<button class="btn btn-p btn-sm" onclick="App.navigate('tickets','create?type=report')"><i class="fas fa-flag"></i> 举报</button>
|
||||||
|
<button class="btn btn-s btn-sm" onclick="App.navigate('tickets','create?type=suggestion')"><i class="fas fa-lightbulb"></i> 建议</button>
|
||||||
|
`;
|
||||||
|
this.renderLayout();
|
||||||
|
await this.loadTab(this.tab);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderLayout() {
|
||||||
|
const ct = document.getElementById('page-content');
|
||||||
|
const isStaff = Auth.isAdmin();
|
||||||
|
ct.innerHTML = `
|
||||||
|
<div class="tab-nav" id="ticket-tabs">
|
||||||
|
<button data-tab="all" class="active">全部</button>
|
||||||
|
<button data-tab="pending">待认领</button>
|
||||||
|
<button data-tab="claimed">已接单</button>
|
||||||
|
${Auth.user().role === 'player' ? '<button data-tab="mine">我的工单</button>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="filter-bar">
|
||||||
|
<select id="ft-type" onchange="TicketsPage.applyFilters()"><option value="">全部类型</option>${Object.entries(U.TYPE).map(([k,v])=>`<option value="${k}">${v}</option>`).join('')}</select>
|
||||||
|
<select id="ft-status" onchange="TicketsPage.applyFilters()"><option value="">全部状态</option>${Object.entries(U.STATUS).map(([k,v])=>`<option value="${k}">${v}</option>`).join('')}</select>
|
||||||
|
<select id="ft-priority" onchange="TicketsPage.applyFilters()"><option value="">全部优先级</option>${Object.entries(U.PRIORITY).map(([k,v])=>`<option value="${k}">${v}</option>`).join('')}</select>
|
||||||
|
<input id="ft-search" placeholder="搜索..." onkeyup="if(event.key==='Enter')TicketsPage.applyFilters()">
|
||||||
|
<button class="btn btn-o btn-sm" onclick="TicketsPage.applyFilters()"><i class="fas fa-search"></i></button>
|
||||||
|
<button class="btn btn-o btn-sm" onclick="TicketsPage.resetFilters()"><i class="fas fa-undo"></i></button>
|
||||||
|
</div>
|
||||||
|
<div id="ticket-list"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.querySelectorAll('#ticket-tabs button').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
document.querySelectorAll('#ticket-tabs button').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
this.tab = btn.dataset.tab;
|
||||||
|
this.loadTab(this.tab);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadTab(tab) {
|
||||||
|
const list = document.getElementById('ticket-list');
|
||||||
|
U.loading(list);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (this.filters.type) params.set('type', this.filters.type);
|
||||||
|
if (this.filters.status) params.set('status', this.filters.status);
|
||||||
|
if (this.filters.priority) params.set('priority', this.filters.priority);
|
||||||
|
if (this.filters.search) params.set('search', this.filters.search);
|
||||||
|
|
||||||
|
if (tab === 'pending') params.set('claimed', '0');
|
||||||
|
else if (tab === 'claimed') params.set('claimed', '1');
|
||||||
|
else if (tab === 'mine') params.set('status', '');
|
||||||
|
|
||||||
|
const qs = params.toString();
|
||||||
|
const tickets = await API.get('/tickets' + (qs ? '?' + qs : ''));
|
||||||
|
|
||||||
|
if (tab === 'mine') {
|
||||||
|
this.renderList(list, tickets);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.renderList(list, tickets);
|
||||||
|
} catch (e) { list.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
renderList(ct, tickets) {
|
||||||
|
ct.innerHTML = tickets.length === 0
|
||||||
|
? `<div class="empty"><i class="fas fa-inbox"></i><p>暂无工单</p></div>`
|
||||||
|
: `<div class="card table-wrap"><table><thead><tr>
|
||||||
|
<th>#</th><th>类型</th><th>标题</th><th>提交者</th><th>对象</th><th>状态</th><th>优先级</th><th>负责人</th><th>时间</th><th>操作</th>
|
||||||
|
</tr></thead><tbody>${tickets.map(t => `
|
||||||
|
<tr>
|
||||||
|
<td>${t.id}</td>
|
||||||
|
<td>${U.badge(t.type,'type')}</td>
|
||||||
|
<td><a href="#" onclick="App.navigate('ticket',${t.id});return false" style="color:var(--p);text-decoration:none;font-weight:600">${U.esc(t.title)}</a></td>
|
||||||
|
<td>${U.esc(t.reporter_game_name)}<br><span class="tm ts">UID:${U.esc(t.reporter_game_uid)}</span></td>
|
||||||
|
<td>${t.target_game_name?`${U.esc(t.target_game_name)}${t.target_game_uid?`<br><span class="tm ts">UID:${U.esc(t.target_game_uid)}</span>`:''}`:'-'}</td>
|
||||||
|
<td>${U.badge(t.status,'status')}</td>
|
||||||
|
<td>${U.badge(t.priority,'priority')}</td>
|
||||||
|
<td>${t.assignee_name?`<span class="badge bg-admin">${U.esc(t.assignee_name)}</span>`:`<span class="tm">待认领</span>`}</td>
|
||||||
|
<td class="ts">${U.date(t.updated_at)}</td>
|
||||||
|
<td><button class="btn btn-o btn-sm" onclick="App.navigate('ticket',${t.id})"><i class="fas fa-eye"></i></button></td>
|
||||||
|
</tr>`).join('')}</tbody></table></div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
applyFilters() {
|
||||||
|
this.filters = {
|
||||||
|
type: document.getElementById('ft-type').value,
|
||||||
|
status: document.getElementById('ft-status').value,
|
||||||
|
priority: document.getElementById('ft-priority').value,
|
||||||
|
search: document.getElementById('ft-search').value,
|
||||||
|
};
|
||||||
|
this.loadTab(this.tab);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetFilters() {
|
||||||
|
this.filters = {};
|
||||||
|
document.querySelectorAll('.filter-bar select, .filter-bar input').forEach(el => { if (el.tagName === 'SELECT' || el.tagName === 'INPUT') el.value = ''; });
|
||||||
|
this.loadTab(this.tab);
|
||||||
|
}
|
||||||
|
};
|
||||||
38
public/js/pages/tracking.js
Normal file
38
public/js/pages/tracking.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const TrackingPage = {
|
||||||
|
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||||
|
|
||||||
|
mount() {
|
||||||
|
const ct = document.getElementById('page-content');
|
||||||
|
document.getElementById('page-title').textContent = '追踪工单';
|
||||||
|
document.getElementById('page-actions').innerHTML = '';
|
||||||
|
|
||||||
|
const token = document.cookie.split('; ').find(r => r.startsWith('tracking_token='))?.split('=')[1] || '';
|
||||||
|
|
||||||
|
ct.innerHTML = `
|
||||||
|
<div class="card"><div class="card-b">
|
||||||
|
<div class="form-group"><label>追踪标识 (从Cookie自动读取)</label><input id="track-token" value="${token}"></div>
|
||||||
|
<button class="btn btn-p" onclick="TrackingPage.search()"><i class="fas fa-search"></i> 查询</button>
|
||||||
|
<div id="track-result" class="mt-4"></div>
|
||||||
|
</div></div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async search() {
|
||||||
|
const token = document.getElementById('track-token').value.trim();
|
||||||
|
if (!token) return alert('请输入追踪标识');
|
||||||
|
const ct = document.getElementById('track-result');
|
||||||
|
U.loading(ct);
|
||||||
|
try {
|
||||||
|
const tickets = await API.get(`/tickets/tracking?token=${token}`);
|
||||||
|
ct.innerHTML = tickets.length ? `
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>#</th><th>类型</th><th>标题</th><th>状态</th><th>时间</th></tr></thead><tbody>${tickets.map(t => `
|
||||||
|
<tr>
|
||||||
|
<td>${t.id}</td><td>${U.badge(t.type,'type')}</td>
|
||||||
|
<td><strong>${U.esc(t.title)}</strong></td>
|
||||||
|
<td>${U.badge(t.status,'status')}</td><td class="ts">${U.date(t.created_at)}</td>
|
||||||
|
</tr>`).join('')}</tbody></table></div>
|
||||||
|
<p class="tm ts mt-4">如需获取更详细的跟踪和回复通知,请<a href="#/register" style="color:var(--p)">注册账号</a></p>
|
||||||
|
` : '<div class="empty"><i class="fas fa-inbox"></i><p>未找到工单</p></div>';
|
||||||
|
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||||
|
}
|
||||||
|
};
|
||||||
69
public/js/pages/unclaimed.js
Normal file
69
public/js/pages/unclaimed.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
const UnclaimedPage = {
|
||||||
|
filters: {},
|
||||||
|
|
||||||
|
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||||
|
|
||||||
|
async mount() {
|
||||||
|
document.getElementById('page-title').textContent = '待处理工单';
|
||||||
|
document.getElementById('page-actions').innerHTML = '';
|
||||||
|
|
||||||
|
const ct = document.getElementById('page-content');
|
||||||
|
ct.innerHTML = `
|
||||||
|
<div class="filter-bar">
|
||||||
|
<select id="u-type" onchange="UnclaimedPage.applyFilters()"><option value="">全部类型</option>${Object.entries(U.TYPE).map(([k,v])=>`<option value="${k}">${v}</option>`).join('')}</select>
|
||||||
|
<button class="btn btn-o btn-sm" onclick="UnclaimedPage.applyFilters()"><i class="fas fa-filter"></i></button>
|
||||||
|
<button class="btn btn-o btn-sm" onclick="UnclaimedPage.reset()"><i class="fas fa-undo"></i></button>
|
||||||
|
</div>
|
||||||
|
<div id="unclaimed-list"></div>
|
||||||
|
`;
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
const list = document.getElementById('unclaimed-list');
|
||||||
|
U.loading(list);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (this.filters.type) params.set('type', this.filters.type);
|
||||||
|
const qs = params.toString();
|
||||||
|
const tickets = await API.get('/tickets/unclaimed' + (qs ? '?' + qs : ''));
|
||||||
|
this.renderList(list, tickets);
|
||||||
|
} catch (e) { list.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
renderList(ct, tickets) {
|
||||||
|
ct.innerHTML = tickets.length === 0
|
||||||
|
? `<div class="empty"><i class="fas fa-check-circle"></i><p>所有工单已处理完毕</p></div>`
|
||||||
|
: `<div class="card"><div class="card-h"><span>${tickets.length} 个待处理工单</span><span class="ts tm">按状态优先级排序</span></div><div class="table-wrap"><table><thead><tr>
|
||||||
|
<th>#</th><th>类型</th><th>标题</th><th>提交者</th><th>对象/详情</th><th>状态</th><th>优先级</th><th>时间</th><th>操作</th>
|
||||||
|
</tr></thead><tbody>${tickets.map(t => `
|
||||||
|
<tr>
|
||||||
|
<td>${t.id}</td><td>${U.badge(t.type,'type')}</td>
|
||||||
|
<td><a href="#" onclick="App.navigate('ticket',${t.id});return false" style="color:var(--p);text-decoration:none;font-weight:600">${U.esc(t.title)}</a></td>
|
||||||
|
<td>${U.esc(t.reporter_game_name)}<br><span class="tm ts">UID:${U.esc(t.reporter_game_uid)}</span></td>
|
||||||
|
<td>${t.type==='report'?`${U.esc(t.target_game_name||'?')}<br><span class="tm ts">${U.esc(t.reason||'')}</span>`
|
||||||
|
:t.type==='appeal'?`<span class="tm ts">${U.esc(t.reason||'')}</span>`
|
||||||
|
:`<span class="tm ts">${U.esc((t.description||'').substring(0,30))}</span>`}</td>
|
||||||
|
<td>${U.badge(t.status,'status')}</td>
|
||||||
|
<td>${U.badge(t.priority,'priority')}</td>
|
||||||
|
<td class="ts">${U.date(t.created_at)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-s btn-sm" onclick="UnclaimedPage.quickClaim(${t.id})"><i class="fas fa-hand-paper"></i> 接单</button>
|
||||||
|
<button class="btn btn-o btn-sm" onclick="App.navigate('ticket',${t.id})"><i class="fas fa-eye"></i></button>
|
||||||
|
</td>
|
||||||
|
</tr>`).join('')}</tbody></table></div></div>`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async quickClaim(id) {
|
||||||
|
U.modal('接单认领', `<form id="qc-form"><div class="form-group"><label>初始回复 (玩家可见)</label><textarea id="qc-reply" rows="3" placeholder="接单时发送给玩家的回复..."></textarea></div><div class="form-group"><label>处理备注 (内部)</label><textarea id="qc-note" rows="2" placeholder="内部备注,玩家不可见"></textarea></div><div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-s"><i class="fas fa-hand-paper"></i> 确认接单</button></div></form>`);
|
||||||
|
document.getElementById('qc-form').onsubmit = async e => { e.preventDefault();
|
||||||
|
try { await API.post(`/tickets/${id}/claim`, {claim_note: document.getElementById('qc-note').value, initial_reply: document.getElementById('qc-reply').value}); App.closeModal(); this.load(); } catch(ex){alert(ex.message);}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
applyFilters() {
|
||||||
|
this.filters.type = document.getElementById('u-type').value;
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
reset() { this.filters = {}; this.load(); }
|
||||||
|
};
|
||||||
84
public/js/pages/users.js
Normal file
84
public/js/pages/users.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
const UsersPage = {
|
||||||
|
async render() { return '<div class="loading"><i class="fas fa-spinner"></i></div>'; },
|
||||||
|
|
||||||
|
async mount() {
|
||||||
|
document.getElementById('page-title').textContent = '用户管理';
|
||||||
|
document.getElementById('page-actions').innerHTML = '<button class="btn btn-p btn-sm" onclick="UsersPage.createUser()"><i class="fas fa-user-plus"></i> 添加用户</button>';
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
const ct = document.getElementById('page-content');
|
||||||
|
try {
|
||||||
|
const users = await API.get('/users');
|
||||||
|
ct.innerHTML = `
|
||||||
|
<div class="card table-wrap"><table><thead><tr>
|
||||||
|
<th>#</th><th>用户名</th><th>邮箱</th><th>游戏名</th><th>游戏UID</th><th>角色</th><th>状态</th><th>时间</th><th>操作</th>
|
||||||
|
</tr></thead><tbody>${users.map(u => `
|
||||||
|
<tr>
|
||||||
|
<td>${u.id}</td><td>${U.esc(u.username)}</td><td>${U.esc(u.email)}</td>
|
||||||
|
<td>${U.esc(u.game_name)}</td><td>${U.esc(u.game_uid)}</td>
|
||||||
|
<td>${U.badge(u.role,'role')}</td>
|
||||||
|
<td>${u.active ? '<span class="badge bg-resolved">启用</span>' : '<span class="badge bg-rejected">禁用</span>'}</td>
|
||||||
|
<td class="ts">${U.date(u.created_at)}</td>
|
||||||
|
<td><button class="btn btn-o btn-sm" onclick="UsersPage.editUser(${u.id}, '${U.esc(u.username)}', '${U.esc(u.email)}', '${U.esc(u.game_name)}', '${U.esc(u.game_uid)}', '${u.role}', ${u.active})"><i class="fas fa-edit"></i></button></td>
|
||||||
|
</tr>`).join('')}</tbody></table></div>
|
||||||
|
`;
|
||||||
|
} catch (e) { ct.innerHTML = `<div class="alert alert-e">${e.message}</div>`; }
|
||||||
|
},
|
||||||
|
|
||||||
|
createUser() {
|
||||||
|
U.modal('添加用户', `
|
||||||
|
<form id="cu-form">
|
||||||
|
<div class="grid-2"><div class="form-group"><label>用户名 *</label><input id="cu-u" required></div><div class="form-group"><label>密码 *</label><input type="password" id="cu-p" required minlength="6"></div></div>
|
||||||
|
<div class="grid-2"><div class="form-group"><label>邮箱 *</label><input type="email" id="cu-e" required></div><div class="form-group"><label>角色 *</label><select id="cu-r"><option value="owner">服主</option><option value="admin">管理员</option><option value="player">玩家</option></select></div></div>
|
||||||
|
<div class="grid-2"><div class="form-group"><label>游戏名 *</label><input id="cu-gn" required></div><div class="form-group"><label>游戏UID *</label><input id="cu-gu" required></div></div>
|
||||||
|
<div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">创建</button></div>
|
||||||
|
</form>
|
||||||
|
`);
|
||||||
|
document.getElementById('cu-form').onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await API.post('/users', {
|
||||||
|
username: document.getElementById('cu-u').value,
|
||||||
|
password: document.getElementById('cu-p').value,
|
||||||
|
email: document.getElementById('cu-e').value,
|
||||||
|
role: document.getElementById('cu-r').value,
|
||||||
|
game_name: document.getElementById('cu-gn').value,
|
||||||
|
game_uid: document.getElementById('cu-gu').value,
|
||||||
|
});
|
||||||
|
App.closeModal();
|
||||||
|
this.load();
|
||||||
|
} catch (ex) { alert(ex.message); }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
editUser(id, username, email, gameName, gameUid, role, active) {
|
||||||
|
U.modal('编辑用户', `
|
||||||
|
<form id="eu-form">
|
||||||
|
<div class="form-group"><label>用户名</label><input value="${username}" disabled style="background:var(--g100)"></div>
|
||||||
|
<div class="grid-2"><div class="form-group"><label>邮箱</label><input type="email" id="eu-e" value="${email}"></div><div class="form-group"><label>角色</label><select id="eu-r">${['owner','admin','player'].map(r=>`<option value="${r}" ${r===role?'selected':''}>${U.ROLE[r]}</option>`).join('')}</select></div></div>
|
||||||
|
<div class="grid-2"><div class="form-group"><label>游戏名</label><input id="eu-gn" value="${gameName}"></div><div class="form-group"><label>游戏UID</label><input id="eu-gu" value="${gameUid}"></div></div>
|
||||||
|
<div class="grid-2"><div class="form-group"><label>新密码(留空不变)</label><input type="password" id="eu-p"></div><div class="form-group"><label>状态</label><select id="eu-a"><option value="1" ${active?'selected':''}>启用</option><option value="0" ${!active?'selected':''}>禁用</option></select></div></div>
|
||||||
|
<div class="modal-f"><button type="button" class="btn btn-o" onclick="App.closeModal()">取消</button><button type="submit" class="btn btn-p">保存</button></div>
|
||||||
|
</form>
|
||||||
|
`);
|
||||||
|
document.getElementById('eu-form').onsubmit = async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const data = {
|
||||||
|
email: document.getElementById('eu-e').value,
|
||||||
|
role: document.getElementById('eu-r').value,
|
||||||
|
game_name: document.getElementById('eu-gn').value,
|
||||||
|
game_uid: document.getElementById('eu-gu').value,
|
||||||
|
active: document.getElementById('eu-a').value === '1',
|
||||||
|
};
|
||||||
|
const pw = document.getElementById('eu-p').value;
|
||||||
|
if (pw) data.password = pw;
|
||||||
|
try {
|
||||||
|
await API.put(`/users/${id}`, data);
|
||||||
|
App.closeModal();
|
||||||
|
this.load();
|
||||||
|
} catch (ex) { alert(ex.message); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
59
public/js/utils.js
Normal file
59
public/js/utils.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
const U = {
|
||||||
|
STATUS: { pending:'待处理', processing:'处理中', awaiting_info:'待补充', resolved:'已解决', rejected:'已驳回', closed:'已关闭' },
|
||||||
|
TYPE: { report:'举报', suggestion:'建议', appeal:'申诉' },
|
||||||
|
ROLE: { owner:'服主', admin:'管理员', player:'玩家' },
|
||||||
|
PRIORITY: { low:'低', medium:'中', high:'高', urgent:'紧急' },
|
||||||
|
|
||||||
|
badge(s, t) {
|
||||||
|
if (t === 'status') return `<span class="badge bg-${s}">${this.STATUS[s]||s}</span>`;
|
||||||
|
if (t === 'type') return `<span class="badge bg-${s}">${this.TYPE[s]||s}</span>`;
|
||||||
|
if (t === 'role') return `<span class="badge bg-${s}">${this.ROLE[s]||s}</span>`;
|
||||||
|
if (t === 'priority') return `<span class="badge bg-${s}">${this.PRIORITY[s]||s}</span>`;
|
||||||
|
return s;
|
||||||
|
},
|
||||||
|
|
||||||
|
date(d) {
|
||||||
|
if (!d) return '-';
|
||||||
|
const dt = new Date(d);
|
||||||
|
if (isNaN(dt.getTime())) return d;
|
||||||
|
const p = n => String(n).padStart(2, '0');
|
||||||
|
return `${dt.getFullYear()}-${p(dt.getMonth()+1)}-${p(dt.getDate())} ${p(dt.getHours())}:${p(dt.getMinutes())}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
esc(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
},
|
||||||
|
|
||||||
|
showAlert(containerId, type, msg) {
|
||||||
|
const ct = document.getElementById(containerId || 'page-content');
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = `alert alert-${type === 'success' ? 's' : type === 'error' ? 'e' : 'i'}`;
|
||||||
|
el.textContent = msg;
|
||||||
|
ct.prepend(el);
|
||||||
|
setTimeout(() => el.remove(), 6000);
|
||||||
|
},
|
||||||
|
|
||||||
|
loading(ct) { ct.innerHTML = '<div class="loading"><i class="fas fa-spinner"></i><p>加载中...</p></div>'; },
|
||||||
|
|
||||||
|
modal(title, html, w) {
|
||||||
|
const o = document.getElementById('modal-overlay');
|
||||||
|
document.getElementById('modal-title').textContent = title;
|
||||||
|
document.getElementById('modal-body').innerHTML = html;
|
||||||
|
if (w) o.querySelector('.modal-box').style.width = w;
|
||||||
|
o.classList.remove('hidden');
|
||||||
|
},
|
||||||
|
|
||||||
|
confirm(msg) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
this.modal('确认', `
|
||||||
|
<p>${msg}</p>
|
||||||
|
<div class="modal-f"><button class="btn btn-o btn-confirm-cancel">取消</button><button class="btn btn-p btn-confirm-ok">确认</button></div>
|
||||||
|
`);
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelector('.btn-confirm-cancel')?.addEventListener('click', () => { App.closeModal(); resolve(false); });
|
||||||
|
document.querySelector('.btn-confirm-ok')?.addEventListener('click', () => { App.closeModal(); resolve(true); });
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user