修复工单系统竞态与接口安全问题

This commit is contained in:
2026-09-05 16:14:43 +08:00
parent a992bd841d
commit af79e5864f
8 changed files with 521 additions and 99 deletions
+86 -23
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
@@ -17,6 +17,7 @@ import RefreshIcon from '@mui/icons-material/Refresh';
import SendIcon from '@mui/icons-material/Send';
import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { normalizeTicketListResponse } from '../../api/tickets.js';
import { request } from '../../api/client.js';
import { showSnack } from '../snack.jsx';
@@ -28,6 +29,13 @@ const STATUS = {
resolved: { label: '已解决', color: 'success' },
closed: { label: '已关闭', color: 'default' },
};
const STATUS_TRANSITIONS = {
open: ['processing', 'closed'],
processing: ['waiting', 'resolved', 'closed'],
waiting: ['processing', 'closed'],
resolved: ['closed', 'processing'],
closed: ['processing'],
};
const PRIORITY = {
low: { label: '低', color: 'default' },
normal: { label: '普通', color: 'info' },
@@ -80,32 +88,60 @@ export default function TicketManage() {
const [internal, setInternal] = useState('');
const [assignees, setAssignees] = useState([]);
const [saveFeedback, setSaveFeedback] = useState({ type: '', text: '' });
const listRequestRef = useRef(0);
const statsRequestRef = useRef(0);
const detailRequestRef = useRef(0);
const selectedIdRef = useRef(null);
const loadStats = useCallback(() => {
request('/tickets/admin/stats').then((data) => setStats(data || {})).catch(() => {});
const requestId = ++statsRequestRef.current;
request('/tickets/admin/stats').then((data) => {
if (requestId === statsRequestRef.current) setStats(data || {});
}).catch(() => {});
}, []);
const loadList = useCallback(() => {
const requestId = ++listRequestRef.current;
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);
if (requestId !== listRequestRef.current) return;
const normalized = normalizeTicketListResponse(data, PAGE_SIZE);
setList(normalized.tickets); setTotal(normalized.total);
})
.catch((e) => setError(e.message || '工单加载失败'))
.finally(() => setLoading(false));
}, [filters, page, selectedId]);
.catch((e) => {
if (requestId === listRequestRef.current) setError(e.message || '工单加载失败');
})
.finally(() => {
if (requestId === listRequestRef.current) setLoading(false);
});
}, [filters, page]);
const loadDetail = useCallback((id) => {
if (!id) return;
const changed = String(selectedIdRef.current) !== String(id);
selectedIdRef.current = id;
const requestId = ++detailRequestRef.current;
setSelectedId(id); setDetailLoading(true);
setDetail(null);
if (changed) {
setReply(''); setInternal('');
setSaveFeedback({ type: '', text: '' });
}
request(`/tickets/${encodeURIComponent(id)}`)
.then((data) => setDetail(data.ticket ? data : { ticket: data, messages: [], events: [] }))
.catch((e) => showSnack(e.message || '详情加载失败', 'error'))
.finally(() => setDetailLoading(false));
.then((data) => {
if (requestId === detailRequestRef.current && String(selectedIdRef.current) === String(id)) {
setDetail(data.ticket ? data : { ticket: data, messages: [], events: [] });
}
})
.catch((e) => {
if (requestId === detailRequestRef.current && String(selectedIdRef.current) === String(id)) showSnack(e.message || '详情加载失败', 'error');
})
.finally(() => {
if (requestId === detailRequestRef.current && String(selectedIdRef.current) === String(id)) setDetailLoading(false);
});
}, []);
useEffect(() => { loadList(); }, [loadList]);
@@ -135,33 +171,60 @@ export default function TicketManage() {
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) => {
const updateTicket = async (path, body, message, ticketId) => {
if (!ticketId || String(selectedIdRef.current) !== String(ticketId)) return;
setSaving(true);
setSaveFeedback({ type: 'saving', text: '正在保存…' });
try {
await request(path, { method: 'PUT', body });
if (String(selectedIdRef.current) !== String(ticketId)) return;
setSaveFeedback({ type: 'success', text: '已保存' });
showSnack(message); loadDetail(selectedId); loadList(); loadStats();
showSnack(message); loadDetail(ticketId); loadList(); loadStats();
} catch (e) {
setSaveFeedback({ type: 'error', text: e.message || '保存失败,请重试' });
showSnack(e.message || '保存失败', 'error');
if (String(selectedIdRef.current) === String(ticketId)) {
setSaveFeedback({ type: 'error', text: e.message || '保存失败,请重试' });
showSnack(e.message || '保存失败', 'error');
}
} finally { setSaving(false); }
};
const sendMessage = async (internalMessage) => {
const sendMessage = async (internalMessage, ticketId) => {
if (!ticketId || String(selectedIdRef.current) !== String(ticketId)) return;
const content = (internalMessage ? internal : reply).trim();
if (!content) return;
setSaving(true);
try {
await request(`/tickets/${selectedId}/${internalMessage ? 'internal-messages' : 'messages'}`, { method: 'POST', body: { content } });
await request(`/tickets/${ticketId}/${internalMessage ? 'internal-messages' : 'messages'}`, { method: 'POST', body: { content } });
if (String(selectedIdRef.current) !== String(ticketId)) return;
if (internalMessage) setInternal(''); else setReply('');
showSnack(internalMessage ? '内部备注已添加' : '公开回复已发送');
loadDetail(selectedId); loadList(); loadStats();
} catch (e) { showSnack(e.message || '发送失败', 'error'); }
loadDetail(ticketId); loadList(); loadStats();
} catch (e) {
if (String(selectedIdRef.current) === String(ticketId)) showSnack(e.message || '发送失败', 'error');
}
finally { setSaving(false); }
};
const messages = detail?.messages || [];
const events = detail?.events || [];
const timeline = useMemo(() => [
...messages.map((item) => ({ type: 'message', item })),
...events.map((item) => ({ type: 'event', item })),
].sort((a, b) => {
const timeA = Date.parse(String(a.item.created_at || '').replace(' ', 'T'));
const timeB = Date.parse(String(b.item.created_at || '').replace(' ', 'T'));
const timeDiff = (Number.isNaN(timeA) ? Number.MAX_SAFE_INTEGER : timeA)
- (Number.isNaN(timeB) ? Number.MAX_SAFE_INTEGER : timeB);
if (timeDiff) return timeDiff;
const createdDiff = String(a.item.created_at || '').localeCompare(String(b.item.created_at || ''));
if (createdDiff) return createdDiff;
const idA = Number(a.item.id);
const idB = Number(b.item.id);
if (Number.isFinite(idA) && Number.isFinite(idB) && idA !== idB) return idA - idB;
return a.type === b.type ? 0 : a.type === 'event' ? 1 : -1;
}), [messages, events]);
const currentStatus = selectedTicket?.status || 'open';
const statusOptions = [currentStatus, ...(STATUS_TRANSITIONS[currentStatus] || [])]
.filter((value, index, values) => STATUS[value] && values.indexOf(value) === index);
const statItems = useMemo(() => [
['全部', stats.total || total, null], ['待处理', stats.open || 0, 'warning'],
['处理中', stats.processing || 0, 'info'], ['等待用户', stats.waiting || 0, 'secondary'],
@@ -179,7 +242,7 @@ export default function TicketManage() {
{statItems.map(([label, value, tone]) => <StatCard key={label} label={label} value={value} tone={tone} />)}
</Stack>
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }} component="form" onSubmit={(e) => { e.preventDefault(); setPage(1); loadList(); }}>
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }} component="form" onSubmit={(e) => { e.preventDefault(); if (page === 1) loadList(); else setPage(1); }}>
<Box className="ticket-filter-grid">
<TextField className="ticket-filter-search" fullWidth size="small" label="搜索工单" placeholder="编号、标题或内容" value={filters.q} onChange={(e) => updateFilter('q', e.target.value)} />
<TextField fullWidth select size="small" label="状态" value={filters.status} onChange={(e) => updateFilter('status', e.target.value)}><MenuItem value="">全部状态</MenuItem>{Object.entries(STATUS).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField>
@@ -213,11 +276,11 @@ export default function TicketManage() {
{!selectedId ? <Box sx={{ py: 10, textAlign: 'center' }}><Typography variant="h6" color="text.secondary">选择一个工单开始处理</Typography><Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>工单详情回复和状态操作会显示在这里</Typography></Box> : detailLoading ? <Box role="status" aria-label="正在加载工单详情" sx={{ py: 10, textAlign: 'center' }}><CircularProgress /></Box> : selectedTicket ? (
<Stack spacing={2}>
<Box><Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap><Typography variant="h6" component="h2" sx={{ overflowWrap: 'anywhere' }}>{selectedTicket.subject || selectedTicket.title}</Typography><StatusChip value={selectedTicket.status} /><PriorityChip value={selectedTicket.priority} /></Stack><Typography variant="caption" color="text.secondary">{selectedTicket.ticket_no || `#${selectedTicket.id}`} · {selectedTicket.requester_name || selectedTicket.requester_username || '未知用户'} · 创建于 {fmtTime(selectedTicket.created_at)}</Typography></Box>
<Box><Grid container spacing={1.25}><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="状态" helperText="管理员可更新处理阶段" value={selectedTicket.status || 'open'} disabled={saving} onChange={(e) => updateTicket(`/tickets/${selectedId}/status`, { status: e.target.value }, '状态已更新')}>{Object.entries(STATUS).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField></Grid><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="优先级" helperText="用于安排处理顺序" value={selectedTicket.priority || 'normal'} disabled={saving} onChange={(e) => updateTicket(`/tickets/${selectedId}/priority`, { priority: e.target.value }, '优先级已更新')}>{Object.entries(PRIORITY).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField></Grid><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="负责人" helperText="可选择管理员或设为未分配" value={selectedTicket.assignee_id == null ? '' : String(selectedTicket.assignee_id)} disabled={saving} SelectProps={{ renderValue: (value) => { const person = normalizedAssignees.find((user) => user.id === String(value)); return <Box component="span" className="ticket-select-value" title={person ? person.displayName : '未分配'}>{person ? person.displayName : '未分配'}</Box>; } }} onChange={(e) => updateTicket(`/tickets/${selectedId}/assignee`, { assignee_id: e.target.value ? Number(e.target.value) || e.target.value : null }, '负责人已更新')}><MenuItem value="">未分配</MenuItem>{normalizedAssignees.map((user) => <MenuItem key={user.id} value={user.id} sx={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>{user.displayName}</MenuItem>)}</TextField></Grid></Grid>{saveFeedback.text && <Typography className={`ticket-save-feedback ticket-save-${saveFeedback.type}`} variant="caption" role={saveFeedback.type === 'error' ? 'alert' : 'status'} sx={{ display: 'block', mt: 0.75 }}>{saveFeedback.text}</Typography>}</Box>
<Box><Grid container spacing={1.25}><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="状态" helperText="管理员可更新处理阶段" value={selectedTicket.status || 'open'} disabled={saving} onChange={(e) => updateTicket(`/tickets/${selectedTicket.id}/status`, { status: e.target.value }, '状态已更新', selectedTicket.id)}>{statusOptions.map((key) => <MenuItem key={key} value={key}>{STATUS[key].label}</MenuItem>)}</TextField></Grid><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="优先级" helperText="用于安排处理顺序" value={selectedTicket.priority || 'normal'} disabled={saving} onChange={(e) => updateTicket(`/tickets/${selectedTicket.id}/priority`, { priority: e.target.value }, '优先级已更新', selectedTicket.id)}>{Object.entries(PRIORITY).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField></Grid><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="负责人" helperText="可选择管理员或设为未分配" value={selectedTicket.assignee_id == null ? '' : String(selectedTicket.assignee_id)} disabled={saving} SelectProps={{ renderValue: (value) => { const person = normalizedAssignees.find((user) => user.id === String(value)); return <Box component="span" className="ticket-select-value" title={person ? person.displayName : '未分配'}>{person ? person.displayName : '未分配'}</Box>; } }} onChange={(e) => updateTicket(`/tickets/${selectedTicket.id}/assignee`, { assignee_id: e.target.value ? Number(e.target.value) || e.target.value : null }, '负责人已更新', selectedTicket.id)}><MenuItem value="">未分配</MenuItem>{normalizedAssignees.map((user) => <MenuItem key={user.id} value={user.id} sx={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>{user.displayName}</MenuItem>)}</TextField></Grid></Grid>{saveFeedback.text && <Typography className={`ticket-save-feedback ticket-save-${saveFeedback.type}`} variant="caption" role={saveFeedback.type === 'error' ? 'alert' : 'status'} sx={{ display: 'block', mt: 0.75 }}>{saveFeedback.text}</Typography>}</Box>
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'action.hover' }}><Typography variant="subtitle2" gutterBottom>问题描述</Typography><Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{selectedTicket.description || '暂无描述'}</Typography>{selectedTicket.source_url && <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1, overflowWrap: 'anywhere' }}>来源{selectedTicket.source_url}</Typography>}</Paper>
<Box><Typography variant="subtitle2" sx={{ mb: 1 }}>处理记录</Typography><Stack component="ol" spacing={1.25} sx={{ m: 0, pl: 2.5 }}>{messages.map((message) => <Box component="li" key={`m-${message.id}`}><Paper variant="outlined" sx={{ p: 1.25 }}><Stack direction="row" spacing={1} alignItems="center"><Avatar sx={{ width: 26, height: 26, fontSize: 12 }}>{String(message.author_name || message.author_username || '管')[0]}</Avatar><Typography variant="body2" fontWeight={600}>{message.author_name || message.author_username || '管理员'}</Typography>{message.is_internal ? <Chip size="small" label="内部备注" color="warning" /> : <Chip size="small" label="公开回复" variant="outlined" />}<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>{fmtTime(message.created_at)}</Typography></Stack><Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', mt: 1 }}>{message.content}</Typography></Paper></Box>)}{events.map((event) => <Box component="li" key={`e-${event.id}`}><Typography variant="caption" color="text.secondary">{fmtTime(event.created_at)} · {event.detail || `${event.field_name || '工单'}已更新`}</Typography></Box>)}</Stack></Box>
<Box><Typography variant="subtitle2" sx={{ mb: 1 }}>处理记录</Typography><Stack component="ol" spacing={1.25} sx={{ m: 0, pl: 2.5 }}>{timeline.map((entry) => { if (entry.type === 'message') { const message = entry.item; return <Box component="li" key={`m-${message.id}`}><Paper variant="outlined" sx={{ p: 1.25 }}><Stack direction="row" spacing={1} alignItems="center"><Avatar sx={{ width: 26, height: 26, fontSize: 12 }}>{String(message.author_name || message.author_username || '管')[0]}</Avatar><Typography variant="body2" fontWeight={600}>{message.author_name || message.author_username || '管理员'}</Typography>{message.is_internal ? <Chip size="small" label="内部备注" color="warning" /> : <Chip size="small" label="公开回复" variant="outlined" />}<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>{fmtTime(message.created_at)}</Typography></Stack><Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', mt: 1 }}>{message.content}</Typography></Paper></Box>; } const event = entry.item; return <Box component="li" key={`e-${event.id}`}><Typography variant="caption" color="text.secondary">{fmtTime(event.created_at)} · {event.detail || `${event.field_name || '工单'}已更新`}</Typography></Box>; })}</Stack></Box>
<Divider />
<Grid container spacing={1.5}><Grid item xs={12} md={6}><TextField fullWidth multiline minRows={3} label="公开回复" placeholder="回复内容会发送给用户" value={reply} disabled={saving} onChange={(e) => setReply(e.target.value)} /><Button sx={{ mt: 1 }} variant="contained" startIcon={<SendIcon />} disabled={saving || !reply.trim()} onClick={() => sendMessage(false)}>发送公开回复</Button></Grid><Grid item xs={12} md={6}><TextField fullWidth multiline minRows={3} label="内部备注" placeholder="仅管理员可见" value={internal} disabled={saving} onChange={(e) => setInternal(e.target.value)} /><Button sx={{ mt: 1 }} variant="outlined" color="warning" disabled={saving || !internal.trim()} onClick={() => sendMessage(true)}>添加内部备注</Button></Grid></Grid>
<Grid container spacing={1.5}><Grid item xs={12} md={6}><TextField fullWidth multiline minRows={3} label="公开回复" placeholder="回复内容会发送给用户" value={reply} disabled={saving} onChange={(e) => setReply(e.target.value)} /><Button sx={{ mt: 1 }} variant="contained" startIcon={<SendIcon />} disabled={saving || !reply.trim()} onClick={() => sendMessage(false, selectedTicket.id)}>发送公开回复</Button></Grid><Grid item xs={12} md={6}><TextField fullWidth multiline minRows={3} label="内部备注" placeholder="仅管理员可见" value={internal} disabled={saving} onChange={(e) => setInternal(e.target.value)} /><Button sx={{ mt: 1 }} variant="outlined" color="warning" disabled={saving || !internal.trim()} onClick={() => sendMessage(true, selectedTicket.id)}>添加内部备注</Button></Grid></Grid>
</Stack>
) : <Alert severity="error">工单详情不存在或加载失败</Alert>}
</Paper>
+16
View File
@@ -1,5 +1,21 @@
import { request } from './client.js';
export function normalizeTicketListResponse(data, fallbackPageSize = 20) {
const payload = Array.isArray(data) ? { tickets: data } : (data && typeof data === 'object' ? data : {});
const tickets = Array.isArray(payload.tickets)
? payload.tickets
: (Array.isArray(payload.list) ? payload.list : (Array.isArray(payload.items) ? payload.items : []));
const pageSizeValue = Number(payload.pageSize ?? payload.page_size);
const pageSize = Number.isInteger(pageSizeValue) && pageSizeValue > 0 ? pageSizeValue : fallbackPageSize;
const totalValue = Number(payload.total ?? payload.count);
const total = Number.isFinite(totalValue) && totalValue >= 0 ? totalValue : tickets.length;
const totalPagesValue = Number(payload.totalPages ?? payload.total_pages ?? payload.pages);
const totalPages = Number.isInteger(totalPagesValue) && totalPagesValue > 0
? totalPagesValue
: Math.max(1, Math.ceil(total / pageSize));
return { tickets, total, totalPages };
}
export function listTickets(params = {}) {
const query = new URLSearchParams();
if (params.page) query.set('page', String(params.page));
+9 -2
View File
@@ -5,7 +5,7 @@ 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', '紧急']];
const priorities = [['low', ''], ['normal', '普通'], ['high', ''], ['urgent', '紧急']];
export default function TicketCreate() {
const navigate = useNavigate();
@@ -26,12 +26,19 @@ export default function TicketCreate() {
if (description.length < 10) { setError('请详细描述问题(至少 10 个字)'); return; }
setError(''); setBusy(true);
try {
const sourceValue = String(form.source_url ?? '');
const sourceUrl = safeSourceUrl(sourceValue);
const hasInvalidSource = sourceValue.trim() || /[\u0000-\u001f\u007f-\u009f]/.test(sourceValue) || sourceValue.includes('\\');
if (hasInvalidSource && !sourceUrl) {
setError('来源地址不合法,请填写站内路径或 http/https 地址');
return;
}
const data = await ticketsApi.createTicket({
...form,
subject,
description,
source: form.category === 'forum_bug' ? 'forum' : 'site',
source_url: safeSourceUrl(form.source_url),
source_url: sourceUrl,
browser_info: [navigator.userAgent, `${window.innerWidth}x${window.innerHeight}`].join(' | ').slice(0, 1000),
});
if (!data.ticket || !data.ticket.id) throw new Error('工单创建成功但未返回编号');
+47 -7
View File
@@ -1,9 +1,9 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import * 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';
import { categoryLabel, formatTime, priorityLabel, 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']);
@@ -43,7 +43,22 @@ export default function TicketDetail() {
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]);
const detailRequestRef = useRef(0);
const activeIdRef = useRef(id);
activeIdRef.current = id;
const load = useCallback(async () => {
if (String(activeIdRef.current) !== String(id)) return;
const requestId = ++detailRequestRef.current;
setLoading(true); setError(''); setData(null);
try {
const nextData = await ticketsApi.getTicket(id);
if (requestId === detailRequestRef.current) setData(nextData);
} catch (e) {
if (requestId === detailRequestRef.current) setError(e.message || '工单加载失败');
} finally {
if (requestId === detailRequestRef.current) setLoading(false);
}
}, [id]);
useEffect(() => { if (!getToken()) { setLoading(false); return; } me().then(setUser).catch(() => { setUser(null); setLoading(false); }); }, []);
useEffect(() => { if (user) load(); }, [user, load]);
@@ -53,15 +68,40 @@ export default function TicketDetail() {
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); } };
const sourceUrl = safeSourceUrl(ticket.source_url); const canReply = ticket.status !== 'closed';
const runAction = async (action, confirmation) => {
if (!window.confirm(confirmation)) return;
const actionId = id;
setBusy(true); setError('');
try {
await action(actionId);
if (String(activeIdRef.current) === String(actionId)) await load();
} catch (e) {
if (String(activeIdRef.current) === String(actionId)) setError(e.message || '操作失败');
} finally { setBusy(false); }
};
const sendReply = async (e) => {
e.preventDefault();
const content = reply.trim();
if (!content) { setError('回复内容不能为空'); return; }
const actionId = id;
setBusy(true); setError('');
try {
await ticketsApi.addMessage(actionId, content);
if (String(activeIdRef.current) === String(actionId)) {
setReply('');
await load();
}
} catch (e) {
if (String(activeIdRef.current) === String(actionId)) setError(e.message || '回复失败');
} finally { setBusy(false); }
};
return <div className="ticket-detail-page">
<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-header"><div className="ticket-detail-kicker">{ticket.ticket_no || `工单 #${ticket.id}`}</div><h1 className="ticket-detail-title">{ticket.subject || ticket.title || '未命名工单'}</h1><div className="ticket-detail-tags"><StatusChip status={ticket.status} /><span className="chip chip-static">{categoryLabel(ticket.category)}</span><span className="chip chip-static">{priorityLabel(ticket.priority)}</span></div><TicketProgress status={ticket.status} /></article>
<article className="card ticket-detail-description"><h2>问题描述</h2><p>{ticket.description}</p>{sourceUrl && <p className="ticket-detail-source"><span className="text-muted">发现页面</span><a href={sourceUrl} target="_blank" rel="noopener noreferrer">{sourceUrl}</a></p>}</article>
<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>}
+37 -11
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import * as ticketsApi from '../api/tickets.js';
import { me } from '../api/auth.js';
@@ -15,13 +15,19 @@ const CATEGORY = { forum_bug: '论坛 Bug', site_bug: '站内 Bug', feature: '
export function statusInfo(status) { return STATUS[status] || { label: status || '未知状态', icon: 'help', tone: 'neutral' }; }
export function categoryLabel(category) { return CATEGORY[category] || category || '其他问题'; }
export function priorityLabel(priority) {
return { low: '低', normal: '普通', high: '高', urgent: '紧急' }[priority] || '普通';
}
/** 工单来源只允许 http(s) 或本站相对路径,避免把用户可控值直接作为危险链接。 */
export function safeSourceUrl(value) {
const raw = String(value || '').trim();
if (!raw || raw.length > 1000) return '';
if (raw.startsWith('/') && !raw.startsWith('//')) return raw;
const valueText = String(value ?? '');
if (!valueText || valueText.length > 1000) return '';
if (/[\u0000-\u001f\u007f-\u009f]/.test(valueText) || valueText.includes('\\')) return '';
const raw = valueText.trim();
if (!raw || raw.startsWith('//')) return '';
if (raw.startsWith('/')) return raw;
try {
const parsed = new URL(raw, window.location.origin);
const parsed = new URL(raw);
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : '';
} catch { return ''; }
}
@@ -51,15 +57,28 @@ export default function Tickets() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [status, setStatus] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const listRequestRef = useRef(0);
const load = useCallback(async () => {
const requestId = ++listRequestRef.current;
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]);
const data = await ticketsApi.listTickets({ page, pageSize: 20, status });
if (requestId !== listRequestRef.current) return;
const normalized = ticketsApi.normalizeTicketListResponse(data, 20);
setTickets(normalized.tickets);
setTotal(normalized.total);
setTotalPages(normalized.totalPages);
} catch (e) {
if (requestId !== listRequestRef.current) return;
setError(e.message || '工单加载失败');
} finally {
if (requestId === listRequestRef.current) setLoading(false);
}
}, [page, status]);
useEffect(() => {
if (!getToken()) { setLoading(false); return; }
@@ -67,6 +86,8 @@ export default function Tickets() {
}, []);
useEffect(() => { if (user) load(); }, [user, load]);
const updateStatus = (value) => { setStatus(value); setPage(1); };
if (!getToken() || (!user && !loading)) return <LoginGuide />;
return <div style={{ maxWidth: 960, margin: '0 auto' }}>
@@ -76,7 +97,7 @@ export default function Tickets() {
</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 }}>
<select id="ticket-status-filter" value={status} onChange={(e) => updateStatus(e.target.value)} style={{ minHeight: 40, padding: '0 10px', borderRadius: 8 }}>
<option value="">全部工单</option><option value="open">待处理</option><option value="processing">处理中</option><option value="waiting">等待用户</option><option value="resolved">已解决</option><option value="closed">已关闭</option>
</select>
</div>
@@ -92,5 +113,10 @@ export default function Tickets() {
</Link></li>)}
</ul>
</section>}
{!loading && !error && totalPages > 1 && <nav className="ticket-pagination" aria-label="工单分页" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 12, marginTop: 20 }}>
<button type="button" className="btn btn-text btn-sm" disabled={page <= 1} onClick={() => setPage((current) => current - 1)}>上一页</button>
<span className="text-muted" aria-live="polite"> {page} / {totalPages} {total} </span>
<button type="button" className="btn btn-text btn-sm" disabled={page >= totalPages} onClick={() => setPage((current) => current + 1)}>下一页</button>
</nav>}
</div>;
}