修复工单系统竞态与接口安全问题
This commit is contained in:
@@ -274,10 +274,11 @@ function migrateSchema() {
|
|||||||
assignee_id INTEGER,
|
assignee_id INTEGER,
|
||||||
created_at DATETIME DEFAULT (datetime('now')),
|
created_at DATETIME DEFAULT (datetime('now')),
|
||||||
updated_at DATETIME DEFAULT (datetime('now')),
|
updated_at DATETIME DEFAULT (datetime('now')),
|
||||||
last_reply_at DATETIME DEFAULT (datetime('now')),
|
last_reply_at DATETIME DEFAULT NULL,
|
||||||
first_response_at DATETIME,
|
first_response_at DATETIME,
|
||||||
resolved_at DATETIME,
|
resolved_at DATETIME,
|
||||||
closed_at DATETIME,
|
closed_at DATETIME,
|
||||||
|
revision INTEGER NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE SET NULL,
|
FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL
|
FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
)`);
|
)`);
|
||||||
@@ -305,6 +306,97 @@ function migrateSchema() {
|
|||||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
|
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
)`);
|
)`);
|
||||||
|
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_requester ON tickets(requester_id, updated_at DESC)'); } catch {}
|
||||||
|
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status_updated ON tickets(status, updated_at DESC)'); } catch {}
|
||||||
|
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_assignee ON tickets(assignee_id, status, updated_at DESC)'); } catch {}
|
||||||
|
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category, updated_at DESC)'); } catch {}
|
||||||
|
try { db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id, created_at ASC)'); } catch {}
|
||||||
|
try { db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket ON ticket_events(ticket_id, created_at ASC)'); } catch {}
|
||||||
|
} },
|
||||||
|
// v17: 工单并发版本与回复时间修正;为已存在的部分工单表补齐字段
|
||||||
|
{ version: 17, up: () => {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ticket_no TEXT NOT NULL UNIQUE,
|
||||||
|
requester_id INTEGER,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL DEFAULT 'other',
|
||||||
|
priority TEXT NOT NULL DEFAULT 'normal',
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
source TEXT NOT NULL DEFAULT 'site',
|
||||||
|
source_url TEXT DEFAULT '',
|
||||||
|
source_type TEXT DEFAULT '',
|
||||||
|
source_id INTEGER DEFAULT 0,
|
||||||
|
browser_info TEXT DEFAULT '',
|
||||||
|
assignee_id INTEGER,
|
||||||
|
created_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
updated_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
last_reply_at DATETIME DEFAULT NULL,
|
||||||
|
first_response_at DATETIME,
|
||||||
|
resolved_at DATETIME,
|
||||||
|
closed_at DATETIME,
|
||||||
|
revision INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS ticket_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ticket_id INTEGER NOT NULL,
|
||||||
|
author_id INTEGER,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
is_internal INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
updated_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS ticket_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ticket_id INTEGER NOT NULL,
|
||||||
|
actor_id INTEGER,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
field_name TEXT DEFAULT '',
|
||||||
|
old_value TEXT DEFAULT '',
|
||||||
|
new_value TEXT DEFAULT '',
|
||||||
|
detail TEXT DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)`);
|
||||||
|
const ensureColumns = (table, columns) => {
|
||||||
|
const existing = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
|
||||||
|
for (const [name, definition] of columns) {
|
||||||
|
if (!existing.has(name)) {
|
||||||
|
try { db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ensureColumns('tickets', [
|
||||||
|
['ticket_no', "TEXT NOT NULL DEFAULT ''"], ['requester_id', 'INTEGER'],
|
||||||
|
['subject', "TEXT NOT NULL DEFAULT ''"], ['description', "TEXT NOT NULL DEFAULT ''"],
|
||||||
|
['category', "TEXT NOT NULL DEFAULT 'other'"], ['priority', "TEXT NOT NULL DEFAULT 'normal'"],
|
||||||
|
['status', "TEXT NOT NULL DEFAULT 'open'"], ['source', "TEXT NOT NULL DEFAULT 'site'"],
|
||||||
|
['source_url', "TEXT DEFAULT ''"], ['source_type', "TEXT DEFAULT ''"], ['source_id', 'INTEGER DEFAULT 0'],
|
||||||
|
['browser_info', "TEXT DEFAULT ''"], ['assignee_id', 'INTEGER'],
|
||||||
|
['created_at', 'DATETIME DEFAULT NULL'], ['updated_at', 'DATETIME DEFAULT NULL'],
|
||||||
|
['last_reply_at', 'DATETIME DEFAULT NULL'], ['first_response_at', 'DATETIME DEFAULT NULL'],
|
||||||
|
['resolved_at', 'DATETIME DEFAULT NULL'], ['closed_at', 'DATETIME DEFAULT NULL'],
|
||||||
|
['revision', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
|
]);
|
||||||
|
ensureColumns('ticket_messages', [
|
||||||
|
['ticket_id', 'INTEGER NOT NULL DEFAULT 0'], ['author_id', 'INTEGER'],
|
||||||
|
['content', "TEXT NOT NULL DEFAULT ''"], ['is_internal', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
|
['created_at', 'DATETIME DEFAULT NULL'], ['updated_at', 'DATETIME DEFAULT NULL'],
|
||||||
|
]);
|
||||||
|
ensureColumns('ticket_events', [
|
||||||
|
['ticket_id', 'INTEGER NOT NULL DEFAULT 0'], ['actor_id', 'INTEGER'],
|
||||||
|
['event_type', "TEXT NOT NULL DEFAULT ''"], ['field_name', "TEXT DEFAULT ''"],
|
||||||
|
['old_value', "TEXT DEFAULT ''"], ['new_value', "TEXT DEFAULT ''"],
|
||||||
|
['detail', "TEXT DEFAULT ''"], ['created_at', 'DATETIME DEFAULT NULL'],
|
||||||
|
]);
|
||||||
|
db.exec(`UPDATE tickets SET last_reply_at = NULL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM ticket_messages WHERE ticket_messages.ticket_id = tickets.id)`);
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_requester ON tickets(requester_id, updated_at DESC)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_requester ON tickets(requester_id, updated_at DESC)');
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status_updated ON tickets(status, updated_at DESC)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status_updated ON tickets(status, updated_at DESC)');
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_assignee ON tickets(assignee_id, status, updated_at DESC)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_assignee ON tickets(assignee_id, status, updated_at DESC)');
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# RainWeb 工单系统后续完善计划
|
||||||
|
|
||||||
|
## 当前定位
|
||||||
|
|
||||||
|
RainWeb 当前已具备登录用户提交和跟踪、管理员队列处理、状态/优先级/负责人、公开回复、内部备注、基础事件时间线、筛选搜索和分页,定位为可用 MVP。
|
||||||
|
|
||||||
|
本计划只覆盖审查后确认的后续增强,不影响当前 Bug 修复批次。
|
||||||
|
|
||||||
|
## 阶段一:运营上线前(P0)
|
||||||
|
|
||||||
|
目标:控制滥用风险,保护工单隐私,保证管理员团队可以稳定协作。
|
||||||
|
|
||||||
|
| 项目 | 主要内容 | 复杂度 | 依赖 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 工单反滥用 | 按 IP/用户限流、未关闭工单配额、重复提交幂等键、必要时验证码 | 中 | 现有 tickets、captcha |
|
||||||
|
| 私有附件 | 工单附件关联表、引用对象校验、私有下载鉴权、配额、孤儿清理、下载审计 | 中高 | upload、权限模型 |
|
||||||
|
| 通知闭环 | 创建确认、公开回复、状态/负责人变化通知;投递记录、失败重试和管理员告警 | 中 | email、异步任务 |
|
||||||
|
| 最小权限模型 | 客服、主管、只读审计角色;按队列/分类限制访问范围 | 中高 | 用户角色、工单分类 |
|
||||||
|
| 自动化测试 | 越权访问、内部备注隔离、状态流转、并发更新、限流、附件访问、迁移兼容 | 中 | 以上后端能力 |
|
||||||
|
|
||||||
|
## 阶段二:处理效率(P1)
|
||||||
|
|
||||||
|
目标:让工单处理可衡量、可搜索、可协作。
|
||||||
|
|
||||||
|
- SLA 策略:按优先级/分类设置首次响应和解决目标。
|
||||||
|
- SLA 计时:支持工作时间、节假日,并在“等待用户”时暂停。
|
||||||
|
- 超时处理:临界提醒、违约标记、自动升级和主管队列。
|
||||||
|
- 全文搜索:搜索回复、用户、负责人、来源和日期;数据量增长后接入 SQLite FTS5。
|
||||||
|
- 工单协作:快捷回复模板、标签、合并/关联工单、重复反馈识别。
|
||||||
|
- 用户体验:通知偏好、未读数、满意度评价、前台完整分页。
|
||||||
|
- 完整审计:操作者、IP、请求 ID、前后值、后台查询和导出。
|
||||||
|
|
||||||
|
## 阶段三:规模化与治理(P2)
|
||||||
|
|
||||||
|
目标:支持长期运营、外部渠道和故障恢复。
|
||||||
|
|
||||||
|
- 邮件入站转工单、退信处理和邮件线程关联。
|
||||||
|
- 附件真实 MIME/魔数检查、病毒扫描、图片重编码,必要时迁移对象存储。
|
||||||
|
- 运营报表:积压、响应时长、解决时长、重开率、SLA 达成率和客服负载。
|
||||||
|
- 健康检查、就绪检查、结构化日志、指标、告警和磁盘容量保护。
|
||||||
|
- 数据保留、删除/匿名化、用户数据导出、备份加密、轮换、异地保存和恢复演练。
|
||||||
|
|
||||||
|
## 推荐实施顺序
|
||||||
|
|
||||||
|
1. 先完成阶段一的权限和附件边界,再接入通知,避免通知泄漏内部数据。
|
||||||
|
2. 在限流、分页和审计基础上实现 SLA,避免统计建立在不完整事件数据上。
|
||||||
|
3. 最后实现邮件入站、病毒扫描、对象存储和运营报表等高复杂度能力。
|
||||||
|
|
||||||
|
## 暂不纳入本批次
|
||||||
|
|
||||||
|
本批次只修复审查发现的代码 Bug:前端请求竞态、记录排序、分页/来源地址校验、状态选项和文案一致性,以及后端限流/响应放大、并发更新、用户存在性、时间字段、事件契约和迁移兼容。上述阶段功能不会在 Bug 修复批次中顺手实现。
|
||||||
|
|
||||||
|
## 参考资料
|
||||||
|
|
||||||
|
- SQLite FTS5:https://sqlite.org/fts5.html
|
||||||
|
- OWASP 文件上传安全:https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
|
||||||
|
- OWASP 日志安全:https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
|
||||||
|
- Google SRE 监控原则:https://sre.google/sre-book/monitoring/
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Paper from '@mui/material/Paper';
|
import Paper from '@mui/material/Paper';
|
||||||
import Stack from '@mui/material/Stack';
|
import Stack from '@mui/material/Stack';
|
||||||
@@ -17,6 +17,7 @@ import RefreshIcon from '@mui/icons-material/Refresh';
|
|||||||
import SendIcon from '@mui/icons-material/Send';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore';
|
import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore';
|
||||||
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
|
||||||
|
import { normalizeTicketListResponse } from '../../api/tickets.js';
|
||||||
import { request } from '../../api/client.js';
|
import { request } from '../../api/client.js';
|
||||||
import { showSnack } from '../snack.jsx';
|
import { showSnack } from '../snack.jsx';
|
||||||
|
|
||||||
@@ -28,6 +29,13 @@ const STATUS = {
|
|||||||
resolved: { label: '已解决', color: 'success' },
|
resolved: { label: '已解决', color: 'success' },
|
||||||
closed: { label: '已关闭', color: 'default' },
|
closed: { label: '已关闭', color: 'default' },
|
||||||
};
|
};
|
||||||
|
const STATUS_TRANSITIONS = {
|
||||||
|
open: ['processing', 'closed'],
|
||||||
|
processing: ['waiting', 'resolved', 'closed'],
|
||||||
|
waiting: ['processing', 'closed'],
|
||||||
|
resolved: ['closed', 'processing'],
|
||||||
|
closed: ['processing'],
|
||||||
|
};
|
||||||
const PRIORITY = {
|
const PRIORITY = {
|
||||||
low: { label: '低', color: 'default' },
|
low: { label: '低', color: 'default' },
|
||||||
normal: { label: '普通', color: 'info' },
|
normal: { label: '普通', color: 'info' },
|
||||||
@@ -80,32 +88,60 @@ export default function TicketManage() {
|
|||||||
const [internal, setInternal] = useState('');
|
const [internal, setInternal] = useState('');
|
||||||
const [assignees, setAssignees] = useState([]);
|
const [assignees, setAssignees] = useState([]);
|
||||||
const [saveFeedback, setSaveFeedback] = useState({ type: '', text: '' });
|
const [saveFeedback, setSaveFeedback] = useState({ type: '', text: '' });
|
||||||
|
const listRequestRef = useRef(0);
|
||||||
|
const statsRequestRef = useRef(0);
|
||||||
|
const detailRequestRef = useRef(0);
|
||||||
|
const selectedIdRef = useRef(null);
|
||||||
|
|
||||||
const loadStats = useCallback(() => {
|
const loadStats = useCallback(() => {
|
||||||
request('/tickets/admin/stats').then((data) => setStats(data || {})).catch(() => {});
|
const requestId = ++statsRequestRef.current;
|
||||||
|
request('/tickets/admin/stats').then((data) => {
|
||||||
|
if (requestId === statsRequestRef.current) setStats(data || {});
|
||||||
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadList = useCallback(() => {
|
const loadList = useCallback(() => {
|
||||||
|
const requestId = ++listRequestRef.current;
|
||||||
setLoading(true); setError('');
|
setLoading(true); setError('');
|
||||||
const params = new URLSearchParams({ page: String(page), pageSize: String(PAGE_SIZE) });
|
const params = new URLSearchParams({ page: String(page), pageSize: String(PAGE_SIZE) });
|
||||||
Object.entries(filters).forEach(([key, value]) => { if (value) params.set(key, value); });
|
Object.entries(filters).forEach(([key, value]) => { if (value) params.set(key, value); });
|
||||||
request(`/tickets/admin?${params.toString()}`)
|
request(`/tickets/admin?${params.toString()}`)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const rows = data.list || data.tickets || [];
|
if (requestId !== listRequestRef.current) return;
|
||||||
setList(rows); setTotal(Number(data.total || 0));
|
const normalized = normalizeTicketListResponse(data, PAGE_SIZE);
|
||||||
if (selectedId && !rows.some((row) => String(row.id) === String(selectedId))) setSelectedId(null);
|
setList(normalized.tickets); setTotal(normalized.total);
|
||||||
})
|
})
|
||||||
.catch((e) => setError(e.message || '工单加载失败'))
|
.catch((e) => {
|
||||||
.finally(() => setLoading(false));
|
if (requestId === listRequestRef.current) setError(e.message || '工单加载失败');
|
||||||
}, [filters, page, selectedId]);
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (requestId === listRequestRef.current) setLoading(false);
|
||||||
|
});
|
||||||
|
}, [filters, page]);
|
||||||
|
|
||||||
const loadDetail = useCallback((id) => {
|
const loadDetail = useCallback((id) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
const changed = String(selectedIdRef.current) !== String(id);
|
||||||
|
selectedIdRef.current = id;
|
||||||
|
const requestId = ++detailRequestRef.current;
|
||||||
setSelectedId(id); setDetailLoading(true);
|
setSelectedId(id); setDetailLoading(true);
|
||||||
|
setDetail(null);
|
||||||
|
if (changed) {
|
||||||
|
setReply(''); setInternal('');
|
||||||
|
setSaveFeedback({ type: '', text: '' });
|
||||||
|
}
|
||||||
request(`/tickets/${encodeURIComponent(id)}`)
|
request(`/tickets/${encodeURIComponent(id)}`)
|
||||||
.then((data) => setDetail(data.ticket ? data : { ticket: data, messages: [], events: [] }))
|
.then((data) => {
|
||||||
.catch((e) => showSnack(e.message || '详情加载失败', 'error'))
|
if (requestId === detailRequestRef.current && String(selectedIdRef.current) === String(id)) {
|
||||||
.finally(() => setDetailLoading(false));
|
setDetail(data.ticket ? data : { ticket: data, messages: [], events: [] });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (requestId === detailRequestRef.current && String(selectedIdRef.current) === String(id)) showSnack(e.message || '详情加载失败', 'error');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (requestId === detailRequestRef.current && String(selectedIdRef.current) === String(id)) setDetailLoading(false);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { loadList(); }, [loadList]);
|
useEffect(() => { loadList(); }, [loadList]);
|
||||||
@@ -135,33 +171,60 @@ export default function TicketManage() {
|
|||||||
const updateFilter = (key, value) => { setPage(1); setFilters((old) => ({ ...old, [key]: value })); };
|
const updateFilter = (key, value) => { setPage(1); setFilters((old) => ({ ...old, [key]: value })); };
|
||||||
const refresh = () => { loadList(); loadStats(); if (selectedId) loadDetail(selectedId); };
|
const refresh = () => { loadList(); loadStats(); if (selectedId) loadDetail(selectedId); };
|
||||||
|
|
||||||
const updateTicket = async (path, body, message) => {
|
const updateTicket = async (path, body, message, ticketId) => {
|
||||||
|
if (!ticketId || String(selectedIdRef.current) !== String(ticketId)) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveFeedback({ type: 'saving', text: '正在保存…' });
|
setSaveFeedback({ type: 'saving', text: '正在保存…' });
|
||||||
try {
|
try {
|
||||||
await request(path, { method: 'PUT', body });
|
await request(path, { method: 'PUT', body });
|
||||||
|
if (String(selectedIdRef.current) !== String(ticketId)) return;
|
||||||
setSaveFeedback({ type: 'success', text: '已保存' });
|
setSaveFeedback({ type: 'success', text: '已保存' });
|
||||||
showSnack(message); loadDetail(selectedId); loadList(); loadStats();
|
showSnack(message); loadDetail(ticketId); loadList(); loadStats();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setSaveFeedback({ type: 'error', text: e.message || '保存失败,请重试' });
|
if (String(selectedIdRef.current) === String(ticketId)) {
|
||||||
showSnack(e.message || '保存失败', 'error');
|
setSaveFeedback({ type: 'error', text: e.message || '保存失败,请重试' });
|
||||||
|
showSnack(e.message || '保存失败', 'error');
|
||||||
|
}
|
||||||
} finally { setSaving(false); }
|
} finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
const sendMessage = async (internalMessage) => {
|
const sendMessage = async (internalMessage, ticketId) => {
|
||||||
|
if (!ticketId || String(selectedIdRef.current) !== String(ticketId)) return;
|
||||||
const content = (internalMessage ? internal : reply).trim();
|
const content = (internalMessage ? internal : reply).trim();
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await request(`/tickets/${selectedId}/${internalMessage ? 'internal-messages' : 'messages'}`, { method: 'POST', body: { content } });
|
await request(`/tickets/${ticketId}/${internalMessage ? 'internal-messages' : 'messages'}`, { method: 'POST', body: { content } });
|
||||||
|
if (String(selectedIdRef.current) !== String(ticketId)) return;
|
||||||
if (internalMessage) setInternal(''); else setReply('');
|
if (internalMessage) setInternal(''); else setReply('');
|
||||||
showSnack(internalMessage ? '内部备注已添加' : '公开回复已发送');
|
showSnack(internalMessage ? '内部备注已添加' : '公开回复已发送');
|
||||||
loadDetail(selectedId); loadList(); loadStats();
|
loadDetail(ticketId); loadList(); loadStats();
|
||||||
} catch (e) { showSnack(e.message || '发送失败', 'error'); }
|
} catch (e) {
|
||||||
|
if (String(selectedIdRef.current) === String(ticketId)) showSnack(e.message || '发送失败', 'error');
|
||||||
|
}
|
||||||
finally { setSaving(false); }
|
finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const messages = detail?.messages || [];
|
const messages = detail?.messages || [];
|
||||||
const events = detail?.events || [];
|
const events = detail?.events || [];
|
||||||
|
const timeline = useMemo(() => [
|
||||||
|
...messages.map((item) => ({ type: 'message', item })),
|
||||||
|
...events.map((item) => ({ type: 'event', item })),
|
||||||
|
].sort((a, b) => {
|
||||||
|
const timeA = Date.parse(String(a.item.created_at || '').replace(' ', 'T'));
|
||||||
|
const timeB = Date.parse(String(b.item.created_at || '').replace(' ', 'T'));
|
||||||
|
const timeDiff = (Number.isNaN(timeA) ? Number.MAX_SAFE_INTEGER : timeA)
|
||||||
|
- (Number.isNaN(timeB) ? Number.MAX_SAFE_INTEGER : timeB);
|
||||||
|
if (timeDiff) return timeDiff;
|
||||||
|
const createdDiff = String(a.item.created_at || '').localeCompare(String(b.item.created_at || ''));
|
||||||
|
if (createdDiff) return createdDiff;
|
||||||
|
const idA = Number(a.item.id);
|
||||||
|
const idB = Number(b.item.id);
|
||||||
|
if (Number.isFinite(idA) && Number.isFinite(idB) && idA !== idB) return idA - idB;
|
||||||
|
return a.type === b.type ? 0 : a.type === 'event' ? 1 : -1;
|
||||||
|
}), [messages, events]);
|
||||||
|
const currentStatus = selectedTicket?.status || 'open';
|
||||||
|
const statusOptions = [currentStatus, ...(STATUS_TRANSITIONS[currentStatus] || [])]
|
||||||
|
.filter((value, index, values) => STATUS[value] && values.indexOf(value) === index);
|
||||||
const statItems = useMemo(() => [
|
const statItems = useMemo(() => [
|
||||||
['全部', stats.total || total, null], ['待处理', stats.open || 0, 'warning'],
|
['全部', stats.total || total, null], ['待处理', stats.open || 0, 'warning'],
|
||||||
['处理中', stats.processing || 0, 'info'], ['等待用户', stats.waiting || 0, 'secondary'],
|
['处理中', stats.processing || 0, 'info'], ['等待用户', stats.waiting || 0, 'secondary'],
|
||||||
@@ -179,7 +242,7 @@ export default function TicketManage() {
|
|||||||
{statItems.map(([label, value, tone]) => <StatCard key={label} label={label} value={value} tone={tone} />)}
|
{statItems.map(([label, value, tone]) => <StatCard key={label} label={label} value={value} tone={tone} />)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }} component="form" onSubmit={(e) => { e.preventDefault(); setPage(1); loadList(); }}>
|
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }} component="form" onSubmit={(e) => { e.preventDefault(); if (page === 1) loadList(); else setPage(1); }}>
|
||||||
<Box className="ticket-filter-grid">
|
<Box className="ticket-filter-grid">
|
||||||
<TextField className="ticket-filter-search" fullWidth size="small" label="搜索工单" placeholder="编号、标题或内容" value={filters.q} onChange={(e) => updateFilter('q', e.target.value)} />
|
<TextField className="ticket-filter-search" fullWidth size="small" label="搜索工单" placeholder="编号、标题或内容" value={filters.q} onChange={(e) => updateFilter('q', e.target.value)} />
|
||||||
<TextField fullWidth select size="small" label="状态" value={filters.status} onChange={(e) => updateFilter('status', e.target.value)}><MenuItem value="">全部状态</MenuItem>{Object.entries(STATUS).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField>
|
<TextField fullWidth select size="small" label="状态" value={filters.status} onChange={(e) => updateFilter('status', e.target.value)}><MenuItem value="">全部状态</MenuItem>{Object.entries(STATUS).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField>
|
||||||
@@ -213,11 +276,11 @@ export default function TicketManage() {
|
|||||||
{!selectedId ? <Box sx={{ py: 10, textAlign: 'center' }}><Typography variant="h6" color="text.secondary">选择一个工单开始处理</Typography><Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>工单详情、回复和状态操作会显示在这里</Typography></Box> : detailLoading ? <Box role="status" aria-label="正在加载工单详情" sx={{ py: 10, textAlign: 'center' }}><CircularProgress /></Box> : selectedTicket ? (
|
{!selectedId ? <Box sx={{ py: 10, textAlign: 'center' }}><Typography variant="h6" color="text.secondary">选择一个工单开始处理</Typography><Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>工单详情、回复和状态操作会显示在这里</Typography></Box> : detailLoading ? <Box role="status" aria-label="正在加载工单详情" sx={{ py: 10, textAlign: 'center' }}><CircularProgress /></Box> : selectedTicket ? (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<Box><Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap><Typography variant="h6" component="h2" sx={{ overflowWrap: 'anywhere' }}>{selectedTicket.subject || selectedTicket.title}</Typography><StatusChip value={selectedTicket.status} /><PriorityChip value={selectedTicket.priority} /></Stack><Typography variant="caption" color="text.secondary">{selectedTicket.ticket_no || `#${selectedTicket.id}`} · {selectedTicket.requester_name || selectedTicket.requester_username || '未知用户'} · 创建于 {fmtTime(selectedTicket.created_at)}</Typography></Box>
|
<Box><Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap><Typography variant="h6" component="h2" sx={{ overflowWrap: 'anywhere' }}>{selectedTicket.subject || selectedTicket.title}</Typography><StatusChip value={selectedTicket.status} /><PriorityChip value={selectedTicket.priority} /></Stack><Typography variant="caption" color="text.secondary">{selectedTicket.ticket_no || `#${selectedTicket.id}`} · {selectedTicket.requester_name || selectedTicket.requester_username || '未知用户'} · 创建于 {fmtTime(selectedTicket.created_at)}</Typography></Box>
|
||||||
<Box><Grid container spacing={1.25}><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="状态" helperText="管理员可更新处理阶段" value={selectedTicket.status || 'open'} disabled={saving} onChange={(e) => updateTicket(`/tickets/${selectedId}/status`, { status: e.target.value }, '状态已更新')}>{Object.entries(STATUS).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField></Grid><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="优先级" helperText="用于安排处理顺序" value={selectedTicket.priority || 'normal'} disabled={saving} onChange={(e) => updateTicket(`/tickets/${selectedId}/priority`, { priority: e.target.value }, '优先级已更新')}>{Object.entries(PRIORITY).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField></Grid><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="负责人" helperText="可选择管理员或设为未分配" value={selectedTicket.assignee_id == null ? '' : String(selectedTicket.assignee_id)} disabled={saving} SelectProps={{ renderValue: (value) => { const person = normalizedAssignees.find((user) => user.id === String(value)); return <Box component="span" className="ticket-select-value" title={person ? person.displayName : '未分配'}>{person ? person.displayName : '未分配'}</Box>; } }} onChange={(e) => updateTicket(`/tickets/${selectedId}/assignee`, { assignee_id: e.target.value ? Number(e.target.value) || e.target.value : null }, '负责人已更新')}><MenuItem value="">未分配</MenuItem>{normalizedAssignees.map((user) => <MenuItem key={user.id} value={user.id} sx={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>{user.displayName}</MenuItem>)}</TextField></Grid></Grid>{saveFeedback.text && <Typography className={`ticket-save-feedback ticket-save-${saveFeedback.type}`} variant="caption" role={saveFeedback.type === 'error' ? 'alert' : 'status'} sx={{ display: 'block', mt: 0.75 }}>{saveFeedback.text}</Typography>}</Box>
|
<Box><Grid container spacing={1.25}><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="状态" helperText="管理员可更新处理阶段" value={selectedTicket.status || 'open'} disabled={saving} onChange={(e) => updateTicket(`/tickets/${selectedTicket.id}/status`, { status: e.target.value }, '状态已更新', selectedTicket.id)}>{statusOptions.map((key) => <MenuItem key={key} value={key}>{STATUS[key].label}</MenuItem>)}</TextField></Grid><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="优先级" helperText="用于安排处理顺序" value={selectedTicket.priority || 'normal'} disabled={saving} onChange={(e) => updateTicket(`/tickets/${selectedTicket.id}/priority`, { priority: e.target.value }, '优先级已更新', selectedTicket.id)}>{Object.entries(PRIORITY).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField></Grid><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="负责人" helperText="可选择管理员或设为未分配" value={selectedTicket.assignee_id == null ? '' : String(selectedTicket.assignee_id)} disabled={saving} SelectProps={{ renderValue: (value) => { const person = normalizedAssignees.find((user) => user.id === String(value)); return <Box component="span" className="ticket-select-value" title={person ? person.displayName : '未分配'}>{person ? person.displayName : '未分配'}</Box>; } }} onChange={(e) => updateTicket(`/tickets/${selectedTicket.id}/assignee`, { assignee_id: e.target.value ? Number(e.target.value) || e.target.value : null }, '负责人已更新', selectedTicket.id)}><MenuItem value="">未分配</MenuItem>{normalizedAssignees.map((user) => <MenuItem key={user.id} value={user.id} sx={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>{user.displayName}</MenuItem>)}</TextField></Grid></Grid>{saveFeedback.text && <Typography className={`ticket-save-feedback ticket-save-${saveFeedback.type}`} variant="caption" role={saveFeedback.type === 'error' ? 'alert' : 'status'} sx={{ display: 'block', mt: 0.75 }}>{saveFeedback.text}</Typography>}</Box>
|
||||||
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'action.hover' }}><Typography variant="subtitle2" gutterBottom>问题描述</Typography><Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{selectedTicket.description || '暂无描述'}</Typography>{selectedTicket.source_url && <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1, overflowWrap: 'anywhere' }}>来源:{selectedTicket.source_url}</Typography>}</Paper>
|
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'action.hover' }}><Typography variant="subtitle2" gutterBottom>问题描述</Typography><Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{selectedTicket.description || '暂无描述'}</Typography>{selectedTicket.source_url && <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1, overflowWrap: 'anywhere' }}>来源:{selectedTicket.source_url}</Typography>}</Paper>
|
||||||
<Box><Typography variant="subtitle2" sx={{ mb: 1 }}>处理记录</Typography><Stack component="ol" spacing={1.25} sx={{ m: 0, pl: 2.5 }}>{messages.map((message) => <Box component="li" key={`m-${message.id}`}><Paper variant="outlined" sx={{ p: 1.25 }}><Stack direction="row" spacing={1} alignItems="center"><Avatar sx={{ width: 26, height: 26, fontSize: 12 }}>{String(message.author_name || message.author_username || '管')[0]}</Avatar><Typography variant="body2" fontWeight={600}>{message.author_name || message.author_username || '管理员'}</Typography>{message.is_internal ? <Chip size="small" label="内部备注" color="warning" /> : <Chip size="small" label="公开回复" variant="outlined" />}<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>{fmtTime(message.created_at)}</Typography></Stack><Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', mt: 1 }}>{message.content}</Typography></Paper></Box>)}{events.map((event) => <Box component="li" key={`e-${event.id}`}><Typography variant="caption" color="text.secondary">{fmtTime(event.created_at)} · {event.detail || `${event.field_name || '工单'}已更新`}</Typography></Box>)}</Stack></Box>
|
<Box><Typography variant="subtitle2" sx={{ mb: 1 }}>处理记录</Typography><Stack component="ol" spacing={1.25} sx={{ m: 0, pl: 2.5 }}>{timeline.map((entry) => { if (entry.type === 'message') { const message = entry.item; return <Box component="li" key={`m-${message.id}`}><Paper variant="outlined" sx={{ p: 1.25 }}><Stack direction="row" spacing={1} alignItems="center"><Avatar sx={{ width: 26, height: 26, fontSize: 12 }}>{String(message.author_name || message.author_username || '管')[0]}</Avatar><Typography variant="body2" fontWeight={600}>{message.author_name || message.author_username || '管理员'}</Typography>{message.is_internal ? <Chip size="small" label="内部备注" color="warning" /> : <Chip size="small" label="公开回复" variant="outlined" />}<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>{fmtTime(message.created_at)}</Typography></Stack><Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', mt: 1 }}>{message.content}</Typography></Paper></Box>; } const event = entry.item; return <Box component="li" key={`e-${event.id}`}><Typography variant="caption" color="text.secondary">{fmtTime(event.created_at)} · {event.detail || `${event.field_name || '工单'}已更新`}</Typography></Box>; })}</Stack></Box>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Grid container spacing={1.5}><Grid item xs={12} md={6}><TextField fullWidth multiline minRows={3} label="公开回复" placeholder="回复内容会发送给用户" value={reply} disabled={saving} onChange={(e) => setReply(e.target.value)} /><Button sx={{ mt: 1 }} variant="contained" startIcon={<SendIcon />} disabled={saving || !reply.trim()} onClick={() => sendMessage(false)}>发送公开回复</Button></Grid><Grid item xs={12} md={6}><TextField fullWidth multiline minRows={3} label="内部备注" placeholder="仅管理员可见" value={internal} disabled={saving} onChange={(e) => setInternal(e.target.value)} /><Button sx={{ mt: 1 }} variant="outlined" color="warning" disabled={saving || !internal.trim()} onClick={() => sendMessage(true)}>添加内部备注</Button></Grid></Grid>
|
<Grid container spacing={1.5}><Grid item xs={12} md={6}><TextField fullWidth multiline minRows={3} label="公开回复" placeholder="回复内容会发送给用户" value={reply} disabled={saving} onChange={(e) => setReply(e.target.value)} /><Button sx={{ mt: 1 }} variant="contained" startIcon={<SendIcon />} disabled={saving || !reply.trim()} onClick={() => sendMessage(false, selectedTicket.id)}>发送公开回复</Button></Grid><Grid item xs={12} md={6}><TextField fullWidth multiline minRows={3} label="内部备注" placeholder="仅管理员可见" value={internal} disabled={saving} onChange={(e) => setInternal(e.target.value)} /><Button sx={{ mt: 1 }} variant="outlined" color="warning" disabled={saving || !internal.trim()} onClick={() => sendMessage(true, selectedTicket.id)}>添加内部备注</Button></Grid></Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : <Alert severity="error">工单详情不存在或加载失败。</Alert>}
|
) : <Alert severity="error">工单详情不存在或加载失败。</Alert>}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import { request } from './client.js';
|
import { request } from './client.js';
|
||||||
|
|
||||||
|
export function normalizeTicketListResponse(data, fallbackPageSize = 20) {
|
||||||
|
const payload = Array.isArray(data) ? { tickets: data } : (data && typeof data === 'object' ? data : {});
|
||||||
|
const tickets = Array.isArray(payload.tickets)
|
||||||
|
? payload.tickets
|
||||||
|
: (Array.isArray(payload.list) ? payload.list : (Array.isArray(payload.items) ? payload.items : []));
|
||||||
|
const pageSizeValue = Number(payload.pageSize ?? payload.page_size);
|
||||||
|
const pageSize = Number.isInteger(pageSizeValue) && pageSizeValue > 0 ? pageSizeValue : fallbackPageSize;
|
||||||
|
const totalValue = Number(payload.total ?? payload.count);
|
||||||
|
const total = Number.isFinite(totalValue) && totalValue >= 0 ? totalValue : tickets.length;
|
||||||
|
const totalPagesValue = Number(payload.totalPages ?? payload.total_pages ?? payload.pages);
|
||||||
|
const totalPages = Number.isInteger(totalPagesValue) && totalPagesValue > 0
|
||||||
|
? totalPagesValue
|
||||||
|
: Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
return { tickets, total, totalPages };
|
||||||
|
}
|
||||||
|
|
||||||
export function listTickets(params = {}) {
|
export function listTickets(params = {}) {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params.page) query.set('page', String(params.page));
|
if (params.page) query.set('page', String(params.page));
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getToken } from '../api/client.js';
|
|||||||
import { safeSourceUrl } from './Tickets.jsx';
|
import { safeSourceUrl } from './Tickets.jsx';
|
||||||
|
|
||||||
const categories = [['forum_bug', '论坛 Bug'], ['site_bug', '站内 Bug'], ['feature', '功能建议'], ['account', '账号问题'], ['other', '其他问题']];
|
const categories = [['forum_bug', '论坛 Bug'], ['site_bug', '站内 Bug'], ['feature', '功能建议'], ['account', '账号问题'], ['other', '其他问题']];
|
||||||
const priorities = [['low', '普通'], ['normal', '一般'], ['high', '重要'], ['urgent', '紧急']];
|
const priorities = [['low', '低'], ['normal', '普通'], ['high', '高'], ['urgent', '紧急']];
|
||||||
|
|
||||||
export default function TicketCreate() {
|
export default function TicketCreate() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -26,12 +26,19 @@ export default function TicketCreate() {
|
|||||||
if (description.length < 10) { setError('请详细描述问题(至少 10 个字)'); return; }
|
if (description.length < 10) { setError('请详细描述问题(至少 10 个字)'); return; }
|
||||||
setError(''); setBusy(true);
|
setError(''); setBusy(true);
|
||||||
try {
|
try {
|
||||||
|
const sourceValue = String(form.source_url ?? '');
|
||||||
|
const sourceUrl = safeSourceUrl(sourceValue);
|
||||||
|
const hasInvalidSource = sourceValue.trim() || /[\u0000-\u001f\u007f-\u009f]/.test(sourceValue) || sourceValue.includes('\\');
|
||||||
|
if (hasInvalidSource && !sourceUrl) {
|
||||||
|
setError('来源地址不合法,请填写站内路径或 http/https 地址');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data = await ticketsApi.createTicket({
|
const data = await ticketsApi.createTicket({
|
||||||
...form,
|
...form,
|
||||||
subject,
|
subject,
|
||||||
description,
|
description,
|
||||||
source: form.category === 'forum_bug' ? 'forum' : 'site',
|
source: form.category === 'forum_bug' ? 'forum' : 'site',
|
||||||
source_url: safeSourceUrl(form.source_url),
|
source_url: sourceUrl,
|
||||||
browser_info: [navigator.userAgent, `${window.innerWidth}x${window.innerHeight}`].join(' | ').slice(0, 1000),
|
browser_info: [navigator.userAgent, `${window.innerWidth}x${window.innerHeight}`].join(' | ').slice(0, 1000),
|
||||||
});
|
});
|
||||||
if (!data.ticket || !data.ticket.id) throw new Error('工单创建成功但未返回编号');
|
if (!data.ticket || !data.ticket.id) throw new Error('工单创建成功但未返回编号');
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { Link, useParams } from 'react-router-dom';
|
||||||
import * as ticketsApi from '../api/tickets.js';
|
import * as ticketsApi from '../api/tickets.js';
|
||||||
import { me } from '../api/auth.js';
|
import { me } from '../api/auth.js';
|
||||||
import { getToken } from '../api/client.js';
|
import { getToken } from '../api/client.js';
|
||||||
import { categoryLabel, formatTime, safeSourceUrl, statusInfo } from './Tickets.jsx';
|
import { categoryLabel, formatTime, priorityLabel, safeSourceUrl, statusInfo } from './Tickets.jsx';
|
||||||
|
|
||||||
const STEPS = ['open', 'processing', 'waiting', 'resolved', 'closed'];
|
const STEPS = ['open', 'processing', 'waiting', 'resolved', 'closed'];
|
||||||
const PUBLIC_EVENTS = new Set(['ticket_created', 'message_added', 'status_changed', 'ticket_closed', 'ticket_reopened']);
|
const PUBLIC_EVENTS = new Set(['ticket_created', 'message_added', 'status_changed', 'ticket_closed', 'ticket_reopened']);
|
||||||
@@ -43,7 +43,22 @@ export default function TicketDetail() {
|
|||||||
const [user, setUser] = useState(null); const [data, setData] = useState(null);
|
const [user, setUser] = useState(null); const [data, setData] = useState(null);
|
||||||
const [loading, setLoading] = useState(true); const [error, setError] = useState('');
|
const [loading, setLoading] = useState(true); const [error, setError] = useState('');
|
||||||
const [reply, setReply] = useState(''); const [busy, setBusy] = useState(false);
|
const [reply, setReply] = useState(''); const [busy, setBusy] = useState(false);
|
||||||
const load = useCallback(async () => { setLoading(true); setError(''); try { setData(await ticketsApi.getTicket(id)); } catch (e) { setError(e.message || '工单加载失败'); } finally { setLoading(false); } }, [id]);
|
const detailRequestRef = useRef(0);
|
||||||
|
const activeIdRef = useRef(id);
|
||||||
|
activeIdRef.current = id;
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (String(activeIdRef.current) !== String(id)) return;
|
||||||
|
const requestId = ++detailRequestRef.current;
|
||||||
|
setLoading(true); setError(''); setData(null);
|
||||||
|
try {
|
||||||
|
const nextData = await ticketsApi.getTicket(id);
|
||||||
|
if (requestId === detailRequestRef.current) setData(nextData);
|
||||||
|
} catch (e) {
|
||||||
|
if (requestId === detailRequestRef.current) setError(e.message || '工单加载失败');
|
||||||
|
} finally {
|
||||||
|
if (requestId === detailRequestRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
useEffect(() => { if (!getToken()) { setLoading(false); return; } me().then(setUser).catch(() => { setUser(null); setLoading(false); }); }, []);
|
useEffect(() => { if (!getToken()) { setLoading(false); return; } me().then(setUser).catch(() => { setUser(null); setLoading(false); }); }, []);
|
||||||
useEffect(() => { if (user) load(); }, [user, load]);
|
useEffect(() => { if (user) load(); }, [user, load]);
|
||||||
|
|
||||||
@@ -53,15 +68,40 @@ export default function TicketDetail() {
|
|||||||
|
|
||||||
const { ticket } = data; const messages = Array.isArray(data.messages) ? data.messages : [];
|
const { ticket } = data; const messages = Array.isArray(data.messages) ? data.messages : [];
|
||||||
const events = (Array.isArray(data.events) ? data.events : []).filter((event) => PUBLIC_EVENTS.has(event.event_type));
|
const events = (Array.isArray(data.events) ? data.events : []).filter((event) => PUBLIC_EVENTS.has(event.event_type));
|
||||||
const sourceUrl = safeSourceUrl(ticket.source_url); const info = statusInfo(ticket.status); const canReply = ticket.status !== 'closed';
|
const sourceUrl = safeSourceUrl(ticket.source_url); const canReply = ticket.status !== 'closed';
|
||||||
const runAction = async (action, confirmation) => { if (!window.confirm(confirmation)) return; setBusy(true); setError(''); try { await action(id); await load(); } catch (e) { setError(e.message || '操作失败'); } finally { setBusy(false); } };
|
const runAction = async (action, confirmation) => {
|
||||||
const sendReply = async (e) => { e.preventDefault(); const content = reply.trim(); if (!content) { setError('回复内容不能为空'); return; } setBusy(true); setError(''); try { await ticketsApi.addMessage(id, content); setReply(''); await load(); } catch (e) { setError(e.message || '回复失败'); } finally { setBusy(false); } };
|
if (!window.confirm(confirmation)) return;
|
||||||
|
const actionId = id;
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try {
|
||||||
|
await action(actionId);
|
||||||
|
if (String(activeIdRef.current) === String(actionId)) await load();
|
||||||
|
} catch (e) {
|
||||||
|
if (String(activeIdRef.current) === String(actionId)) setError(e.message || '操作失败');
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
const sendReply = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const content = reply.trim();
|
||||||
|
if (!content) { setError('回复内容不能为空'); return; }
|
||||||
|
const actionId = id;
|
||||||
|
setBusy(true); setError('');
|
||||||
|
try {
|
||||||
|
await ticketsApi.addMessage(actionId, content);
|
||||||
|
if (String(activeIdRef.current) === String(actionId)) {
|
||||||
|
setReply('');
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (String(activeIdRef.current) === String(actionId)) setError(e.message || '回复失败');
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
return <div className="ticket-detail-page">
|
return <div className="ticket-detail-page">
|
||||||
<Link to="/tickets.html" className="btn btn-text btn-sm ticket-detail-back">← 返回工单列表</Link>
|
<Link to="/tickets.html" className="btn btn-text btn-sm ticket-detail-back">← 返回工单列表</Link>
|
||||||
<div className="ticket-detail-layout">
|
<div className="ticket-detail-layout">
|
||||||
<div className="ticket-detail-main">
|
<div className="ticket-detail-main">
|
||||||
<article className="card ticket-detail-header"><div className="ticket-detail-kicker">{ticket.ticket_no || `工单 #${ticket.id}`}</div><h1 className="ticket-detail-title">{ticket.subject || ticket.title || '未命名工单'}</h1><div className="ticket-detail-tags"><StatusChip status={ticket.status} /><span className="chip chip-static">{categoryLabel(ticket.category)}</span><span className="chip chip-static">{ticket.priority === 'urgent' ? '紧急' : ticket.priority === 'high' ? '重要' : ticket.priority === 'low' ? '低' : '一般'}</span></div><TicketProgress status={ticket.status} /></article>
|
<article className="card ticket-detail-header"><div className="ticket-detail-kicker">{ticket.ticket_no || `工单 #${ticket.id}`}</div><h1 className="ticket-detail-title">{ticket.subject || ticket.title || '未命名工单'}</h1><div className="ticket-detail-tags"><StatusChip status={ticket.status} /><span className="chip chip-static">{categoryLabel(ticket.category)}</span><span className="chip chip-static">{priorityLabel(ticket.priority)}</span></div><TicketProgress status={ticket.status} /></article>
|
||||||
<article className="card ticket-detail-description"><h2>问题描述</h2><p>{ticket.description}</p>{sourceUrl && <p className="ticket-detail-source"><span className="text-muted">发现页面</span><a href={sourceUrl} target="_blank" rel="noopener noreferrer">{sourceUrl}</a></p>}</article>
|
<article className="card ticket-detail-description"><h2>问题描述</h2><p>{ticket.description}</p>{sourceUrl && <p className="ticket-detail-source"><span className="text-muted">发现页面</span><a href={sourceUrl} target="_blank" rel="noopener noreferrer">{sourceUrl}</a></p>}</article>
|
||||||
<section className="ticket-detail-conversation" aria-labelledby="ticket-messages-heading"><div className="ticket-detail-section-heading"><h2 id="ticket-messages-heading">沟通记录</h2><span className="text-muted">{messages.length} 条公开回复</span></div>{messages.length === 0 ? <div className="card ticket-detail-empty"><span className="material-icons" aria-hidden="true">forum</span><p>暂无回复,提交后管理员会在这里跟进。</p></div> : <ol className="ticket-detail-messages">{messages.map((message) => <li key={message.id} className="card ticket-detail-message"><div className="ticket-detail-message-meta"><strong>{message.author_username || message.author_name || '用户'}</strong><time className="text-muted" dateTime={message.created_at}>{formatTime(message.created_at)}</time></div><p>{message.content || message.body}</p></li>)}</ol>}</section>
|
<section className="ticket-detail-conversation" aria-labelledby="ticket-messages-heading"><div className="ticket-detail-section-heading"><h2 id="ticket-messages-heading">沟通记录</h2><span className="text-muted">{messages.length} 条公开回复</span></div>{messages.length === 0 ? <div className="card ticket-detail-empty"><span className="material-icons" aria-hidden="true">forum</span><p>暂无回复,提交后管理员会在这里跟进。</p></div> : <ol className="ticket-detail-messages">{messages.map((message) => <li key={message.id} className="card ticket-detail-message"><div className="ticket-detail-message-meta"><strong>{message.author_username || message.author_name || '用户'}</strong><time className="text-muted" dateTime={message.created_at}>{formatTime(message.created_at)}</time></div><p>{message.content || message.body}</p></li>)}</ol>}</section>
|
||||||
{events.length > 0 && <section className="ticket-detail-events" aria-labelledby="ticket-events-heading"><h2 id="ticket-events-heading">处理记录</h2><ol>{events.map((event) => <li key={event.id}><span>{eventLabel(event)}</span><time className="text-muted" dateTime={event.created_at}>{formatTime(event.created_at)}</time></li>)}</ol></section>}
|
{events.length > 0 && <section className="ticket-detail-events" aria-labelledby="ticket-events-heading"><h2 id="ticket-events-heading">处理记录</h2><ol>{events.map((event) => <li key={event.id}><span>{eventLabel(event)}</span><time className="text-muted" dateTime={event.created_at}>{formatTime(event.created_at)}</time></li>)}</ol></section>}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import * as ticketsApi from '../api/tickets.js';
|
import * as ticketsApi from '../api/tickets.js';
|
||||||
import { me } from '../api/auth.js';
|
import { me } from '../api/auth.js';
|
||||||
@@ -15,13 +15,19 @@ const CATEGORY = { forum_bug: '论坛 Bug', site_bug: '站内 Bug', feature: '
|
|||||||
|
|
||||||
export function statusInfo(status) { return STATUS[status] || { label: status || '未知状态', icon: 'help', tone: 'neutral' }; }
|
export function statusInfo(status) { return STATUS[status] || { label: status || '未知状态', icon: 'help', tone: 'neutral' }; }
|
||||||
export function categoryLabel(category) { return CATEGORY[category] || category || '其他问题'; }
|
export function categoryLabel(category) { return CATEGORY[category] || category || '其他问题'; }
|
||||||
|
export function priorityLabel(priority) {
|
||||||
|
return { low: '低', normal: '普通', high: '高', urgent: '紧急' }[priority] || '普通';
|
||||||
|
}
|
||||||
/** 工单来源只允许 http(s) 或本站相对路径,避免把用户可控值直接作为危险链接。 */
|
/** 工单来源只允许 http(s) 或本站相对路径,避免把用户可控值直接作为危险链接。 */
|
||||||
export function safeSourceUrl(value) {
|
export function safeSourceUrl(value) {
|
||||||
const raw = String(value || '').trim();
|
const valueText = String(value ?? '');
|
||||||
if (!raw || raw.length > 1000) return '';
|
if (!valueText || valueText.length > 1000) return '';
|
||||||
if (raw.startsWith('/') && !raw.startsWith('//')) return raw;
|
if (/[\u0000-\u001f\u007f-\u009f]/.test(valueText) || valueText.includes('\\')) return '';
|
||||||
|
const raw = valueText.trim();
|
||||||
|
if (!raw || raw.startsWith('//')) return '';
|
||||||
|
if (raw.startsWith('/')) return raw;
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(raw, window.location.origin);
|
const parsed = new URL(raw);
|
||||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : '';
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : '';
|
||||||
} catch { return ''; }
|
} catch { return ''; }
|
||||||
}
|
}
|
||||||
@@ -51,15 +57,28 @@ export default function Tickets() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [status, setStatus] = useState('');
|
const [status, setStatus] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const listRequestRef = useRef(0);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
const requestId = ++listRequestRef.current;
|
||||||
setLoading(true); setError('');
|
setLoading(true); setError('');
|
||||||
try {
|
try {
|
||||||
const data = await ticketsApi.listTickets({ page: 1, pageSize: 20, status });
|
const data = await ticketsApi.listTickets({ page, pageSize: 20, status });
|
||||||
setTickets(data.tickets || []);
|
if (requestId !== listRequestRef.current) return;
|
||||||
} catch (e) { setError(e.message || '工单加载失败'); }
|
const normalized = ticketsApi.normalizeTicketListResponse(data, 20);
|
||||||
finally { setLoading(false); }
|
setTickets(normalized.tickets);
|
||||||
}, [status]);
|
setTotal(normalized.total);
|
||||||
|
setTotalPages(normalized.totalPages);
|
||||||
|
} catch (e) {
|
||||||
|
if (requestId !== listRequestRef.current) return;
|
||||||
|
setError(e.message || '工单加载失败');
|
||||||
|
} finally {
|
||||||
|
if (requestId === listRequestRef.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [page, status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!getToken()) { setLoading(false); return; }
|
if (!getToken()) { setLoading(false); return; }
|
||||||
@@ -67,6 +86,8 @@ export default function Tickets() {
|
|||||||
}, []);
|
}, []);
|
||||||
useEffect(() => { if (user) load(); }, [user, load]);
|
useEffect(() => { if (user) load(); }, [user, load]);
|
||||||
|
|
||||||
|
const updateStatus = (value) => { setStatus(value); setPage(1); };
|
||||||
|
|
||||||
if (!getToken() || (!user && !loading)) return <LoginGuide />;
|
if (!getToken() || (!user && !loading)) return <LoginGuide />;
|
||||||
|
|
||||||
return <div style={{ maxWidth: 960, margin: '0 auto' }}>
|
return <div style={{ maxWidth: 960, margin: '0 auto' }}>
|
||||||
@@ -76,7 +97,7 @@ export default function Tickets() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="card" style={{ padding: 12, marginBottom: 16 }}>
|
<div className="card" style={{ padding: 12, marginBottom: 16 }}>
|
||||||
<label htmlFor="ticket-status-filter" style={{ marginRight: 10 }}>筛选状态</label>
|
<label htmlFor="ticket-status-filter" style={{ marginRight: 10 }}>筛选状态</label>
|
||||||
<select id="ticket-status-filter" value={status} onChange={(e) => setStatus(e.target.value)} style={{ minHeight: 40, padding: '0 10px', borderRadius: 8 }}>
|
<select id="ticket-status-filter" value={status} onChange={(e) => updateStatus(e.target.value)} style={{ minHeight: 40, padding: '0 10px', borderRadius: 8 }}>
|
||||||
<option value="">全部工单</option><option value="open">待处理</option><option value="processing">处理中</option><option value="waiting">等待用户</option><option value="resolved">已解决</option><option value="closed">已关闭</option>
|
<option value="">全部工单</option><option value="open">待处理</option><option value="processing">处理中</option><option value="waiting">等待用户</option><option value="resolved">已解决</option><option value="closed">已关闭</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,5 +113,10 @@ export default function Tickets() {
|
|||||||
</Link></li>)}
|
</Link></li>)}
|
||||||
</ul>
|
</ul>
|
||||||
</section>}
|
</section>}
|
||||||
|
{!loading && !error && totalPages > 1 && <nav className="ticket-pagination" aria-label="工单分页" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 12, marginTop: 20 }}>
|
||||||
|
<button type="button" className="btn btn-text btn-sm" disabled={page <= 1} onClick={() => setPage((current) => current - 1)}>上一页</button>
|
||||||
|
<span className="text-muted" aria-live="polite">第 {page} / {totalPages} 页,共 {total} 条</span>
|
||||||
|
<button type="button" className="btn btn-text btn-sm" disabled={page >= totalPages} onClick={() => setPage((current) => current + 1)}>下一页</button>
|
||||||
|
</nav>}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
+175
-55
@@ -1,9 +1,21 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const { rateLimit } = require('express-rate-limit');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const { authMiddleware, adminOnly } = require('../middleware/auth');
|
const { authMiddleware, adminOnly } = require('../middleware/auth');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
function userKey(req) {
|
||||||
|
return String(req.user && req.user.id ? req.user.id : 'anonymous');
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTicketUserLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 5, keyGenerator: userKey, standardHeaders: true, legacyHeaders: false, message: { error: '提交过于频繁,请稍后再试' } });
|
||||||
|
const createTicketIpLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 20, standardHeaders: true, legacyHeaders: false, message: { error: '当前网络提交过于频繁,请稍后再试' } });
|
||||||
|
const messageUserLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 30, keyGenerator: userKey, standardHeaders: true, legacyHeaders: false, message: { error: '回复过于频繁,请稍后再试' } });
|
||||||
|
const messageIpLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 120, standardHeaders: true, legacyHeaders: false, message: { error: '当前网络回复过于频繁,请稍后再试' } });
|
||||||
|
const detailUserLimiter = rateLimit({ windowMs: 60 * 1000, limit: 60, keyGenerator: userKey, standardHeaders: true, legacyHeaders: false, message: { error: '请求过于频繁,请稍后再试' } });
|
||||||
|
const detailIpLimiter = rateLimit({ windowMs: 60 * 1000, limit: 180, standardHeaders: true, legacyHeaders: false, message: { error: '当前网络请求过于频繁,请稍后再试' } });
|
||||||
|
|
||||||
const CATEGORIES = new Set(['forum_bug', 'site_bug', 'feature', 'account', 'other']);
|
const CATEGORIES = new Set(['forum_bug', 'site_bug', 'feature', 'account', 'other']);
|
||||||
const PRIORITIES = new Set(['low', 'normal', 'high', 'urgent']);
|
const PRIORITIES = new Set(['low', 'normal', 'high', 'urgent']);
|
||||||
const STATUSES = new Set(['open', 'processing', 'waiting', 'resolved', 'closed']);
|
const STATUSES = new Set(['open', 'processing', 'waiting', 'resolved', 'closed']);
|
||||||
@@ -29,7 +41,7 @@ function currentUserRole(userId) {
|
|||||||
function validateSourceUrl(value) {
|
function validateSourceUrl(value) {
|
||||||
if (value === undefined || value === null || value === '') return { value: '' };
|
if (value === undefined || value === null || value === '') return { value: '' };
|
||||||
if (typeof value !== 'string') return { error: '来源地址不合法' };
|
if (typeof value !== 'string') return { error: '来源地址不合法' };
|
||||||
if (value.length > 1000 || /[\u0000-\u001f\u007f]/.test(value)) return { error: '来源地址不合法' };
|
if (value.length > 1000 || /[\u0000-\u001f\u007f-\u009f]/.test(value) || value.includes('\\')) return { error: '来源地址不合法' };
|
||||||
const sourceUrl = value.trim();
|
const sourceUrl = value.trim();
|
||||||
if (!sourceUrl) return { value: '' };
|
if (!sourceUrl) return { value: '' };
|
||||||
if (sourceUrl.startsWith('/') && !sourceUrl.startsWith('//')) return { value: sourceUrl };
|
if (sourceUrl.startsWith('/') && !sourceUrl.startsWith('//')) return { value: sourceUrl };
|
||||||
@@ -52,6 +64,24 @@ function pageParams(query) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detailParams(query) {
|
||||||
|
const page = Number.parseInt(query.messagePage || query.page, 10);
|
||||||
|
const pageSize = Number.parseInt(query.messagePageSize || query.pageSize, 10);
|
||||||
|
return {
|
||||||
|
page: Number.isInteger(page) && page >= 1 ? page : 1,
|
||||||
|
pageSize: Number.isInteger(pageSize) && pageSize >= 1 && pageSize <= 50 ? pageSize : 20,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventParams(query) {
|
||||||
|
const page = Number.parseInt(query.eventPage || query.page, 10);
|
||||||
|
const pageSize = Number.parseInt(query.eventPageSize || query.pageSize, 10);
|
||||||
|
return {
|
||||||
|
page: Number.isInteger(page) && page >= 1 ? page : 1,
|
||||||
|
pageSize: Number.isInteger(pageSize) && pageSize >= 1 && pageSize <= 50 ? pageSize : 20,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function idParam(value) {
|
function idParam(value) {
|
||||||
const id = Number.parseInt(value, 10);
|
const id = Number.parseInt(value, 10);
|
||||||
return Number.isInteger(id) && id > 0 && String(id) === String(value) ? id : 0;
|
return Number.isInteger(id) && id > 0 && String(id) === String(value) ? id : 0;
|
||||||
@@ -74,7 +104,7 @@ function addEvent(insertEvent, ticketId, actorId, type, field = '', oldValue = '
|
|||||||
insertEvent.run(ticketId, actorId || null, type, field, String(oldValue ?? ''), String(newValue ?? ''), detail);
|
insertEvent.run(ticketId, actorId || null, type, field, String(oldValue ?? ''), String(newValue ?? ''), detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStatus(ticket, status, actorId, insertEvent) {
|
function updateStatus(ticket, status, actorId, insertEvent, database) {
|
||||||
if (!STATUSES.has(status)) return { error: '状态不合法' };
|
if (!STATUSES.has(status)) return { error: '状态不合法' };
|
||||||
if (ticket.status === status) return { changed: false };
|
if (ticket.status === status) return { changed: false };
|
||||||
if (!TRANSITIONS[ticket.status] || !TRANSITIONS[ticket.status].has(status)) {
|
if (!TRANSITIONS[ticket.status] || !TRANSITIONS[ticket.status].has(status)) {
|
||||||
@@ -82,33 +112,68 @@ function updateStatus(ticket, status, actorId, insertEvent) {
|
|||||||
}
|
}
|
||||||
const now = "datetime('now')";
|
const now = "datetime('now')";
|
||||||
const values = [status];
|
const values = [status];
|
||||||
let sql = `UPDATE tickets SET status = ?, updated_at = ${now}`;
|
let sql = `UPDATE tickets SET status = ?, updated_at = ${now}, revision = revision + 1`;
|
||||||
if (status === 'resolved') sql += `, resolved_at = ${now}`;
|
if (status === 'resolved') sql += `, resolved_at = ${now}`;
|
||||||
if (status === 'closed') sql += `, closed_at = ${now}`;
|
if (status === 'closed') sql += `, closed_at = ${now}`;
|
||||||
if (status !== 'resolved') sql += ', resolved_at = NULL';
|
if (status !== 'resolved') sql += ', resolved_at = NULL';
|
||||||
if (status !== 'closed') sql += ', closed_at = NULL';
|
if (status !== 'closed') sql += ', closed_at = NULL';
|
||||||
sql += ' WHERE id = ?';
|
sql += ' WHERE id = ? AND revision = ?';
|
||||||
values.push(ticket.id);
|
values.push(ticket.id, ticket.revision);
|
||||||
db.getDb().prepare(sql).run(...values);
|
const result = database.prepare(sql).run(...values);
|
||||||
addEvent(insertEvent, ticket.id, actorId, 'status_changed', 'status', ticket.status, status);
|
if (result.changes !== 1) {
|
||||||
|
const error = new Error('工单已被其他人更新,请刷新后重试');
|
||||||
|
error.code = 'TICKET_CONFLICT';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const eventType = status === 'closed' ? 'ticket_closed' : (ticket.status === 'closed' && status === 'processing' ? 'ticket_reopened' : 'status_changed');
|
||||||
|
addEvent(insertEvent, ticket.id, actorId, eventType, 'status', ticket.status, status);
|
||||||
return { changed: true };
|
return { changed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function ticketResponse(ticket, isAdmin) {
|
function ticketResponse(ticket, isAdmin, query = {}) {
|
||||||
|
const messagePaging = detailParams(query);
|
||||||
|
const eventPaging = eventParams(query);
|
||||||
|
const { page: messagePage, pageSize: messagePageSize } = messagePaging;
|
||||||
|
const visibility = isAdmin ? '' : 'AND tm.is_internal = 0';
|
||||||
|
const messageTotal = db.get(`SELECT COUNT(*) AS count FROM ticket_messages tm WHERE tm.ticket_id = ? ${visibility}`, [ticket.id]).count;
|
||||||
const messages = db.all(`SELECT tm.id, tm.ticket_id, tm.author_id, tm.content, tm.is_internal,
|
const messages = db.all(`SELECT tm.id, tm.ticket_id, tm.author_id, tm.content, tm.is_internal,
|
||||||
tm.created_at, u.username AS author_name, u.nickname AS author_nickname, u.role AS author_role
|
tm.created_at, u.username AS author_name, u.nickname AS author_nickname, u.role AS author_role
|
||||||
FROM ticket_messages tm LEFT JOIN users u ON u.id = tm.author_id
|
FROM ticket_messages tm LEFT JOIN users u ON u.id = tm.author_id
|
||||||
WHERE tm.ticket_id = ? ${isAdmin ? '' : 'AND tm.is_internal = 0'}
|
WHERE tm.ticket_id = ? ${visibility}
|
||||||
ORDER BY tm.created_at ASC, tm.id ASC`, [ticket.id]);
|
ORDER BY tm.created_at ASC, tm.id ASC LIMIT ? OFFSET ?`, [ticket.id, messagePageSize, (messagePage - 1) * messagePageSize]);
|
||||||
const response = { ticket, messages };
|
const response = { ticket, messages, messagePage, messagePageSize, messageTotal, messageTotalPages: Math.ceil(messageTotal / messagePageSize) };
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
|
const eventTotal = db.get('SELECT COUNT(*) AS count FROM ticket_events WHERE ticket_id = ?', [ticket.id]).count;
|
||||||
response.events = db.all(`SELECT e.*, u.username AS actor_name, u.nickname AS actor_nickname
|
response.events = db.all(`SELECT e.*, u.username AS actor_name, u.nickname AS actor_nickname
|
||||||
FROM ticket_events e LEFT JOIN users u ON u.id = e.actor_id
|
FROM ticket_events e LEFT JOIN users u ON u.id = e.actor_id
|
||||||
WHERE e.ticket_id = ? ORDER BY e.created_at ASC, e.id ASC`, [ticket.id]);
|
WHERE e.ticket_id = ? ORDER BY e.created_at ASC, e.id ASC LIMIT ? OFFSET ?`, [ticket.id, eventPaging.pageSize, (eventPaging.page - 1) * eventPaging.pageSize]);
|
||||||
|
response.eventPage = eventPaging.page;
|
||||||
|
response.eventPageSize = eventPaging.pageSize;
|
||||||
|
response.eventTotal = eventTotal;
|
||||||
|
response.eventTotalPages = Math.ceil(eventTotal / eventPaging.pageSize);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTicketRow(database, id) {
|
||||||
|
return database.prepare('SELECT * FROM tickets WHERE id = ?').get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticketEventStatement(database) {
|
||||||
|
return database.prepare(`INSERT INTO ticket_events
|
||||||
|
(ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function conflictError(message = '工单已被其他人更新,请刷新后重试') {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = 'TICKET_CONFLICT';
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function conflictResponse(error) {
|
||||||
|
return error && error.code === 'TICKET_CONFLICT' ? { status: 409, body: { error: error.message } } : null;
|
||||||
|
}
|
||||||
|
|
||||||
// 管理后台列表(作为管理后台的工单子 tab 使用)
|
// 管理后台列表(作为管理后台的工单子 tab 使用)
|
||||||
router.get('/admin', authMiddleware, adminOnly, (req, res) => {
|
router.get('/admin', authMiddleware, adminOnly, (req, res) => {
|
||||||
const { page, pageSize } = pageParams(req.query);
|
const { page, pageSize } = pageParams(req.query);
|
||||||
@@ -176,7 +241,7 @@ router.get('/', authMiddleware, (req, res) => {
|
|||||||
res.json({ tickets: list, list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) });
|
res.json({ tickets: list, list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', authMiddleware, (req, res) => {
|
router.post('/', authMiddleware, createTicketUserLimiter, createTicketIpLimiter, (req, res) => {
|
||||||
const body = req.body || {};
|
const body = req.body || {};
|
||||||
const subject = text(body.subject, 120);
|
const subject = text(body.subject, 120);
|
||||||
const description = text(body.description, 20000);
|
const description = text(body.description, 20000);
|
||||||
@@ -201,8 +266,8 @@ router.post('/', authMiddleware, (req, res) => {
|
|||||||
const result = db.transaction(() => {
|
const result = db.transaction(() => {
|
||||||
const database = db.getDb();
|
const database = db.getDb();
|
||||||
const insertTicket = database.prepare(`INSERT INTO tickets
|
const insertTicket = database.prepare(`INSERT INTO tickets
|
||||||
(ticket_no, requester_id, subject, description, category, priority, source, source_url, source_type, source_id, browser_info)
|
(ticket_no, requester_id, subject, description, category, priority, source, source_url, source_type, source_id, browser_info, last_reply_at)
|
||||||
VALUES ('PENDING-' || hex(randomblob(8)), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
VALUES ('PENDING-' || hex(randomblob(8)), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`);
|
||||||
const info = insertTicket.run(req.user.id, subject, description, category, priority, source, sourceUrl, sourceType, sourceId, browserInfo);
|
const info = insertTicket.run(req.user.id, subject, description, category, priority, source, sourceUrl, sourceType, sourceId, browserInfo);
|
||||||
const id = Number(info.lastInsertRowid);
|
const id = Number(info.lastInsertRowid);
|
||||||
const ticketNo = `RW-${String(id).padStart(6, '0')}`;
|
const ticketNo = `RW-${String(id).padStart(6, '0')}`;
|
||||||
@@ -219,26 +284,36 @@ router.post('/', authMiddleware, (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:id/events', authMiddleware, adminOnly, (req, res) => {
|
router.get('/:id/events', authMiddleware, adminOnly, detailUserLimiter, detailIpLimiter, (req, res) => {
|
||||||
const id = idParam(req.params.id);
|
const id = idParam(req.params.id);
|
||||||
if (!id) return res.status(404).json({ error: '工单不存在' });
|
if (!id) return res.status(404).json({ error: '工单不存在' });
|
||||||
if (!getTicket(id)) return res.status(404).json({ error: '工单不存在' });
|
if (!getTicket(id)) return res.status(404).json({ error: '工单不存在' });
|
||||||
res.json(db.all(`SELECT e.*, u.username AS actor_name, u.nickname AS actor_nickname
|
const { page, pageSize } = eventParams(req.query);
|
||||||
|
const eventTotal = db.get('SELECT COUNT(*) AS count FROM ticket_events WHERE ticket_id = ?', [id]).count;
|
||||||
|
const events = db.all(`SELECT e.*, u.username AS actor_name, u.nickname AS actor_nickname
|
||||||
FROM ticket_events e LEFT JOIN users u ON u.id = e.actor_id
|
FROM ticket_events e LEFT JOIN users u ON u.id = e.actor_id
|
||||||
WHERE e.ticket_id = ? ORDER BY e.created_at ASC, e.id ASC`, [id]));
|
WHERE e.ticket_id = ? ORDER BY e.created_at ASC, e.id ASC LIMIT ? OFFSET ?`, [id, pageSize, (page - 1) * pageSize]);
|
||||||
|
// 保留旧接口的数组响应契约,分页信息通过响应头提供。
|
||||||
|
res.set({
|
||||||
|
'X-Page': String(page),
|
||||||
|
'X-Page-Size': String(pageSize),
|
||||||
|
'X-Total-Count': String(eventTotal),
|
||||||
|
'X-Total-Pages': String(Math.ceil(eventTotal / pageSize)),
|
||||||
|
});
|
||||||
|
res.json(events);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:id', authMiddleware, (req, res) => {
|
router.get('/:id', authMiddleware, detailUserLimiter, detailIpLimiter, (req, res) => {
|
||||||
const id = idParam(req.params.id);
|
const id = idParam(req.params.id);
|
||||||
const ticket = id ? getTicket(id) : null;
|
const ticket = id ? getTicket(id) : null;
|
||||||
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
||||||
const role = currentUserRole(req.user.id);
|
const role = currentUserRole(req.user.id);
|
||||||
if (!role) return res.status(401).json({ error: '登录已失效' });
|
if (!role) return res.status(401).json({ error: '登录已失效' });
|
||||||
if (!canAccess(ticket, req.user, role)) return res.status(403).json({ error: '无权限访问该工单' });
|
if (!canAccess(ticket, req.user, role)) return res.status(403).json({ error: '无权限访问该工单' });
|
||||||
res.json(ticketResponse(ticket, role === 'admin'));
|
res.json(ticketResponse(ticket, role === 'admin', req.query));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/messages', authMiddleware, (req, res) => {
|
router.post('/:id/messages', authMiddleware, messageUserLimiter, messageIpLimiter, (req, res) => {
|
||||||
const id = idParam(req.params.id);
|
const id = idParam(req.params.id);
|
||||||
const ticket = id ? getTicket(id) : null;
|
const ticket = id ? getTicket(id) : null;
|
||||||
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
||||||
@@ -251,25 +326,35 @@ router.post('/:id/messages', authMiddleware, (req, res) => {
|
|||||||
try {
|
try {
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
const database = db.getDb();
|
const database = db.getDb();
|
||||||
|
const freshTicket = database.prepare('SELECT * FROM tickets WHERE id = ?').get(id);
|
||||||
|
if (!freshTicket || freshTicket.revision !== ticket.revision) throw conflictError();
|
||||||
|
if (freshTicket.status === 'closed') {
|
||||||
|
const error = new Error('工单已关闭,不能回复');
|
||||||
|
error.code = 'TICKET_CLOSED';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
database.prepare('INSERT INTO ticket_messages (ticket_id, author_id, content, is_internal) VALUES (?, ?, ?, 0)').run(id, req.user.id, content);
|
database.prepare('INSERT INTO ticket_messages (ticket_id, author_id, content, is_internal) VALUES (?, ?, ?, 0)').run(id, req.user.id, content);
|
||||||
let nextStatus = ticket.status;
|
let nextStatus = freshTicket.status;
|
||||||
if (role !== 'admin' && ticket.status === 'waiting') nextStatus = 'processing';
|
if (role !== 'admin' && freshTicket.status === 'waiting') nextStatus = 'processing';
|
||||||
database.prepare(`UPDATE tickets SET updated_at = datetime('now'), last_reply_at = datetime('now'),
|
const updateResult = database.prepare(`UPDATE tickets SET updated_at = datetime('now'), last_reply_at = datetime('now'),
|
||||||
first_response_at = CASE WHEN first_response_at IS NULL AND ? = 'admin' THEN datetime('now') ELSE first_response_at END,
|
first_response_at = CASE WHEN first_response_at IS NULL AND ? = 'admin' THEN datetime('now') ELSE first_response_at END,
|
||||||
status = ? WHERE id = ?`).run(role, nextStatus, id);
|
status = ?, revision = revision + 1 WHERE id = ? AND revision = ?`).run(role, nextStatus, id, freshTicket.revision);
|
||||||
|
if (updateResult.changes !== 1) throw conflictError();
|
||||||
const event = database.prepare(`INSERT INTO ticket_events
|
const event = database.prepare(`INSERT INTO ticket_events
|
||||||
(ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
(ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||||||
addEvent(event, id, req.user.id, 'message_added', '', '', '公开回复', '');
|
addEvent(event, id, req.user.id, 'message_added', '', '', '公开回复', '');
|
||||||
if (nextStatus !== ticket.status) addEvent(event, id, req.user.id, 'status_changed', 'status', ticket.status, nextStatus, '用户回复后自动进入处理中');
|
if (nextStatus !== freshTicket.status) addEvent(event, id, req.user.id, 'status_changed', 'status', freshTicket.status, nextStatus, '用户回复后自动进入处理中');
|
||||||
});
|
});
|
||||||
res.status(201).json(ticketResponse(getTicket(id), role === 'admin'));
|
res.status(201).json(ticketResponse(getTicket(id), role === 'admin', req.query));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Ticket message error:', e.message);
|
console.error('Ticket message error:', e.message);
|
||||||
res.status(500).json({ error: '回复工单失败' });
|
const response = conflictResponse(e)
|
||||||
|
|| (e.code === 'TICKET_CLOSED' ? { status: 409, body: { error: e.message } } : null);
|
||||||
|
res.status(response ? response.status : 500).json(response ? response.body : { error: '回复工单失败' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/internal-messages', authMiddleware, adminOnly, (req, res) => {
|
router.post('/:id/internal-messages', authMiddleware, adminOnly, messageUserLimiter, messageIpLimiter, (req, res) => {
|
||||||
const id = idParam(req.params.id);
|
const id = idParam(req.params.id);
|
||||||
if (!id || !getTicket(id)) return res.status(404).json({ error: '工单不存在' });
|
if (!id || !getTicket(id)) return res.status(404).json({ error: '工单不存在' });
|
||||||
const content = text(req.body && req.body.content, 20000);
|
const content = text(req.body && req.body.content, 20000);
|
||||||
@@ -277,16 +362,19 @@ router.post('/:id/internal-messages', authMiddleware, adminOnly, (req, res) => {
|
|||||||
try {
|
try {
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
const database = db.getDb();
|
const database = db.getDb();
|
||||||
|
const freshTicket = getTicketRow(database, id);
|
||||||
|
if (!freshTicket) throw new Error('工单不存在');
|
||||||
database.prepare('INSERT INTO ticket_messages (ticket_id, author_id, content, is_internal) VALUES (?, ?, ?, 1)').run(id, req.user.id, content);
|
database.prepare('INSERT INTO ticket_messages (ticket_id, author_id, content, is_internal) VALUES (?, ?, ?, 1)').run(id, req.user.id, content);
|
||||||
database.prepare("UPDATE tickets SET updated_at = datetime('now') WHERE id = ?").run(id);
|
const updateResult = database.prepare("UPDATE tickets SET updated_at = datetime('now'), revision = revision + 1 WHERE id = ? AND revision = ?").run(id, freshTicket.revision);
|
||||||
const event = database.prepare(`INSERT INTO ticket_events
|
if (updateResult.changes !== 1) throw conflictError();
|
||||||
(ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
const event = ticketEventStatement(database);
|
||||||
addEvent(event, id, req.user.id, 'internal_note_added', '', '', '', '管理员添加内部备注');
|
addEvent(event, id, req.user.id, 'internal_note_added', '', '', '', '管理员添加内部备注');
|
||||||
});
|
});
|
||||||
res.status(201).json(ticketResponse(getTicket(id), true));
|
res.status(201).json(ticketResponse(getTicket(id), true, req.query));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Ticket internal message error:', e.message);
|
console.error('Ticket internal message error:', e.message);
|
||||||
res.status(500).json({ error: '添加内部备注失败' });
|
const conflict = conflictResponse(e);
|
||||||
|
res.status(conflict ? conflict.status : 500).json(conflict ? conflict.body : { error: '添加内部备注失败' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -298,13 +386,17 @@ router.put('/:id/status', authMiddleware, adminOnly, (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const result = db.transaction(() => {
|
const result = db.transaction(() => {
|
||||||
const database = db.getDb();
|
const database = db.getDb();
|
||||||
const event = database.prepare(`INSERT INTO ticket_events
|
const freshTicket = database.prepare('SELECT * FROM tickets WHERE id = ?').get(id);
|
||||||
(ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
if (!freshTicket || freshTicket.revision !== ticket.revision) throw conflictError();
|
||||||
return updateStatus(ticket, status, req.user.id, event);
|
return updateStatus(freshTicket, status, req.user.id, ticketEventStatement(database), database);
|
||||||
});
|
});
|
||||||
if (result.error) return res.status(result.error === '不允许的状态流转' ? 409 : 400).json({ error: result.error });
|
if (result.error) return res.status(result.error === '不允许的状态流转' ? 409 : 400).json({ error: result.error });
|
||||||
res.json({ message: '状态已更新', ticket: getTicket(id) });
|
res.json({ message: '状态已更新', ticket: getTicket(id) });
|
||||||
} catch (e) { console.error('Ticket status error:', e.message); res.status(500).json({ error: '更新状态失败' }); }
|
} catch (e) {
|
||||||
|
console.error('Ticket status error:', e.message);
|
||||||
|
const conflict = conflictResponse(e);
|
||||||
|
res.status(conflict ? conflict.status : 500).json(conflict ? conflict.body : { error: '更新状态失败' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id/priority', authMiddleware, adminOnly, (req, res) => {
|
router.put('/:id/priority', authMiddleware, adminOnly, (req, res) => {
|
||||||
@@ -316,13 +408,19 @@ router.put('/:id/priority', authMiddleware, adminOnly, (req, res) => {
|
|||||||
try {
|
try {
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
const database = db.getDb();
|
const database = db.getDb();
|
||||||
database.prepare("UPDATE tickets SET priority = ?, updated_at = datetime('now') WHERE id = ?").run(priority, id);
|
const freshTicket = database.prepare('SELECT * FROM tickets WHERE id = ?').get(id);
|
||||||
const event = database.prepare(`INSERT INTO ticket_events
|
if (!freshTicket || freshTicket.revision !== ticket.revision) throw conflictError();
|
||||||
(ticket_id, actor_id, event_type, field_name, old_value, new_value) VALUES (?, ?, ?, ?, ?, ?)`);
|
if (freshTicket.priority === priority) return;
|
||||||
addEvent(event, id, req.user.id, 'priority_changed', 'priority', ticket.priority, priority);
|
const updateResult = database.prepare("UPDATE tickets SET priority = ?, updated_at = datetime('now'), revision = revision + 1 WHERE id = ? AND revision = ?").run(priority, id, freshTicket.revision);
|
||||||
|
if (updateResult.changes !== 1) throw conflictError();
|
||||||
|
addEvent(ticketEventStatement(database), id, req.user.id, 'priority_changed', 'priority', freshTicket.priority, priority);
|
||||||
});
|
});
|
||||||
res.json({ message: '优先级已更新', ticket: getTicket(id) });
|
res.json({ message: '优先级已更新', ticket: getTicket(id) });
|
||||||
} catch (e) { console.error('Ticket priority error:', e.message); res.status(500).json({ error: '更新优先级失败' }); }
|
} catch (e) {
|
||||||
|
console.error('Ticket priority error:', e.message);
|
||||||
|
const conflict = conflictResponse(e);
|
||||||
|
res.status(conflict ? conflict.status : 500).json(conflict ? conflict.body : { error: '更新优先级失败' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id/assignee', authMiddleware, adminOnly, (req, res) => {
|
router.put('/:id/assignee', authMiddleware, adminOnly, (req, res) => {
|
||||||
@@ -335,13 +433,24 @@ router.put('/:id/assignee', authMiddleware, adminOnly, (req, res) => {
|
|||||||
try {
|
try {
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
const database = db.getDb();
|
const database = db.getDb();
|
||||||
database.prepare("UPDATE tickets SET assignee_id = ?, updated_at = datetime('now') WHERE id = ?").run(assigneeId, id);
|
const freshTicket = database.prepare('SELECT * FROM tickets WHERE id = ?').get(id);
|
||||||
const event = database.prepare(`INSERT INTO ticket_events
|
if (!freshTicket || freshTicket.revision !== ticket.revision) throw conflictError();
|
||||||
(ticket_id, actor_id, event_type, field_name, old_value, new_value) VALUES (?, ?, ?, ?, ?, ?)`);
|
if (freshTicket.assignee_id === assigneeId) return;
|
||||||
addEvent(event, id, req.user.id, 'assignee_changed', 'assignee_id', ticket.assignee_id, assigneeId);
|
if (assigneeId !== null && !database.prepare("SELECT id FROM users WHERE id = ? AND role = 'admin'").get(assigneeId)) {
|
||||||
|
throw new Error('负责人必须是管理员');
|
||||||
|
}
|
||||||
|
const updateResult = database.prepare("UPDATE tickets SET assignee_id = ?, updated_at = datetime('now'), revision = revision + 1 WHERE id = ? AND revision = ?").run(assigneeId, id, freshTicket.revision);
|
||||||
|
if (updateResult.changes !== 1) throw conflictError();
|
||||||
|
addEvent(ticketEventStatement(database), id, req.user.id, 'assignee_changed', 'assignee_id', freshTicket.assignee_id, assigneeId);
|
||||||
});
|
});
|
||||||
res.json({ message: '负责人已更新', ticket: getTicket(id) });
|
res.json({ message: '负责人已更新', ticket: getTicket(id) });
|
||||||
} catch (e) { console.error('Ticket assignee error:', e.message); res.status(500).json({ error: '更新负责人失败' }); }
|
} catch (e) {
|
||||||
|
console.error('Ticket assignee error:', e.message);
|
||||||
|
const conflict = conflictResponse(e);
|
||||||
|
const validation = e.message === '负责人必须是管理员' ? { status: 400, body: { error: e.message } } : null;
|
||||||
|
const response = conflict || validation;
|
||||||
|
res.status(response ? response.status : 500).json(response ? response.body : { error: '更新负责人失败' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/close', authMiddleware, (req, res) => changeUserStatus(req, res, 'closed'));
|
router.post('/:id/close', authMiddleware, (req, res) => changeUserStatus(req, res, 'closed'));
|
||||||
@@ -351,20 +460,31 @@ function changeUserStatus(req, res, status) {
|
|||||||
const id = idParam(req.params.id);
|
const id = idParam(req.params.id);
|
||||||
const ticket = id ? getTicket(id) : null;
|
const ticket = id ? getTicket(id) : null;
|
||||||
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
||||||
|
if (!currentUserRole(req.user.id)) return res.status(401).json({ error: '登录已失效' });
|
||||||
if (ticket.requester_id !== req.user.id) return res.status(403).json({ error: '无权限操作该工单' });
|
if (ticket.requester_id !== req.user.id) return res.status(403).json({ error: '无权限操作该工单' });
|
||||||
if ((status === 'closed' && ticket.status === 'closed') || (status === 'processing' && !['resolved', 'closed'].includes(ticket.status))) {
|
|
||||||
return res.status(409).json({ error: '当前状态不支持此操作' });
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
const database = db.getDb();
|
const database = db.getDb();
|
||||||
const event = database.prepare(`INSERT INTO ticket_events
|
if (!database.prepare('SELECT id FROM users WHERE id = ?').get(req.user.id)) throw new Error('登录已失效');
|
||||||
(ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
const freshTicket = getTicketRow(database, id);
|
||||||
const result = updateStatus(ticket, status, req.user.id, event);
|
if (!freshTicket || freshTicket.revision !== ticket.revision) throw conflictError();
|
||||||
|
if ((status === 'closed' && freshTicket.status === 'closed') || (status === 'processing' && !['resolved', 'closed'].includes(freshTicket.status))) {
|
||||||
|
throw new Error('当前状态不支持此操作');
|
||||||
|
}
|
||||||
|
if (freshTicket.requester_id !== req.user.id) throw new Error('无权限操作该工单');
|
||||||
|
const result = updateStatus(freshTicket, status, req.user.id, ticketEventStatement(database), database);
|
||||||
if (result.error) throw new Error(result.error);
|
if (result.error) throw new Error(result.error);
|
||||||
});
|
});
|
||||||
res.json({ message: status === 'closed' ? '工单已关闭' : '工单已重新打开', ticket: getTicket(id) });
|
res.json({ message: status === 'closed' ? '工单已关闭' : '工单已重新打开', ticket: getTicket(id) });
|
||||||
} catch (e) { res.status(e.message === '不允许的状态流转' ? 409 : 500).json({ error: e.message === '不允许的状态流转' ? e.message : '更新工单失败' }); }
|
} catch (e) {
|
||||||
|
const conflict = conflictResponse(e);
|
||||||
|
const response = conflict
|
||||||
|
|| (['当前状态不支持此操作', '不允许的状态流转'].includes(e.message) ? { status: 409, body: { error: e.message } } : null)
|
||||||
|
|| (['登录已失效', '无权限操作该工单', '工单不存在'].includes(e.message)
|
||||||
|
? { status: e.message === '登录已失效' ? 401 : e.message === '无权限操作该工单' ? 403 : 404, body: { error: e.message } }
|
||||||
|
: null);
|
||||||
|
res.status(response ? response.status : 500).json(response ? response.body : { error: '更新工单失败' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
Reference in New Issue
Block a user