修复工单系统竞态与接口安全问题
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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('工单创建成功但未返回编号');
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user