feat: 新增工单反馈系统

This commit is contained in:
2026-09-05 02:42:51 +08:00
parent e505a170b7
commit 8e890d9a96
13 changed files with 996 additions and 0 deletions
+57
View File
@@ -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); }
+6
View File
@@ -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() {
<Route path="/forum.html" element={<Forum />} />
<Route path="/forum/c/:id" element={<ForumCategory />} />
<Route path="/forum/:id" element={<ForumDetail />} />
<Route path="/tickets.html" element={<Tickets />} />
<Route path="/tickets/new" element={<TicketCreate />} />
<Route path="/tickets/:id" element={<TicketDetail />} />
<Route path="/forum/manage" element={<ForumManagePanel />} />
<Route path="/forum/manage/:id" element={<ForumManageCategory />} />
<Route path="/u/:id" element={<UserProfile />} />
+2
View File
@@ -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() {
<Route path="/links" element={<Links />} />
<Route path="/uploads" element={<Uploads />} />
<Route path="/import" element={<ImportDb />} />
<Route path="/tickets" element={<TicketManage />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Route>
</Routes>
+9
View File
@@ -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' },
+201
View File
@@ -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 <Chip size="small" label={item.label} color={item.color} variant={value === 'closed' ? 'outlined' : 'filled'} />;
}
function PriorityChip({ value }) {
const item = PRIORITY[value] || { label: value || '普通', color: 'default' };
return <Chip size="small" label={item.label} color={item.color} variant="outlined" />;
}
function StatCard({ label, value, tone }) {
return (
<Paper variant="outlined" sx={{ p: 1.5, minWidth: 105, flex: '1 1 130px' }}>
<Typography variant="caption" color="text.secondary">{label}</Typography>
<Typography variant="h5" sx={{ mt: 0.25, fontWeight: 700, color: tone ? `${tone}.main` : 'text.primary' }}>{value}</Typography>
</Paper>
);
}
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 (
<Box component="main" aria-labelledby="ticket-page-title">
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
<Box><Typography id="ticket-page-title" variant="h5" component="h1">工单管理</Typography><Typography variant="body2" color="text.secondary">集中处理论坛和站内问题反馈</Typography></Box>
<IconButton aria-label="刷新工单" title="刷新" onClick={refresh} disabled={loading || saving}><RefreshIcon /></IconButton>
</Stack>
<Stack direction="row" spacing={1.25} useFlexGap flexWrap="wrap" sx={{ mb: 2 }}>
{statItems.map(([label, value, tone]) => <StatCard key={label} label={label} value={value} tone={tone} />)}
</Stack>
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }} component="form" onSubmit={(e) => { e.preventDefault(); setPage(1); loadList(); }}>
<Grid container spacing={1.25} alignItems="center">
<Grid item xs={12} md={4}><TextField fullWidth size="small" label="搜索工单" placeholder="编号、标题或内容" value={filters.q} onChange={(e) => updateFilter('q', e.target.value)} /></Grid>
<Grid item xs={6} sm={4} md={2}><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></Grid>
<Grid item xs={6} sm={4} md={2}><TextField fullWidth select size="small" label="优先级" value={filters.priority} onChange={(e) => updateFilter('priority', e.target.value)}><MenuItem value="">全部优先级</MenuItem>{Object.entries(PRIORITY).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField></Grid>
<Grid item xs={12} sm={4} md={2}><TextField fullWidth select size="small" label="类型" value={filters.category} onChange={(e) => updateFilter('category', e.target.value)}><MenuItem value="">全部类型</MenuItem>{Object.entries(CATEGORY).map(([key, label]) => <MenuItem key={key} value={key}>{label}</MenuItem>)}</TextField></Grid>
<Grid item xs={12} md={2}><Button fullWidth type="submit" variant="contained" sx={{ minHeight: 40 }}>搜索</Button></Grid>
</Grid>
</Paper>
{error && <Alert severity="error" role="alert" action={<Button color="inherit" size="small" onClick={loadList}>重试</Button>} sx={{ mb: 2 }}>{error}</Alert>}
<Grid container spacing={2} alignItems="stretch">
<Grid item xs={12} lg={5} sx={{ minWidth: 0 }}>
<Paper variant="outlined" sx={{ height: '100%', overflow: 'hidden' }}>
<Box sx={{ p: 1.5, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}><Typography variant="subtitle1" fontWeight={700}>最近工单</Typography><Typography variant="caption" color="text.secondary"> {total} </Typography></Box>
<Divider />
{loading ? <Box role="status" aria-label="正在加载工单" sx={{ p: 4, textAlign: 'center' }}><CircularProgress size={28} /></Box> : list.length === 0 ? <Box sx={{ p: 4, textAlign: 'center' }}><Typography color="text.secondary">暂无匹配工单</Typography></Box> : (
<Stack component="ul" aria-label="工单列表" sx={{ listStyle: 'none', m: 0, p: 0 }}>
{list.map((ticket) => <Box component="li" key={ticket.id}><Button fullWidth onClick={() => loadDetail(ticket.id)} aria-pressed={String(selectedId) === String(ticket.id)} sx={{ p: 1.5, textAlign: 'left', textTransform: 'none', justifyContent: 'flex-start', borderRadius: 0, borderBottom: '1px solid', borderColor: 'divider', bgcolor: String(selectedId) === String(ticket.id) ? 'action.selected' : 'transparent' }}><Box sx={{ width: '100%', minWidth: 0 }}><Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}><Typography variant="caption" color="text.secondary">{ticket.ticket_no || `#${ticket.id}`}</Typography><StatusChip value={ticket.status} /><PriorityChip value={ticket.priority} /></Stack><Typography variant="body2" fontWeight={600} noWrap>{ticket.subject || ticket.title || '无标题工单'}</Typography><Typography variant="caption" color="text.secondary" noWrap>{ticket.requester_name || ticket.requester_username || '未知用户'} · {fmtTime(ticket.updated_at || ticket.created_at)}</Typography></Box></Button></Box>)}
</Stack>
)}
<Stack direction="row" alignItems="center" justifyContent="center" spacing={1} sx={{ p: 1.25 }}><IconButton aria-label="上一页" size="small" disabled={page <= 1 || loading} onClick={() => setPage(page - 1)}><NavigateBeforeIcon /></IconButton><Typography variant="caption"> {page} / {totalPages} </Typography><IconButton aria-label="下一页" size="small" disabled={page >= totalPages || loading} onClick={() => setPage(page + 1)}><NavigateNextIcon /></IconButton></Stack>
</Paper>
</Grid>
<Grid item xs={12} lg={7} sx={{ minWidth: 0 }}>
<Paper variant="outlined" sx={{ p: { xs: 2, md: 2.5 }, minHeight: 420 }}>
{!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}>
<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>
<Grid container spacing={1.25}><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="状态" 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="优先级" 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="负责人" value={selectedTicket.assignee_id || ''} disabled={saving} onChange={(e) => updateTicket(`/tickets/${selectedId}/assignee`, { assignee_id: e.target.value || null }, '负责人已更新')}><MenuItem value="">未分配</MenuItem>{assignees.filter((user) => user.role === 'admin' || user.role === undefined).map((user) => <MenuItem key={user.id} value={user.id}>{user.nickname || user.username}</MenuItem>)}</TextField></Grid></Grid>
<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>
<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>
</Stack>
) : <Alert severity="error">工单详情不存在或加载失败</Alert>}
</Paper>
</Grid>
</Grid>
</Box>
);
}
+33
View File
@@ -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' });
}
+1
View File
@@ -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 },
];
+54
View File
@@ -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 <div className="empty-state" style={{ maxWidth: 460, margin: '48px auto' }}><div className="empty-icon" aria-hidden="true">🔒</div><h1 style={{ fontSize: 22 }}>登录后提交工单</h1><p className="text-muted">请先登录再反馈论坛或站内问题</p><Link to="/login.html" state={{ from: location.pathname }} className="btn btn-filled" style={{ marginTop: 16 }}>去登录</Link></div>;
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 <div style={{ maxWidth: 760, margin: '0 auto' }}>
<div style={{ marginBottom: 22 }}><Link to="/tickets.html" className="btn btn-text btn-sm"> 返回工单</Link><h1 className="page-title" style={{ margin: '12px 0 6px' }}>提交问题</h1><p className="text-muted" style={{ margin: 0 }}>描述得越具体越有助于快速定位问题</p></div>
<form className="card" onSubmit={submit} noValidate style={{ padding: 22 }}>
{error && <div role="alert" className="empty-state" style={{ padding: 12, marginBottom: 18, textAlign: 'left' }}><p style={{ margin: 0 }}>{error}</p></div>}
<div className="form-group"><label htmlFor="ticket-subject">问题标题 <span aria-hidden="true">*</span></label><input id="ticket-subject" value={form.subject} onChange={update('subject')} maxLength={120} required aria-describedby="ticket-subject-help" placeholder="例如:论坛帖子无法回复" /><p id="ticket-subject-help" className="text-muted" style={{ fontSize: 13 }}>请用一句话概括遇到的问题最多 120 </p></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}><div className="form-group"><label htmlFor="ticket-category">问题类型 <span aria-hidden="true">*</span></label><select id="ticket-category" value={form.category} onChange={update('category')} required>{categories.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></div><div className="form-group"><label htmlFor="ticket-priority">优先级</label><select id="ticket-priority" value={form.priority} onChange={update('priority')}>{priorities.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></div></div>
<div className="form-group"><label htmlFor="ticket-description">问题描述 <span aria-hidden="true">*</span></label><textarea id="ticket-description" value={form.description} onChange={update('description')} maxLength={20000} required rows={8} aria-describedby="ticket-description-help" placeholder="请描述发生了什么、如何复现,以及你期望的结果。" /><p id="ticket-description-help" className="text-muted" style={{ fontSize: 13 }}>至少 10 个字最多 20000 </p></div>
<div className="form-group"><label htmlFor="ticket-source-url">发现问题的页面地址</label><input id="ticket-source-url" type="url" value={form.source_url} onChange={update('source_url')} maxLength={1000} aria-describedby="ticket-source-help" /><p id="ticket-source-help" className="text-muted" style={{ fontSize: 13 }}>已自动记录当前页面地址如果问题来自其他页面可以在这里修改</p></div>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', flexWrap: 'wrap' }}><Link to="/tickets.html" className="btn btn-tonal">取消</Link><button type="submit" className="btn btn-filled" disabled={busy}>{busy ? '提交中…' : '提交工单'}</button></div>
</form>
</div>;
}
+74
View File
@@ -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 (
<ol className="ticket-detail-progress" aria-label={`工单处理进度,当前为${statusInfo(status).label}`}>
{STEPS.map((step, index) => {
const info = statusInfo(step);
const state = index === current ? 'current' : index < current ? 'done' : 'upcoming';
return <li key={step} className={`ticket-detail-progress-item is-${state}`} aria-current={state === 'current' ? 'step' : undefined}>
<span className="ticket-detail-progress-line" aria-hidden="true" />
<span className="ticket-detail-progress-dot" aria-hidden="true">{state === 'done' ? '✓' : index + 1}</span>
<span className="ticket-detail-progress-label">{info.label}</span>
</li>;
})}
</ol>
);
}
function StatusChip({ status }) {
const info = statusInfo(status);
return <span className={`chip ticket-status ticket-status--${info.tone}`}><span className="material-icons" aria-hidden="true">{info.icon}</span>{info.label}</span>;
}
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 <div className="empty-state ticket-detail-guard" role="status"><div className="empty-icon" aria-hidden="true">🔒</div><h1>登录后查看工单</h1><p className="text-muted">登录后才能查看工单详情和回复</p><Link to="/login.html" state={{ from: window.location.pathname }} className="btn btn-filled">去登录</Link></div>;
if (loading) return <div className="loading" role="status" aria-label="正在加载工单"><div className="spinner" /></div>;
if (error || !data || !data.ticket) return <div className="empty-state" role="alert"><div className="empty-icon" aria-hidden="true"></div><p>{error || '工单不存在或无权访问'}</p><button type="button" className="btn btn-tonal btn-sm" onClick={load}>重试</button><Link to="/tickets.html" className="btn btn-text btn-sm">返回工单</Link></div>;
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 <div className="ticket-detail-page">
<Link to="/tickets.html" className="btn btn-text btn-sm ticket-detail-back"> 返回工单列表</Link>
<div className="ticket-detail-layout">
<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-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>
{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>}
{canReply ? <form className="card ticket-detail-reply" onSubmit={sendReply}><label htmlFor="ticket-reply">追加回复</label><textarea id="ticket-reply" value={reply} onChange={(e) => setReply(e.target.value)} rows={5} maxLength={20000} placeholder="补充信息或回复处理结果" aria-describedby="ticket-reply-help" /><div className="ticket-detail-reply-footer"><p id="ticket-reply-help" className="text-muted">回复将对工单参与者公开</p><button type="submit" className="btn btn-filled" disabled={busy}>{busy ? '发送中…' : '发送回复'}</button></div></form> : <div className="card ticket-detail-closed" role="status">工单已关闭如需继续反馈请提交新的工单</div>}
</div>
<aside className="card ticket-detail-sidebar" aria-label="工单信息"><div className="ticket-detail-sidebar-status"><span className="text-muted">当前状态</span><StatusChip status={ticket.status} /></div><dl><div><dt>问题类型</dt><dd>{categoryLabel(ticket.category)}</dd></div><div><dt>创建时间</dt><dd>{formatTime(ticket.created_at)}</dd></div><div><dt>最近更新</dt><dd>{formatTime(ticket.updated_at)}</dd></div></dl><div className="ticket-detail-actions">{ticket.status === 'resolved' && <button type="button" className="btn btn-filled" disabled={busy} onClick={() => runAction(ticketsApi.closeTicket, '确认将此工单标记为已关闭吗?')}>确认已解决</button>}{ticket.status !== 'closed' && ticket.status !== 'resolved' && <button type="button" className="btn btn-tonal" disabled={busy} onClick={() => runAction(ticketsApi.closeTicket, '确认关闭此工单吗?')}>关闭工单</button>}{ticket.status === 'closed' && <button type="button" className="btn btn-tonal" disabled={busy} onClick={() => runAction(ticketsApi.reopenTicket, '确认重新打开此工单吗?')}>重新打开</button>}</div></aside>
</div>
{error && <div className="ticket-detail-error" role="alert">{error}</div>}
</div>;
}
+96
View File
@@ -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 <span className={'chip ticket-status ticket-status--' + info.tone}><span className="material-icons" aria-hidden="true" style={{ fontSize: 16 }}>{info.icon}</span>{info.label}</span>;
}
function LoginGuide() {
return <div className="empty-state" style={{ maxWidth: 460, margin: '48px auto' }}>
<div className="empty-icon" aria-hidden="true">🎫</div>
<h1 style={{ fontSize: 22, margin: '0 0 8px' }}>登录后使用工单</h1>
<p className="text-muted">登录后可以提交论坛或站内问题并随时查看处理进度</p>
<Link to="/login.html" state={{ from: '/tickets.html' }} className="btn btn-filled" style={{ marginTop: 16 }}>去登录</Link>
</div>;
}
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 <LoginGuide />;
return <div style={{ maxWidth: 960, margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap', marginBottom: 20 }}>
<div><h1 className="page-title" style={{ margin: 0 }}>工单中心</h1><p className="text-muted" style={{ margin: '6px 0 0' }}>反馈论坛或站内问题查看处理进度</p></div>
<Link to="/tickets/new" className="btn btn-filled"><span className="material-icons" aria-hidden="true" style={{ fontSize: 18 }}>add</span>提交问题</Link>
</div>
<div className="card" style={{ padding: 12, marginBottom: 16 }}>
<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 }}>
<option value="">全部工单</option><option value="open">待处理</option><option value="processing">处理中</option><option value="waiting">等待用户</option><option value="resolved">已解决</option><option value="closed">已关闭</option>
</select>
</div>
{loading && <div className="loading" role="status" aria-label="正在加载工单"><div className="spinner" /></div>}
{error && <div className="empty-state" role="alert"><div className="empty-icon" aria-hidden="true"></div><p>{error}</p><button type="button" className="btn btn-tonal btn-sm" onClick={load}>重试</button></div>}
{!loading && !error && tickets.length === 0 && <div className="empty-state"><div className="empty-icon" aria-hidden="true">📭</div><p>还没有工单</p><p className="text-muted">如果你在论坛或站内遇到问题可以提交一条反馈</p><Link to="/tickets/new" className="btn btn-tonal btn-sm" style={{ marginTop: 12 }}>提交第一个问题</Link></div>}
{!loading && !error && tickets.length > 0 && <section aria-labelledby="recent-tickets-heading">
<h2 id="recent-tickets-heading" style={{ fontSize: 18, margin: '24px 0 12px' }}>最近创建的工单</h2>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: 12 }}>
{tickets.map((ticket) => <li key={ticket.id}><Link to={'/tickets/' + ticket.id} className="card" style={{ display: 'block', textDecoration: 'none', padding: 18 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}><div style={{ minWidth: 0 }}><div className="text-muted" style={{ fontSize: 13 }}>{ticket.ticket_no || `工单 #${ticket.id}`}</div><h3 style={{ margin: '5px 0 8px', overflowWrap: 'anywhere' }}>{ticket.subject || ticket.title || '未命名工单'}</h3></div><StatusChip status={ticket.status} /></div>
<div className="text-muted" style={{ fontSize: 13, display: 'flex', gap: 12, flexWrap: 'wrap' }}><span>{categoryLabel(ticket.category)}</span><span>创建于 {formatTime(ticket.created_at)}</span><span>更新于 {formatTime(ticket.updated_at)}</span></div>
</Link></li>)}
</ul>
</section>}
</div>;
}
+91
View File
@@ -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); }
+370
View File
@@ -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;
+2
View File
@@ -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();