修复后台工单处理布局与控件
This commit is contained in:
@@ -79,6 +79,7 @@ export default function TicketManage() {
|
||||
const [reply, setReply] = useState('');
|
||||
const [internal, setInternal] = useState('');
|
||||
const [assignees, setAssignees] = useState([]);
|
||||
const [saveFeedback, setSaveFeedback] = useState({ type: '', text: '' });
|
||||
|
||||
const loadStats = useCallback(() => {
|
||||
request('/tickets/admin/stats').then((data) => setStats(data || {})).catch(() => {});
|
||||
@@ -110,19 +111,41 @@ export default function TicketManage() {
|
||||
useEffect(() => { loadList(); }, [loadList]);
|
||||
useEffect(() => { loadStats(); }, [loadStats]);
|
||||
useEffect(() => {
|
||||
request('/tickets/admin/assignees').then((data) => setAssignees(Array.isArray(data) ? data : (data.users || data.list || []))).catch(() => {});
|
||||
request('/tickets/admin/assignees').then((data) => setAssignees(Array.isArray(data) ? data : (data.users || data.list || []))).catch((e) => setSaveFeedback({ type: 'error', text: e.message || '负责人列表加载失败' }));
|
||||
}, []);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const selectedTicket = detail && detail.ticket;
|
||||
const normalizedAssignees = useMemo(() => {
|
||||
const values = assignees
|
||||
.filter((user) => user && (user.role === 'admin' || user.role === undefined))
|
||||
.map((user) => ({
|
||||
...user,
|
||||
id: String(user.id),
|
||||
displayName: user.nickname || user.username || user.name || `管理员 #${user.id}`,
|
||||
}));
|
||||
if (selectedTicket?.assignee_id != null && !values.some((user) => user.id === String(selectedTicket.assignee_id))) {
|
||||
values.push({
|
||||
id: String(selectedTicket.assignee_id),
|
||||
displayName: selectedTicket.assignee_name || selectedTicket.assignee_username || `负责人 #${selectedTicket.assignee_id}`,
|
||||
});
|
||||
}
|
||||
return values;
|
||||
}, [assignees, selectedTicket]);
|
||||
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); }
|
||||
setSaveFeedback({ type: 'saving', text: '正在保存…' });
|
||||
try {
|
||||
await request(path, { method: 'PUT', body });
|
||||
setSaveFeedback({ type: 'success', text: '已保存' });
|
||||
showSnack(message); loadDetail(selectedId); loadList(); loadStats();
|
||||
} catch (e) {
|
||||
setSaveFeedback({ type: 'error', text: e.message || '保存失败,请重试' });
|
||||
showSnack(e.message || '保存失败', 'error');
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
const sendMessage = async (internalMessage) => {
|
||||
const content = (internalMessage ? internal : reply).trim();
|
||||
@@ -157,24 +180,24 @@ export default function TicketManage() {
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<Button fullWidth type="submit" variant="contained" sx={{ minHeight: 40 }}>搜索</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{error && <Alert severity="error" role="alert" action={<Button color="inherit" size="small" onClick={loadList}>重试</Button>} sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<Box className="ticket-work-area">
|
||||
<Box className="ticket-list-pane">
|
||||
<Paper variant="outlined" sx={{ height: '100%', overflow: 'hidden' }}>
|
||||
<Paper className="ticket-list-card" 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 component="ul" className="ticket-list-scroll" 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 className="ticket-list-meta" direction="row" spacing={1} alignItems="center" useFlexGap sx={{ mb: 0.5 }}><Typography className="ticket-list-number" 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>
|
||||
@@ -182,11 +205,11 @@ export default function TicketManage() {
|
||||
</Box>
|
||||
|
||||
<Box className="ticket-detail-pane">
|
||||
<Paper variant="outlined" sx={{ p: { xs: 2, md: 2.5 }, minHeight: 420 }}>
|
||||
<Paper className="ticket-detail-card" 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>
|
||||
<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>
|
||||
<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 />
|
||||
|
||||
@@ -50,7 +50,7 @@ body {
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: calc(100vh - 280px);
|
||||
height: max(520px, calc(100vh - 280px));
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@@ -68,11 +68,116 @@ body {
|
||||
}
|
||||
|
||||
.ticket-list-pane > .MuiPaper-root {
|
||||
min-height: calc(100vh - 280px);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ticket-detail-pane > .MuiPaper-root {
|
||||
min-height: calc(100vh - 280px);
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ticket-list-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ticket-list-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.ticket-list-scroll > li,
|
||||
.ticket-list-scroll > li > .MuiButton-root {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ticket-list-scroll > li > .MuiButton-root > .MuiBox-root {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ticket-list-scroll .MuiTypography-root {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ticket-list-scroll .MuiTypography-noWrap {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
white-space: normal;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ticket-list-scroll .MuiChip-root {
|
||||
flex: 0 0 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ticket-list-meta {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ticket-list-number {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ticket-detail-card {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ticket-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 2fr) repeat(3, minmax(130px, 1fr)) minmax(96px, 0.85fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ticket-filter-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ticket-filter-grid .MuiInputBase-root,
|
||||
.ticket-filter-grid .MuiButton-root {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ticket-select-value {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ticket-save-feedback {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ticket-save-saving { color: var(--mui-palette-text-secondary, inherit); }
|
||||
.ticket-save-success { color: #2e7d32; }
|
||||
.ticket-save-error { color: #d32f2f; }
|
||||
|
||||
[data-theme="dark"] .ticket-save-success { color: #81c784; }
|
||||
[data-theme="dark"] .ticket-save-error { color: #ef9a9a; }
|
||||
|
||||
@media (max-width: 1199px) and (min-width: 700px) {
|
||||
.ticket-filter-grid {
|
||||
grid-template-columns: minmax(220px, 1.6fr) repeat(3, minmax(118px, 1fr));
|
||||
}
|
||||
|
||||
.ticket-filter-grid .ticket-filter-search {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ticket-filter-grid > .MuiButton-root {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) and (min-width: 700px) {
|
||||
@@ -89,7 +194,7 @@ body {
|
||||
|
||||
.ticket-work-area {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -98,9 +203,33 @@ body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ticket-list-card {
|
||||
max-height: 480px;
|
||||
}
|
||||
|
||||
.ticket-list-scroll {
|
||||
flex: 0 1 auto;
|
||||
max-height: 350px;
|
||||
}
|
||||
|
||||
.ticket-detail-pane > .MuiPaper-root {
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.ticket-filter-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.ticket-filter-grid .ticket-filter-search,
|
||||
.ticket-filter-grid > .MuiButton-root {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 459px) {
|
||||
.ticket-filter-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* 减弱动效偏好 */
|
||||
|
||||
Reference in New Issue
Block a user