feat: 新增工单反馈系统
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user