diff --git a/db.js b/db.js
index cfc098e..b63369a 100644
--- a/db.js
+++ b/db.js
@@ -255,6 +255,63 @@ function migrateSchema() {
try { db.exec("ALTER TABLE admin_links ADD COLUMN trusted INTEGER DEFAULT 1"); } catch {}
try { db.exec("ALTER TABLE admin_links ADD COLUMN slug TEXT DEFAULT ''"); } catch {}
} },
+ // v16: 站内工单——用户反馈、公开回复、内部备注及业务事件
+ { version: 16, 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 (datetime('now')),
+ first_response_at DATETIME,
+ resolved_at DATETIME,
+ closed_at DATETIME,
+ 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
+ )`);
+ 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_assignee ON tickets(assignee_id, status, updated_at DESC)');
+ db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category, updated_at DESC)');
+ db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id, created_at ASC)');
+ db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket ON ticket_events(ticket_id, created_at ASC)');
+ } },
];
for (const m of migrations) {
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index b2cf7f5..f9afe4d 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -20,6 +20,9 @@ import Profile from './pages/Profile.jsx';
import Write from './pages/Write.jsx';
import Embed from './pages/Embed.jsx';
import Setup from './pages/Setup.jsx';
+import Tickets from './pages/Tickets.jsx';
+import TicketCreate from './pages/TicketCreate.jsx';
+import TicketDetail from './pages/TicketDetail.jsx';
export default function App() {
return (
@@ -35,6 +38,9 @@ export default function App() {
} />
} />
} />
+ } />
+ } />
+ } />
} />
} />
} />
diff --git a/frontend/src/admin/main.jsx b/frontend/src/admin/main.jsx
index 64235eb..d2ddf61 100644
--- a/frontend/src/admin/main.jsx
+++ b/frontend/src/admin/main.jsx
@@ -27,6 +27,7 @@ import Announcements from './pages/Announcements.jsx';
import Links from './pages/Links.jsx';
import Uploads from './pages/Uploads.jsx';
import ImportDb from './pages/ImportDb.jsx';
+import TicketManage from './pages/TicketManage.jsx';
// 工作台独立分包:仅访问 /admin/workbench 时才加载(vite 自动 code-split)
const Workbench = lazy(() => import('../tools/workbench/Workbench.jsx'));
@@ -113,6 +114,7 @@ function AdminApp() {
} />
} />
} />
+ } />
} />
diff --git a/frontend/src/admin/navConfig.js b/frontend/src/admin/navConfig.js
index 73874b3..9d21dd7 100644
--- a/frontend/src/admin/navConfig.js
+++ b/frontend/src/admin/navConfig.js
@@ -14,6 +14,7 @@ import LinkIcon from '@mui/icons-material/Link';
import RssFeedIcon from '@mui/icons-material/RssFeed';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import UploadFileIcon from '@mui/icons-material/UploadFile';
+import SupportAgentIcon from '@mui/icons-material/SupportAgent';
/**
* 后台侧边栏导航配置(单一数据源):
@@ -41,6 +42,13 @@ export const NAV_GROUPS = [
{ path: '/posts', label: '帖子管理', icon: ListAltIcon },
],
},
+ {
+ id: 'support',
+ label: '反馈处理',
+ items: [
+ { path: '/tickets', label: '工单管理', icon: SupportAgentIcon },
+ ],
+ },
{
id: 'users',
label: '用户',
@@ -116,6 +124,7 @@ export const NAV_SECTIONS = [
/** 别名映射:把常见叫法指到已有菜单项或设置区块(path + 可选 anchor) */
export const NAV_ALIASES = [
+ { keywords: ['工单', '反馈', 'bug', '问题', 'ticket'], path: '/tickets' },
{ keywords: ['邮件', 'email', 'smtp'], path: '/email' },
{ keywords: ['验证码', 'captcha', 'reCAPTCHA', 'turnstile'], path: '/captcha' },
{ keywords: ['rss', '订阅', 'feed'], path: '/rss' },
diff --git a/frontend/src/admin/pages/TicketManage.jsx b/frontend/src/admin/pages/TicketManage.jsx
new file mode 100644
index 0000000..9d7968f
--- /dev/null
+++ b/frontend/src/admin/pages/TicketManage.jsx
@@ -0,0 +1,201 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import Box from '@mui/material/Box';
+import Paper from '@mui/material/Paper';
+import Stack from '@mui/material/Stack';
+import Grid from '@mui/material/Grid';
+import Typography from '@mui/material/Typography';
+import TextField from '@mui/material/TextField';
+import MenuItem from '@mui/material/MenuItem';
+import Button from '@mui/material/Button';
+import Chip from '@mui/material/Chip';
+import Divider from '@mui/material/Divider';
+import IconButton from '@mui/material/IconButton';
+import CircularProgress from '@mui/material/CircularProgress';
+import Alert from '@mui/material/Alert';
+import Avatar from '@mui/material/Avatar';
+import RefreshIcon from '@mui/icons-material/Refresh';
+import SendIcon from '@mui/icons-material/Send';
+import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore';
+import NavigateNextIcon from '@mui/icons-material/NavigateNext';
+import { request } from '../../api/client.js';
+import { showSnack } from '../snack.jsx';
+
+const PAGE_SIZE = 15;
+const STATUS = {
+ open: { label: '待处理', color: 'warning' },
+ processing: { label: '处理中', color: 'info' },
+ waiting: { label: '等待用户', color: 'secondary' },
+ resolved: { label: '已解决', color: 'success' },
+ closed: { label: '已关闭', color: 'default' },
+};
+const PRIORITY = {
+ low: { label: '低', color: 'default' },
+ normal: { label: '普通', color: 'info' },
+ high: { label: '高', color: 'warning' },
+ urgent: { label: '紧急', color: 'error' },
+};
+const CATEGORY = {
+ forum_bug: '论坛 Bug', site_bug: '站内 Bug', feature: '功能建议',
+ account: '账号问题', other: '其他',
+};
+
+function fmtTime(value) {
+ return value ? String(value).replace('T', ' ').slice(0, 16) : '—';
+}
+function clip(value, size = 80) {
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
+ return text.length > size ? `${text.slice(0, size)}…` : text;
+}
+function StatusChip({ value }) {
+ const item = STATUS[value] || { label: value || '未知', color: 'default' };
+ return ;
+}
+function PriorityChip({ value }) {
+ const item = PRIORITY[value] || { label: value || '普通', color: 'default' };
+ return ;
+}
+
+function StatCard({ label, value, tone }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+export default function TicketManage() {
+ const [filters, setFilters] = useState({ q: '', status: '', priority: '', category: '' });
+ const [page, setPage] = useState(1);
+ const [list, setList] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [stats, setStats] = useState({});
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [selectedId, setSelectedId] = useState(null);
+ const [detail, setDetail] = useState(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [reply, setReply] = useState('');
+ const [internal, setInternal] = useState('');
+ const [assignees, setAssignees] = useState([]);
+
+ const loadStats = useCallback(() => {
+ request('/tickets/admin/stats').then((data) => setStats(data || {})).catch(() => {});
+ }, []);
+
+ const loadList = useCallback(() => {
+ setLoading(true); setError('');
+ const params = new URLSearchParams({ page: String(page), pageSize: String(PAGE_SIZE) });
+ Object.entries(filters).forEach(([key, value]) => { if (value) params.set(key, value); });
+ request(`/tickets/admin?${params.toString()}`)
+ .then((data) => {
+ const rows = data.list || data.tickets || [];
+ setList(rows); setTotal(Number(data.total || 0));
+ if (selectedId && !rows.some((row) => String(row.id) === String(selectedId))) setSelectedId(null);
+ })
+ .catch((e) => setError(e.message || '工单加载失败'))
+ .finally(() => setLoading(false));
+ }, [filters, page, selectedId]);
+
+ const loadDetail = useCallback((id) => {
+ if (!id) return;
+ setSelectedId(id); setDetailLoading(true);
+ request(`/tickets/${encodeURIComponent(id)}`)
+ .then((data) => setDetail(data.ticket ? data : { ticket: data, messages: [], events: [] }))
+ .catch((e) => showSnack(e.message || '详情加载失败', 'error'))
+ .finally(() => setDetailLoading(false));
+ }, []);
+
+ useEffect(() => { loadList(); }, [loadList]);
+ useEffect(() => { loadStats(); }, [loadStats]);
+ useEffect(() => {
+ request('/tickets/admin/assignees').then((data) => setAssignees(Array.isArray(data) ? data : (data.users || data.list || []))).catch(() => {});
+ }, []);
+
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
+ const selectedTicket = detail && detail.ticket;
+ const updateFilter = (key, value) => { setPage(1); setFilters((old) => ({ ...old, [key]: value })); };
+ const refresh = () => { loadList(); loadStats(); if (selectedId) loadDetail(selectedId); };
+
+ const updateTicket = async (path, body, message) => {
+ setSaving(true);
+ try { await request(path, { method: 'PUT', body }); showSnack(message); loadDetail(selectedId); loadList(); loadStats(); }
+ catch (e) { showSnack(e.message || '保存失败', 'error'); }
+ finally { setSaving(false); }
+ };
+ const sendMessage = async (internalMessage) => {
+ const content = (internalMessage ? internal : reply).trim();
+ if (!content) return;
+ setSaving(true);
+ try {
+ await request(`/tickets/${selectedId}/${internalMessage ? 'internal-messages' : 'messages'}`, { method: 'POST', body: { content } });
+ if (internalMessage) setInternal(''); else setReply('');
+ showSnack(internalMessage ? '内部备注已添加' : '公开回复已发送');
+ loadDetail(selectedId); loadList(); loadStats();
+ } catch (e) { showSnack(e.message || '发送失败', 'error'); }
+ finally { setSaving(false); }
+ };
+
+ const messages = detail?.messages || [];
+ const events = detail?.events || [];
+ const statItems = useMemo(() => [
+ ['全部', stats.total || total, null], ['待处理', stats.open || 0, 'warning'],
+ ['处理中', stats.processing || 0, 'info'], ['等待用户', stats.waiting || 0, 'secondary'],
+ ['已解决', stats.resolved || 0, 'success'],
+ ], [stats, total]);
+
+ return (
+
+
+ 工单管理集中处理论坛和站内问题反馈
+
+
+
+
+ {statItems.map(([label, value, tone]) => )}
+
+
+ { e.preventDefault(); setPage(1); loadList(); }}>
+
+ updateFilter('q', e.target.value)} />
+ updateFilter('status', e.target.value)}>{Object.entries(STATUS).map(([key, item]) => )}
+ updateFilter('priority', e.target.value)}>{Object.entries(PRIORITY).map(([key, item]) => )}
+ updateFilter('category', e.target.value)}>{Object.entries(CATEGORY).map(([key, label]) => )}
+
+
+
+
+ {error && 重试} sx={{ mb: 2 }}>{error}}
+
+
+
+ 最近工单共 {total} 条
+
+ {loading ? : list.length === 0 ? 暂无匹配工单 : (
+
+ {list.map((ticket) => )}
+
+ )}
+ setPage(page - 1)}>第 {page} / {totalPages} 页= totalPages || loading} onClick={() => setPage(page + 1)}>
+
+
+
+
+
+ {!selectedId ? 选择一个工单开始处理工单详情、回复和状态操作会显示在这里 : detailLoading ? : selectedTicket ? (
+
+ {selectedTicket.subject || selectedTicket.title}{selectedTicket.ticket_no || `#${selectedTicket.id}`} · {selectedTicket.requester_name || selectedTicket.requester_username || '未知用户'} · 创建于 {fmtTime(selectedTicket.created_at)}
+ updateTicket(`/tickets/${selectedId}/status`, { status: e.target.value }, '状态已更新')}>{Object.entries(STATUS).map(([key, item]) => )} updateTicket(`/tickets/${selectedId}/priority`, { priority: e.target.value }, '优先级已更新')}>{Object.entries(PRIORITY).map(([key, item]) => )} updateTicket(`/tickets/${selectedId}/assignee`, { assignee_id: e.target.value || null }, '负责人已更新')}>{assignees.filter((user) => user.role === 'admin' || user.role === undefined).map((user) => )}
+ 问题描述{selectedTicket.description || '暂无描述'}{selectedTicket.source_url && 来源:{selectedTicket.source_url}}
+ 处理记录{messages.map((message) => {String(message.author_name || message.author_username || '管')[0]}{message.author_name || message.author_username || '管理员'}{message.is_internal ? : }{fmtTime(message.created_at)}{message.content})}{events.map((event) => {fmtTime(event.created_at)} · {event.detail || `${event.field_name || '工单'}已更新`})}
+
+ setReply(e.target.value)} />} disabled={saving || !reply.trim()} onClick={() => sendMessage(false)}>发送公开回复 setInternal(e.target.value)} />
+
+ ) : 工单详情不存在或加载失败。}
+
+
+
+
+ );
+}
diff --git a/frontend/src/api/tickets.js b/frontend/src/api/tickets.js
new file mode 100644
index 0000000..ce04261
--- /dev/null
+++ b/frontend/src/api/tickets.js
@@ -0,0 +1,33 @@
+import { request } from './client.js';
+
+export function listTickets(params = {}) {
+ const query = new URLSearchParams();
+ if (params.page) query.set('page', String(params.page));
+ if (params.pageSize) query.set('pageSize', String(params.pageSize));
+ if (params.status) query.set('status', params.status);
+ const qs = query.toString();
+ return request('/tickets' + (qs ? '?' + qs : ''));
+}
+
+export function createTicket(data) {
+ return request('/tickets', { method: 'POST', body: data });
+}
+
+export function getTicket(id) {
+ return request('/tickets/' + encodeURIComponent(id));
+}
+
+export function addMessage(id, content) {
+ return request('/tickets/' + encodeURIComponent(id) + '/messages', {
+ method: 'POST',
+ body: { content },
+ });
+}
+
+export function closeTicket(id) {
+ return request('/tickets/' + encodeURIComponent(id) + '/close', { method: 'POST' });
+}
+
+export function reopenTicket(id) {
+ return request('/tickets/' + encodeURIComponent(id) + '/reopen', { method: 'POST' });
+}
diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx
index 3906002..dda93c9 100644
--- a/frontend/src/components/Layout.jsx
+++ b/frontend/src/components/Layout.jsx
@@ -181,6 +181,7 @@ export default function Layout() {
{ to: '/', label: '首页', end: true, show: true },
{ to: '/blog.html', label: '博客', show: true },
{ to: '/forum.html', label: '论坛', show: !!user },
+ { to: '/tickets.html', label: '工单', show: true },
{ to: '/admin', label: '管理后台', show: isAdmin, fullPage: true },
{ to: '/passwords.html', label: '密码箱', show: isAdmin },
];
diff --git a/frontend/src/pages/TicketCreate.jsx b/frontend/src/pages/TicketCreate.jsx
new file mode 100644
index 0000000..f437566
--- /dev/null
+++ b/frontend/src/pages/TicketCreate.jsx
@@ -0,0 +1,54 @@
+import React, { useState } from 'react';
+import { Link, useLocation, useNavigate } from 'react-router-dom';
+import * as ticketsApi from '../api/tickets.js';
+import { getToken } from '../api/client.js';
+import { safeSourceUrl } from './Tickets.jsx';
+
+const categories = [['forum_bug', '论坛 Bug'], ['site_bug', '站内 Bug'], ['feature', '功能建议'], ['account', '账号问题'], ['other', '其他问题']];
+const priorities = [['low', '普通'], ['normal', '一般'], ['high', '重要'], ['urgent', '紧急']];
+
+export default function TicketCreate() {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const search = new URLSearchParams(location.search);
+ const sourceUrl = safeSourceUrl((location.state && location.state.sourceUrl) || search.get('source_url') || search.get('from') || window.location.pathname);
+ const [form, setForm] = useState({ subject: '', description: '', category: 'site_bug', priority: 'normal', source_url: sourceUrl });
+ const [error, setError] = useState('');
+ const [busy, setBusy] = useState(false);
+ const update = (key) => (e) => setForm((prev) => ({ ...prev, [key]: e.target.value }));
+
+ if (!getToken()) return
🔒
登录后提交工单
请先登录,再反馈论坛或站内问题。
去登录
;
+
+ const submit = async (e) => {
+ e.preventDefault();
+ const subject = form.subject.trim(); const description = form.description.trim();
+ if (subject.length < 2) { setError('请填写问题标题(至少 2 个字)'); return; }
+ if (description.length < 10) { setError('请详细描述问题(至少 10 个字)'); return; }
+ setError(''); setBusy(true);
+ try {
+ const data = await ticketsApi.createTicket({
+ ...form,
+ subject,
+ description,
+ source: form.category === 'forum_bug' ? 'forum' : 'site',
+ source_url: safeSourceUrl(form.source_url),
+ browser_info: [navigator.userAgent, `${window.innerWidth}x${window.innerHeight}`].join(' | ').slice(0, 1000),
+ });
+ if (!data.ticket || !data.ticket.id) throw new Error('工单创建成功但未返回编号');
+ navigate('/tickets/' + data.ticket.id, { replace: true });
+ } catch (err) { setError(err.message || '提交失败,请稍后重试'); }
+ finally { setBusy(false); }
+ };
+
+ return
+
← 返回工单
提交问题
描述得越具体,越有助于快速定位问题。
+
+
;
+}
diff --git a/frontend/src/pages/TicketDetail.jsx b/frontend/src/pages/TicketDetail.jsx
new file mode 100644
index 0000000..8c6ed52
--- /dev/null
+++ b/frontend/src/pages/TicketDetail.jsx
@@ -0,0 +1,74 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { Link, useParams } from 'react-router-dom';
+import * as ticketsApi from '../api/tickets.js';
+import { me } from '../api/auth.js';
+import { getToken } from '../api/client.js';
+import { categoryLabel, formatTime, safeSourceUrl, statusInfo } from './Tickets.jsx';
+
+const STEPS = ['open', 'processing', 'waiting', 'resolved', 'closed'];
+const PUBLIC_EVENTS = new Set(['ticket_created', 'message_added', 'status_changed', 'ticket_closed', 'ticket_reopened']);
+
+function eventLabel(event) {
+ if (event.event_type === 'ticket_created') return '工单已创建';
+ if (event.event_type === 'message_added') return '新增公开回复';
+ if (event.event_type === 'ticket_closed') return '工单已关闭';
+ if (event.event_type === 'ticket_reopened') return '工单已重新打开';
+ return event.new_value ? `状态更新为“${statusInfo(event.new_value).label}”` : '工单状态已更新';
+}
+
+function TicketProgress({ status }) {
+ const current = STEPS.indexOf(status);
+ return (
+
+ {STEPS.map((step, index) => {
+ const info = statusInfo(step);
+ const state = index === current ? 'current' : index < current ? 'done' : 'upcoming';
+ return -
+
+ {state === 'done' ? '✓' : index + 1}
+ {info.label}
+
;
+ })}
+
+ );
+}
+
+function StatusChip({ status }) {
+ const info = statusInfo(status);
+ return {info.icon}{info.label};
+}
+
+export default function TicketDetail() {
+ const { id } = useParams();
+ const [user, setUser] = useState(null); const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true); const [error, setError] = useState('');
+ 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]);
+ useEffect(() => { if (!getToken()) { setLoading(false); return; } me().then(setUser).catch(() => { setUser(null); setLoading(false); }); }, []);
+ useEffect(() => { if (user) load(); }, [user, load]);
+
+ if (!getToken()) return 🔒
登录后查看工单
登录后才能查看工单详情和回复。
去登录
;
+ if (loading) return ;
+ if (error || !data || !data.ticket) return ⚠️
{error || '工单不存在或无权访问'}
返回工单
;
+
+ 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 sourceUrl = safeSourceUrl(ticket.source_url); const info = statusInfo(ticket.status); 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 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); } };
+
+ return
+
← 返回工单列表
+
+
+
{ticket.ticket_no || `工单 #${ticket.id}`}
{ticket.subject || ticket.title || '未命名工单'}
{categoryLabel(ticket.category)}{ticket.priority === 'urgent' ? '紧急' : ticket.priority === 'high' ? '重要' : ticket.priority === 'low' ? '低' : '一般'}
+
问题描述
{ticket.description}
{sourceUrl && 发现页面{sourceUrl}
}
+
沟通记录
{messages.length} 条公开回复{messages.length === 0 ? : {messages.map((message) => {message.author_username || message.author_name || '用户'}
{message.content || message.body}
)}
}
+ {events.length > 0 &&
处理记录
{events.map((event) => - {eventLabel(event)}
)}
}
+ {canReply ?
:
工单已关闭,如需继续反馈请提交新的工单。
}
+
+
+
+ {error &&
{error}
}
+
;
+}
diff --git a/frontend/src/pages/Tickets.jsx b/frontend/src/pages/Tickets.jsx
new file mode 100644
index 0000000..b3d5ba1
--- /dev/null
+++ b/frontend/src/pages/Tickets.jsx
@@ -0,0 +1,96 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { Link } from 'react-router-dom';
+import * as ticketsApi from '../api/tickets.js';
+import { me } from '../api/auth.js';
+import { getToken } from '../api/client.js';
+
+const STATUS = {
+ open: { label: '待处理', icon: 'inbox', tone: 'warning' },
+ processing: { label: '处理中', icon: 'sync', tone: 'primary' },
+ waiting: { label: '等待用户', icon: 'schedule', tone: 'secondary' },
+ resolved: { label: '已解决', icon: 'check_circle', tone: 'success' },
+ closed: { label: '已关闭', icon: 'lock', tone: 'neutral' },
+};
+const CATEGORY = { forum_bug: '论坛 Bug', site_bug: '站内 Bug', feature: '功能建议', account: '账号问题', report: '内容举报', other: '其他问题' };
+
+export function statusInfo(status) { return STATUS[status] || { label: status || '未知状态', icon: 'help', tone: 'neutral' }; }
+export function categoryLabel(category) { return CATEGORY[category] || category || '其他问题'; }
+/** 工单来源只允许 http(s) 或本站相对路径,避免把用户可控值直接作为危险链接。 */
+export function safeSourceUrl(value) {
+ const raw = String(value || '').trim();
+ if (!raw || raw.length > 1000) return '';
+ if (raw.startsWith('/') && !raw.startsWith('//')) return raw;
+ try {
+ const parsed = new URL(raw, window.location.origin);
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : '';
+ } catch { return ''; }
+}
+export function formatTime(value) {
+ if (!value) return '';
+ const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
+ return m ? `${m[1]}年${Number(m[2])}月${Number(m[3])}日 ${m[4]}:${m[5]}` : String(value);
+}
+
+function StatusChip({ status }) {
+ const info = statusInfo(status);
+ return {info.icon}{info.label};
+}
+
+function LoginGuide() {
+ return
+
🎫
+
登录后使用工单
+
登录后可以提交论坛或站内问题,并随时查看处理进度。
+
去登录
+
;
+}
+
+export default function Tickets() {
+ const [user, setUser] = useState(null);
+ const [tickets, setTickets] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [status, setStatus] = useState('');
+
+ const load = useCallback(async () => {
+ setLoading(true); setError('');
+ try {
+ const data = await ticketsApi.listTickets({ page: 1, pageSize: 20, status });
+ setTickets(data.tickets || []);
+ } catch (e) { setError(e.message || '工单加载失败'); }
+ finally { setLoading(false); }
+ }, [status]);
+
+ useEffect(() => {
+ if (!getToken()) { setLoading(false); return; }
+ me().then(setUser).catch(() => { setUser(null); setLoading(false); });
+ }, []);
+ useEffect(() => { if (user) load(); }, [user, load]);
+
+ if (!getToken() || (!user && !loading)) return ;
+
+ return
+
+
+
+
+
+ {loading &&
}
+ {error &&
}
+ {!loading && !error && tickets.length === 0 &&
📭
还没有工单
如果你在论坛或站内遇到问题,可以提交一条反馈。
提交第一个问题
}
+ {!loading && !error && tickets.length > 0 &&
}
+
;
+}
diff --git a/public/css/style.css b/public/css/style.css
index 0d76fac..a10978c 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -593,6 +593,97 @@ table tr:hover td {
padding: 1px 8px;
}
+/* 工单状态:独立命名空间,兼容明暗主题。 */
+.ticket-status.ticket-status--warning { background: #fff1d6; color: #6b4300; border-color: transparent; }
+.ticket-status.ticket-status--primary { background: var(--md-ref-primary-container); color: var(--md-ref-on-primary-container); border-color: transparent; }
+.ticket-status.ticket-status--secondary { background: var(--md-ref-secondary-container); color: var(--md-ref-on-secondary-container); border-color: transparent; }
+.ticket-status.ticket-status--success { background: #d9f2df; color: #1b5e20; border-color: transparent; }
+.ticket-status.ticket-status--neutral { background: transparent; color: var(--md-ref-on-surface-variant); border-color: var(--md-ref-outline-variant); }
+[data-theme="dark"] .ticket-status.ticket-status--warning { background: #5a420d; color: #ffdda0; }
+[data-theme="dark"] .ticket-status.ticket-status--success { background: #214d29; color: #b8efbd; }
+
+.ticket-detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(220px, 280px); gap: 20px; }
+.ticket-detail-main { min-width: 0; }
+@media (max-width: 720px) {
+ .ticket-detail-layout { display: block; }
+ .ticket-detail-layout > aside { margin-top: 20px; }
+}
+
+/* 工单详情页:紧凑的主内容与窄信息栏,避免复用通用页面间距造成空洞。 */
+.ticket-detail-page { max-width: 1080px; margin: 0 auto; }
+.ticket-detail-back { margin-bottom: 8px; }
+.ticket-detail-layout { align-items: start; }
+.ticket-detail-header, .ticket-detail-description, .ticket-detail-message, .ticket-detail-reply, .ticket-detail-sidebar { padding: 20px; }
+.ticket-detail-header { margin-bottom: 14px; }
+.ticket-detail-kicker { color: var(--md-ref-on-surface-variant); font-size: 13px; letter-spacing: .04em; }
+.ticket-detail-title { margin: 5px 0 10px; font-size: clamp(22px, 3vw, 30px); line-height: 1.25; overflow-wrap: anywhere; }
+.ticket-detail-tags { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
+.ticket-detail-tags .ticket-status { font-weight: 600; }
+.ticket-detail-progress { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0; list-style: none; padding: 0; margin: 22px 0 0; }
+.ticket-detail-progress-item { position: relative; min-width: 0; text-align: center; color: var(--md-ref-on-surface-variant); }
+.ticket-detail-progress-line { position: absolute; top: 13px; left: 0; right: 0; height: 2px; background: var(--md-ref-outline-variant); z-index: 0; }
+.ticket-detail-progress-item:first-child .ticket-detail-progress-line { left: 50%; }
+.ticket-detail-progress-item:last-child .ticket-detail-progress-line { right: 50%; }
+.ticket-detail-progress-dot { position: relative; z-index: 1; display: inline-flex; width: 27px; height: 27px; align-items: center; justify-content: center; border: 2px solid var(--md-ref-outline-variant); border-radius: 50%; background: var(--md-ref-surface); font-size: 12px; }
+.ticket-detail-progress-label { display: block; margin-top: 6px; font-size: 12px; white-space: nowrap; }
+.ticket-detail-progress-item.is-done .ticket-detail-progress-line, .ticket-detail-progress-item.is-current .ticket-detail-progress-line { background: var(--md-ref-primary); }
+.ticket-detail-progress-item.is-done .ticket-detail-progress-dot { border-color: var(--md-ref-primary); background: var(--md-ref-primary); color: var(--md-ref-on-primary); }
+.ticket-detail-progress-item.is-current { color: var(--md-ref-on-surface); font-weight: 700; }
+.ticket-detail-progress-item.is-current .ticket-detail-progress-dot { width: 33px; height: 33px; margin-top: -3px; border-color: var(--md-ref-primary); background: var(--md-ref-primary-container); color: var(--md-ref-on-primary-container); box-shadow: 0 0 0 4px color-mix(in srgb, var(--md-ref-primary) 18%, transparent); }
+.ticket-detail-description { margin-bottom: 20px; }
+.ticket-detail-description h2, .ticket-detail-events h2 { margin-bottom: 9px; font-size: 17px; }
+.ticket-detail-description p { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.7; }
+.ticket-detail-source { display: flex; gap: 9px; align-items: baseline; margin-top: 15px !important; font-size: 13px; }
+.ticket-detail-source a { min-width: 0; overflow-wrap: anywhere; }
+.ticket-detail-section-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 9px; }
+.ticket-detail-section-heading h2 { margin: 0; font-size: 19px; }
+.ticket-detail-section-heading span { font-size: 13px; }
+.ticket-detail-messages { display: grid; gap: 9px; list-style: none; padding: 0; margin: 0; }
+.ticket-detail-message { padding: 15px 17px; }
+.ticket-detail-message-meta { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; font-size: 13px; }
+.ticket-detail-message p { margin: 8px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.65; }
+.ticket-detail-empty { display: flex; align-items: center; gap: 9px; padding: 13px 16px; color: var(--md-ref-on-surface-variant); }
+.ticket-detail-empty .material-icons { font-size: 19px; }
+.ticket-detail-empty p { margin: 0; font-size: 14px; }
+.ticket-detail-events { margin: 17px 0 0; padding: 14px 17px; border: 1px solid var(--md-ref-outline-variant); border-radius: 12px; }
+.ticket-detail-events ol { list-style: none; padding: 0; margin: 0; }
+.ticket-detail-events li { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; border-bottom: 1px solid var(--md-ref-outline-variant); font-size: 13px; }
+.ticket-detail-events li:last-child { border-bottom: 0; }
+.ticket-detail-reply { margin-top: 14px; }
+.ticket-detail-reply label { display: block; margin-bottom: 7px; font-weight: 600; }
+.ticket-detail-reply textarea { display: block; width: 100%; min-height: 120px; }
+.ticket-detail-reply-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px; }
+.ticket-detail-reply-footer p { margin: 0; font-size: 13px; }
+.ticket-detail-sidebar { position: sticky; top: 76px; }
+.ticket-detail-sidebar-status { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-bottom: 15px; border-bottom: 1px solid var(--md-ref-outline-variant); font-size: 13px; }
+.ticket-detail-sidebar dl { display: grid; gap: 13px; margin: 16px 0 0; font-size: 13px; }
+.ticket-detail-sidebar dt { color: var(--md-ref-on-surface-variant); }
+.ticket-detail-sidebar dd { margin: 3px 0 0; overflow-wrap: anywhere; }
+.ticket-detail-actions { display: grid; gap: 8px; margin-top: 18px; }
+.ticket-detail-error { margin-top: 14px; color: var(--md-ref-error); }
+.ticket-detail-guard { max-width: 460px; margin: 48px auto; }
+.ticket-detail-guard h1 { font-size: 22px; margin-bottom: 8px; }
+.ticket-detail-guard .btn { margin-top: 16px; }
+
+@media (max-width: 720px) {
+ .ticket-detail-page { max-width: none; }
+ .ticket-detail-header, .ticket-detail-description, .ticket-detail-message, .ticket-detail-reply, .ticket-detail-sidebar { padding: 16px; }
+ .ticket-detail-title { font-size: 24px; }
+ .ticket-detail-progress { margin-top: 18px; }
+ .ticket-detail-progress-label { font-size: 11px; }
+ .ticket-detail-sidebar { position: static; }
+ .ticket-detail-reply-footer { align-items: flex-start; flex-direction: column; }
+ .ticket-detail-reply-footer .btn { width: 100%; }
+}
+
+@media (max-width: 420px) {
+ .ticket-detail-progress-label { font-size: 10px; }
+ .ticket-detail-progress-dot { width: 24px; height: 24px; }
+ .ticket-detail-progress-item.is-current .ticket-detail-progress-dot { width: 29px; height: 29px; }
+ .ticket-detail-events li { display: block; }
+ .ticket-detail-events time { display: block; margin-top: 3px; }
+}
+
@keyframes chipPop {
from { transform: scale(0.92); }
50% { transform: scale(1.06); }
diff --git a/routes/tickets.js b/routes/tickets.js
new file mode 100644
index 0000000..0bc1d0d
--- /dev/null
+++ b/routes/tickets.js
@@ -0,0 +1,370 @@
+const express = require('express');
+const db = require('../db');
+const { authMiddleware, adminOnly } = require('../middleware/auth');
+
+const router = express.Router();
+
+const CATEGORIES = new Set(['forum_bug', 'site_bug', 'feature', 'account', 'other']);
+const PRIORITIES = new Set(['low', 'normal', 'high', 'urgent']);
+const STATUSES = new Set(['open', 'processing', 'waiting', 'resolved', 'closed']);
+const TRANSITIONS = {
+ open: new Set(['processing', 'closed']),
+ processing: new Set(['waiting', 'resolved', 'closed']),
+ waiting: new Set(['processing', 'closed']),
+ resolved: new Set(['closed', 'processing']),
+ closed: new Set(['processing']),
+};
+
+function text(value, max = 0) {
+ if (typeof value !== 'string') return '';
+ const valueText = value.trim();
+ return max && valueText.length > max ? valueText.slice(0, max) : valueText;
+}
+
+function currentUserRole(userId) {
+ const user = db.get('SELECT role FROM users WHERE id = ?', [userId]);
+ return user ? user.role : null;
+}
+
+function validateSourceUrl(value) {
+ if (value === undefined || value === null || value === '') return { value: '' };
+ if (typeof value !== 'string') return { error: '来源地址不合法' };
+ if (value.length > 1000 || /[\u0000-\u001f\u007f]/.test(value)) return { error: '来源地址不合法' };
+ const sourceUrl = value.trim();
+ if (!sourceUrl) return { value: '' };
+ if (sourceUrl.startsWith('/') && !sourceUrl.startsWith('//')) return { value: sourceUrl };
+ if (!/^https?:\/\//i.test(sourceUrl)) return { error: '来源地址只允许站内路径或 http/https 地址' };
+ try {
+ const parsed = new URL(sourceUrl);
+ if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) return { error: '来源地址不合法' };
+ } catch {
+ return { error: '来源地址不合法' };
+ }
+ return { value: sourceUrl };
+}
+
+function pageParams(query) {
+ const page = Number.parseInt(query.page, 10);
+ const pageSize = Number.parseInt(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) {
+ const id = Number.parseInt(value, 10);
+ return Number.isInteger(id) && id > 0 && String(id) === String(value) ? id : 0;
+}
+
+function getTicket(id) {
+ return db.get(`SELECT t.*, u.username AS requester_name, u.nickname AS requester_nickname,
+ a.username AS assignee_name, a.nickname AS assignee_nickname
+ FROM tickets t
+ LEFT JOIN users u ON u.id = t.requester_id
+ LEFT JOIN users a ON a.id = t.assignee_id
+ WHERE t.id = ?`, [id]);
+}
+
+function canAccess(ticket, user, role) {
+ return !!ticket && !!role && (ticket.requester_id === user.id || role === 'admin');
+}
+
+function addEvent(insertEvent, ticketId, actorId, type, field = '', oldValue = '', newValue = '', detail = '') {
+ insertEvent.run(ticketId, actorId || null, type, field, String(oldValue ?? ''), String(newValue ?? ''), detail);
+}
+
+function updateStatus(ticket, status, actorId, insertEvent) {
+ if (!STATUSES.has(status)) return { error: '状态不合法' };
+ if (ticket.status === status) return { changed: false };
+ if (!TRANSITIONS[ticket.status] || !TRANSITIONS[ticket.status].has(status)) {
+ return { error: '不允许的状态流转' };
+ }
+ const now = "datetime('now')";
+ const values = [status];
+ let sql = `UPDATE tickets SET status = ?, updated_at = ${now}`;
+ if (status === 'resolved') sql += `, resolved_at = ${now}`;
+ if (status === 'closed') sql += `, closed_at = ${now}`;
+ if (status !== 'resolved') sql += ', resolved_at = NULL';
+ if (status !== 'closed') sql += ', closed_at = NULL';
+ sql += ' WHERE id = ?';
+ values.push(ticket.id);
+ db.getDb().prepare(sql).run(...values);
+ addEvent(insertEvent, ticket.id, actorId, 'status_changed', 'status', ticket.status, status);
+ return { changed: true };
+}
+
+function ticketResponse(ticket, isAdmin) {
+ 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
+ FROM ticket_messages tm LEFT JOIN users u ON u.id = tm.author_id
+ WHERE tm.ticket_id = ? ${isAdmin ? '' : 'AND tm.is_internal = 0'}
+ ORDER BY tm.created_at ASC, tm.id ASC`, [ticket.id]);
+ const response = { ticket, messages };
+ if (isAdmin) {
+ 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
+ WHERE e.ticket_id = ? ORDER BY e.created_at ASC, e.id ASC`, [ticket.id]);
+ }
+ return response;
+}
+
+// 管理后台列表(作为管理后台的工单子 tab 使用)
+router.get('/admin', authMiddleware, adminOnly, (req, res) => {
+ const { page, pageSize } = pageParams(req.query);
+ const conditions = [];
+ const params = [];
+ const filters = [
+ ['status', STATUSES], ['priority', PRIORITIES], ['category', CATEGORIES],
+ ];
+ for (const [key, allowed] of filters) {
+ if (req.query[key]) {
+ if (!allowed.has(String(req.query[key]))) return res.status(400).json({ error: `${key} 不合法` });
+ conditions.push(`t.${key} = ?`); params.push(String(req.query[key]));
+ }
+ }
+ const assigneeId = req.query.assignee_id === 'null' ? null : idParam(req.query.assignee_id || '');
+ if (req.query.assignee_id !== undefined) {
+ if (req.query.assignee_id === 'null') conditions.push('t.assignee_id IS NULL');
+ else if (!assigneeId) return res.status(400).json({ error: '负责人不合法' });
+ else { conditions.push('t.assignee_id = ?'); params.push(assigneeId); }
+ }
+ const requesterId = req.query.requester_id ? idParam(req.query.requester_id) : 0;
+ if (req.query.requester_id && !requesterId) return res.status(400).json({ error: '提交人不合法' });
+ if (requesterId) { conditions.push('t.requester_id = ?'); params.push(requesterId); }
+ if (req.query.q) {
+ const q = text(req.query.q, 100);
+ conditions.push('(t.ticket_no LIKE ? OR t.subject LIKE ? OR t.description LIKE ?)');
+ params.push(`%${q}%`, `%${q}%`, `%${q}%`);
+ }
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
+ const total = db.get(`SELECT COUNT(*) AS count FROM tickets t ${where}`, params).count;
+ const list = db.all(`SELECT t.id, t.ticket_no, t.subject, t.category, t.priority, t.status,
+ t.requester_id, t.assignee_id, t.source, t.created_at, t.updated_at, t.last_reply_at,
+ u.username AS requester_name, u.nickname AS requester_nickname,
+ a.username AS assignee_name, a.nickname AS assignee_nickname
+ FROM tickets t LEFT JOIN users u ON u.id = t.requester_id LEFT JOIN users a ON a.id = t.assignee_id
+ ${where} ORDER BY CASE t.status WHEN 'open' THEN 0 WHEN 'processing' THEN 1 WHEN 'waiting' THEN 2 WHEN 'resolved' THEN 3 ELSE 4 END,
+ t.updated_at DESC, t.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, (page - 1) * pageSize]);
+ res.json({ tickets: list, list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) });
+});
+
+router.get('/admin/stats', authMiddleware, adminOnly, (req, res) => {
+ const row = db.get(`SELECT COUNT(*) AS total,
+ SUM(status = 'open') AS open, SUM(status = 'processing') AS processing,
+ SUM(status = 'waiting') AS waiting, SUM(status = 'resolved') AS resolved,
+ SUM(status = 'closed') AS closed FROM tickets`);
+ res.json(Object.fromEntries(Object.entries(row).map(([key, value]) => [key, Number(value) || 0])));
+});
+
+router.get('/admin/assignees', authMiddleware, adminOnly, (req, res) => {
+ res.json(db.all("SELECT id, username, nickname FROM users WHERE role = 'admin' ORDER BY id ASC"));
+});
+
+// 用户自己的工单列表
+router.get('/', authMiddleware, (req, res) => {
+ const { page, pageSize } = pageParams(req.query);
+ if (req.query.status && !STATUSES.has(String(req.query.status))) return res.status(400).json({ error: '状态不合法' });
+ const params = [req.user.id];
+ const statusSql = req.query.status ? 'AND t.status = ?' : '';
+ if (req.query.status) params.push(String(req.query.status));
+ const total = db.get(`SELECT COUNT(*) AS count FROM tickets t WHERE t.requester_id = ? ${statusSql}`, params).count;
+ const list = db.all(`SELECT t.id, t.ticket_no, t.subject, t.category, t.priority, t.status, t.source,
+ t.created_at, t.updated_at, t.last_reply_at
+ FROM tickets t WHERE t.requester_id = ? ${statusSql}
+ ORDER BY t.created_at DESC, t.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, (page - 1) * pageSize]);
+ res.json({ tickets: list, list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) });
+});
+
+router.post('/', authMiddleware, (req, res) => {
+ const body = req.body || {};
+ const subject = text(body.subject, 120);
+ const description = text(body.description, 20000);
+ const category = text(body.category, 30) || 'other';
+ const priority = text(body.priority, 20) || 'normal';
+ const source = text(body.source, 20) || 'site';
+ if (subject.length < 1 || description.length < 1) return res.status(400).json({ error: '标题和问题描述不能为空' });
+ if (!CATEGORIES.has(category)) return res.status(400).json({ error: '问题分类不合法' });
+ if (!PRIORITIES.has(priority)) return res.status(400).json({ error: '优先级不合法' });
+ if (!['forum', 'site'].includes(source)) return res.status(400).json({ error: '来源不合法' });
+ const sourceUrlResult = validateSourceUrl(body.source_url);
+ if (sourceUrlResult.error) return res.status(400).json({ error: sourceUrlResult.error });
+ const sourceUrl = sourceUrlResult.value;
+ const sourceType = text(body.source_type, 40);
+ const sourceId = body.source_id === undefined || body.source_id === '' ? 0 : idParam(String(body.source_id));
+ if (body.source_id !== undefined && body.source_id !== '' && !sourceId) return res.status(400).json({ error: '来源编号不合法' });
+ if (source === 'forum' && sourceId && !db.get('SELECT id FROM forum_posts WHERE id = ?', [sourceId])) {
+ return res.status(400).json({ error: '关联的论坛帖子不存在' });
+ }
+ const browserInfo = text(body.browser_info, 1000);
+ try {
+ const result = db.transaction(() => {
+ const database = db.getDb();
+ const insertTicket = database.prepare(`INSERT INTO tickets
+ (ticket_no, requester_id, subject, description, category, priority, source, source_url, source_type, source_id, browser_info)
+ VALUES ('PENDING-' || hex(randomblob(8)), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
+ const info = insertTicket.run(req.user.id, subject, description, category, priority, source, sourceUrl, sourceType, sourceId, browserInfo);
+ const id = Number(info.lastInsertRowid);
+ const ticketNo = `RW-${String(id).padStart(6, '0')}`;
+ database.prepare('UPDATE tickets SET ticket_no = ? WHERE id = ?').run(ticketNo, id);
+ const event = database.prepare(`INSERT INTO ticket_events
+ (ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
+ addEvent(event, id, req.user.id, 'ticket_created', '', '', 'open', '用户创建工单');
+ return { id, ticket_no: ticketNo };
+ });
+ res.status(201).json({ message: '工单已创建', ticket: getTicket(result.id) });
+ } catch (e) {
+ console.error('Ticket create error:', e.message);
+ res.status(500).json({ error: '创建工单失败' });
+ }
+});
+
+router.get('/:id/events', authMiddleware, adminOnly, (req, res) => {
+ const id = idParam(req.params.id);
+ if (!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
+ 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]));
+});
+
+router.get('/:id', authMiddleware, (req, res) => {
+ const id = idParam(req.params.id);
+ const ticket = id ? getTicket(id) : null;
+ if (!ticket) return res.status(404).json({ error: '工单不存在' });
+ const role = currentUserRole(req.user.id);
+ if (!role) return res.status(401).json({ error: '登录已失效' });
+ if (!canAccess(ticket, req.user, role)) return res.status(403).json({ error: '无权限访问该工单' });
+ res.json(ticketResponse(ticket, role === 'admin'));
+});
+
+router.post('/:id/messages', authMiddleware, (req, res) => {
+ const id = idParam(req.params.id);
+ const ticket = id ? getTicket(id) : null;
+ if (!ticket) return res.status(404).json({ error: '工单不存在' });
+ const role = currentUserRole(req.user.id);
+ if (!role) return res.status(401).json({ error: '登录已失效' });
+ if (!canAccess(ticket, req.user, role)) return res.status(403).json({ error: '无权限访问该工单' });
+ const content = text(req.body && req.body.content, 20000);
+ if (!content) return res.status(400).json({ error: '回复内容不能为空' });
+ if (ticket.status === 'closed') return res.status(409).json({ error: '工单已关闭,不能回复' });
+ try {
+ db.transaction(() => {
+ const database = db.getDb();
+ 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;
+ if (role !== 'admin' && ticket.status === 'waiting') nextStatus = 'processing';
+ 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,
+ status = ? WHERE id = ?`).run(role, nextStatus, id);
+ const event = database.prepare(`INSERT INTO ticket_events
+ (ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
+ addEvent(event, id, req.user.id, 'message_added', '', '', '公开回复', '');
+ if (nextStatus !== ticket.status) addEvent(event, id, req.user.id, 'status_changed', 'status', ticket.status, nextStatus, '用户回复后自动进入处理中');
+ });
+ res.status(201).json(ticketResponse(getTicket(id), role === 'admin'));
+ } catch (e) {
+ console.error('Ticket message error:', e.message);
+ res.status(500).json({ error: '回复工单失败' });
+ }
+});
+
+router.post('/:id/internal-messages', authMiddleware, adminOnly, (req, res) => {
+ const id = idParam(req.params.id);
+ if (!id || !getTicket(id)) return res.status(404).json({ error: '工单不存在' });
+ const content = text(req.body && req.body.content, 20000);
+ if (!content) return res.status(400).json({ error: '内部备注不能为空' });
+ try {
+ db.transaction(() => {
+ const database = db.getDb();
+ 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 event = database.prepare(`INSERT INTO ticket_events
+ (ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
+ addEvent(event, id, req.user.id, 'internal_note_added', '', '', '', '管理员添加内部备注');
+ });
+ res.status(201).json(ticketResponse(getTicket(id), true));
+ } catch (e) {
+ console.error('Ticket internal message error:', e.message);
+ res.status(500).json({ error: '添加内部备注失败' });
+ }
+});
+
+router.put('/:id/status', authMiddleware, adminOnly, (req, res) => {
+ const id = idParam(req.params.id);
+ const ticket = id ? getTicket(id) : null;
+ if (!ticket) return res.status(404).json({ error: '工单不存在' });
+ const status = text(req.body && req.body.status, 20);
+ try {
+ const result = db.transaction(() => {
+ const database = db.getDb();
+ const event = database.prepare(`INSERT INTO ticket_events
+ (ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
+ return updateStatus(ticket, status, req.user.id, event);
+ });
+ if (result.error) return res.status(result.error === '不允许的状态流转' ? 409 : 400).json({ error: result.error });
+ res.json({ message: '状态已更新', ticket: getTicket(id) });
+ } catch (e) { console.error('Ticket status error:', e.message); res.status(500).json({ error: '更新状态失败' }); }
+});
+
+router.put('/:id/priority', authMiddleware, adminOnly, (req, res) => {
+ const id = idParam(req.params.id);
+ const ticket = id ? getTicket(id) : null;
+ if (!ticket) return res.status(404).json({ error: '工单不存在' });
+ const priority = text(req.body && req.body.priority, 20);
+ if (!PRIORITIES.has(priority)) return res.status(400).json({ error: '优先级不合法' });
+ try {
+ db.transaction(() => {
+ const database = db.getDb();
+ database.prepare("UPDATE tickets SET priority = ?, updated_at = datetime('now') WHERE id = ?").run(priority, id);
+ const event = database.prepare(`INSERT INTO ticket_events
+ (ticket_id, actor_id, event_type, field_name, old_value, new_value) VALUES (?, ?, ?, ?, ?, ?)`);
+ addEvent(event, id, req.user.id, 'priority_changed', 'priority', ticket.priority, priority);
+ });
+ res.json({ message: '优先级已更新', ticket: getTicket(id) });
+ } catch (e) { console.error('Ticket priority error:', e.message); res.status(500).json({ error: '更新优先级失败' }); }
+});
+
+router.put('/:id/assignee', authMiddleware, adminOnly, (req, res) => {
+ const id = idParam(req.params.id);
+ const ticket = id ? getTicket(id) : null;
+ if (!ticket) return res.status(404).json({ error: '工单不存在' });
+ const assigneeId = req.body && (req.body.assignee_id === null || req.body.assignee_id === '' ? null : idParam(String(req.body.assignee_id)));
+ if (assigneeId !== null && !assigneeId) return res.status(400).json({ error: '负责人不合法' });
+ if (assigneeId !== null && !db.get("SELECT id FROM users WHERE id = ? AND role = 'admin'", [assigneeId])) return res.status(400).json({ error: '负责人必须是管理员' });
+ try {
+ db.transaction(() => {
+ const database = db.getDb();
+ database.prepare("UPDATE tickets SET assignee_id = ?, updated_at = datetime('now') WHERE id = ?").run(assigneeId, id);
+ const event = database.prepare(`INSERT INTO ticket_events
+ (ticket_id, actor_id, event_type, field_name, old_value, new_value) VALUES (?, ?, ?, ?, ?, ?)`);
+ addEvent(event, id, req.user.id, 'assignee_changed', 'assignee_id', ticket.assignee_id, assigneeId);
+ });
+ res.json({ message: '负责人已更新', ticket: getTicket(id) });
+ } catch (e) { console.error('Ticket assignee error:', e.message); res.status(500).json({ error: '更新负责人失败' }); }
+});
+
+router.post('/:id/close', authMiddleware, (req, res) => changeUserStatus(req, res, 'closed'));
+router.post('/:id/reopen', authMiddleware, (req, res) => changeUserStatus(req, res, 'processing'));
+
+function changeUserStatus(req, res, status) {
+ const id = idParam(req.params.id);
+ const ticket = id ? getTicket(id) : null;
+ if (!ticket) return res.status(404).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 {
+ db.transaction(() => {
+ const database = db.getDb();
+ const event = database.prepare(`INSERT INTO ticket_events
+ (ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
+ const result = updateStatus(ticket, status, req.user.id, event);
+ if (result.error) throw new Error(result.error);
+ });
+ res.json({ message: status === 'closed' ? '工单已关闭' : '工单已重新打开', ticket: getTicket(id) });
+ } catch (e) { res.status(e.message === '不允许的状态流转' ? 409 : 500).json({ error: e.message === '不允许的状态流转' ? e.message : '更新工单失败' }); }
+}
+
+module.exports = router;
diff --git a/server.js b/server.js
index b3eaa3c..f295e96 100644
--- a/server.js
+++ b/server.js
@@ -38,6 +38,7 @@ const importRoutes = require('./routes/import');
const noteRoutes = require('./routes/notes');
const feedRoutes = require('./routes/feed');
const terminalRoutes = require('./routes/terminal');
+const ticketRoutes = require('./routes/tickets');
const oidcRoutes = require('./routes/oidc');
const { blogSSR, forumSSR, categorySSR, sitemapXml } = require('./ssr');
@@ -290,6 +291,7 @@ app.use('/api/proxy', proxyRoutes);
app.use('/api/import', importRoutes);
app.use('/api/notes', noteRoutes);
app.use('/api/terminal', terminalRoutes);
+app.use('/api/tickets', ticketRoutes);
// Version & Update
const version = require('fs').readFileSync('./VERSION', 'utf8').trim();