diff --git a/frontend/src/admin/pages/TicketManage.jsx b/frontend/src/admin/pages/TicketManage.jsx index 380aa45..7196c37 100644 --- a/frontend/src/admin/pages/TicketManage.jsx +++ b/frontend/src/admin/pages/TicketManage.jsx @@ -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() { { e.preventDefault(); setPage(1); loadList(); }}> - - updateFilter('q', e.target.value)} /> - updateFilter('status', e.target.value)}>全部状态{Object.entries(STATUS).map(([key, item]) => {item.label})} - updateFilter('priority', e.target.value)}>全部优先级{Object.entries(PRIORITY).map(([key, item]) => {item.label})} - updateFilter('category', e.target.value)}>全部类型{Object.entries(CATEGORY).map(([key, label]) => {label})} - - + + updateFilter('q', e.target.value)} /> + updateFilter('status', e.target.value)}>全部状态{Object.entries(STATUS).map(([key, item]) => {item.label})} + updateFilter('priority', e.target.value)}>全部优先级{Object.entries(PRIORITY).map(([key, item]) => {item.label})} + updateFilter('category', e.target.value)}>全部类型{Object.entries(CATEGORY).map(([key, label]) => {label})} + + {error && 重试} sx={{ mb: 2 }}>{error}} - + 最近工单共 {total} 条 {loading ? : list.length === 0 ? 暂无匹配工单 : ( - - {list.map((ticket) => )} + + {list.map((ticket) => )} )} setPage(page - 1)}>第 {page} / {totalPages} 页= totalPages || loading} onClick={() => setPage(page + 1)}> @@ -182,11 +205,11 @@ export default function TicketManage() { - + {!selectedId ? 选择一个工单开始处理工单详情、回复和状态操作会显示在这里 : detailLoading ? : selectedTicket ? ( {selectedTicket.subject || selectedTicket.title}{selectedTicket.ticket_no || `#${selectedTicket.id}`} · {selectedTicket.requester_name || selectedTicket.requester_username || '未知用户'} · 创建于 {fmtTime(selectedTicket.created_at)} - updateTicket(`/tickets/${selectedId}/status`, { status: e.target.value }, '状态已更新')}>{Object.entries(STATUS).map(([key, item]) => {item.label})} updateTicket(`/tickets/${selectedId}/priority`, { priority: e.target.value }, '优先级已更新')}>{Object.entries(PRIORITY).map(([key, item]) => {item.label})} updateTicket(`/tickets/${selectedId}/assignee`, { assignee_id: e.target.value || null }, '负责人已更新')}>未分配{assignees.filter((user) => user.role === 'admin' || user.role === undefined).map((user) => {user.nickname || user.username})} + updateTicket(`/tickets/${selectedId}/status`, { status: e.target.value }, '状态已更新')}>{Object.entries(STATUS).map(([key, item]) => {item.label})} updateTicket(`/tickets/${selectedId}/priority`, { priority: e.target.value }, '优先级已更新')}>{Object.entries(PRIORITY).map(([key, item]) => {item.label})} { const person = normalizedAssignees.find((user) => user.id === String(value)); return {person ? person.displayName : '未分配'}; } }} onChange={(e) => updateTicket(`/tickets/${selectedId}/assignee`, { assignee_id: e.target.value ? Number(e.target.value) || e.target.value : null }, '负责人已更新')}>未分配{normalizedAssignees.map((user) => {user.displayName})}{saveFeedback.text && {saveFeedback.text}} 问题描述{selectedTicket.description || '暂无描述'}{selectedTicket.source_url && 来源:{selectedTicket.source_url}} 处理记录{messages.map((message) => {String(message.author_name || message.author_username || '管')[0]}{message.author_name || message.author_username || '管理员'}{message.is_internal ? : }{fmtTime(message.created_at)}{message.content})}{events.map((event) => {fmtTime(event.created_at)} · {event.detail || `${event.field_name || '工单'}已更新`})} diff --git a/frontend/src/admin/theme.css b/frontend/src/admin/theme.css index 686c98b..995b4ca 100644 --- a/frontend/src/admin/theme.css +++ b/frontend/src/admin/theme.css @@ -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); + } } /* 减弱动效偏好 */