feat: 新增工单反馈系统
This commit is contained in:
@@ -255,6 +255,63 @@ function migrateSchema() {
|
|||||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN trusted INTEGER DEFAULT 1"); } catch {}
|
try { db.exec("ALTER TABLE admin_links ADD COLUMN trusted INTEGER DEFAULT 1"); } catch {}
|
||||||
try { db.exec("ALTER TABLE admin_links ADD COLUMN slug TEXT DEFAULT ''"); } catch {}
|
try { db.exec("ALTER TABLE admin_links ADD COLUMN slug TEXT DEFAULT ''"); } catch {}
|
||||||
} },
|
} },
|
||||||
|
// v16: 站内工单——用户反馈、公开回复、内部备注及业务事件
|
||||||
|
{ version: 16, up: () => {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS tickets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ticket_no TEXT NOT NULL UNIQUE,
|
||||||
|
requester_id INTEGER,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL DEFAULT 'other',
|
||||||
|
priority TEXT NOT NULL DEFAULT 'normal',
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
source TEXT NOT NULL DEFAULT 'site',
|
||||||
|
source_url TEXT DEFAULT '',
|
||||||
|
source_type TEXT DEFAULT '',
|
||||||
|
source_id INTEGER DEFAULT 0,
|
||||||
|
browser_info TEXT DEFAULT '',
|
||||||
|
assignee_id INTEGER,
|
||||||
|
created_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
updated_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
last_reply_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
first_response_at DATETIME,
|
||||||
|
resolved_at DATETIME,
|
||||||
|
closed_at DATETIME,
|
||||||
|
FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS ticket_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ticket_id INTEGER NOT NULL,
|
||||||
|
author_id INTEGER,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
is_internal INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
updated_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)`);
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS ticket_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ticket_id INTEGER NOT NULL,
|
||||||
|
actor_id INTEGER,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
field_name TEXT DEFAULT '',
|
||||||
|
old_value TEXT DEFAULT '',
|
||||||
|
new_value TEXT DEFAULT '',
|
||||||
|
detail TEXT DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)`);
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_requester ON tickets(requester_id, updated_at DESC)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status_updated ON tickets(status, updated_at DESC)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_assignee ON tickets(assignee_id, status, updated_at DESC)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category, updated_at DESC)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id, created_at ASC)');
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket ON ticket_events(ticket_id, created_at ASC)');
|
||||||
|
} },
|
||||||
];
|
];
|
||||||
for (const m of migrations) {
|
for (const m of migrations) {
|
||||||
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
|
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import Profile from './pages/Profile.jsx';
|
|||||||
import Write from './pages/Write.jsx';
|
import Write from './pages/Write.jsx';
|
||||||
import Embed from './pages/Embed.jsx';
|
import Embed from './pages/Embed.jsx';
|
||||||
import Setup from './pages/Setup.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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -35,6 +38,9 @@ export default function App() {
|
|||||||
<Route path="/forum.html" element={<Forum />} />
|
<Route path="/forum.html" element={<Forum />} />
|
||||||
<Route path="/forum/c/:id" element={<ForumCategory />} />
|
<Route path="/forum/c/:id" element={<ForumCategory />} />
|
||||||
<Route path="/forum/:id" element={<ForumDetail />} />
|
<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" element={<ForumManagePanel />} />
|
||||||
<Route path="/forum/manage/:id" element={<ForumManageCategory />} />
|
<Route path="/forum/manage/:id" element={<ForumManageCategory />} />
|
||||||
<Route path="/u/:id" element={<UserProfile />} />
|
<Route path="/u/:id" element={<UserProfile />} />
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import Announcements from './pages/Announcements.jsx';
|
|||||||
import Links from './pages/Links.jsx';
|
import Links from './pages/Links.jsx';
|
||||||
import Uploads from './pages/Uploads.jsx';
|
import Uploads from './pages/Uploads.jsx';
|
||||||
import ImportDb from './pages/ImportDb.jsx';
|
import ImportDb from './pages/ImportDb.jsx';
|
||||||
|
import TicketManage from './pages/TicketManage.jsx';
|
||||||
// 工作台独立分包:仅访问 /admin/workbench 时才加载(vite 自动 code-split)
|
// 工作台独立分包:仅访问 /admin/workbench 时才加载(vite 自动 code-split)
|
||||||
const Workbench = lazy(() => import('../tools/workbench/Workbench.jsx'));
|
const Workbench = lazy(() => import('../tools/workbench/Workbench.jsx'));
|
||||||
|
|
||||||
@@ -113,6 +114,7 @@ function AdminApp() {
|
|||||||
<Route path="/links" element={<Links />} />
|
<Route path="/links" element={<Links />} />
|
||||||
<Route path="/uploads" element={<Uploads />} />
|
<Route path="/uploads" element={<Uploads />} />
|
||||||
<Route path="/import" element={<ImportDb />} />
|
<Route path="/import" element={<ImportDb />} />
|
||||||
|
<Route path="/tickets" element={<TicketManage />} />
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import LinkIcon from '@mui/icons-material/Link';
|
|||||||
import RssFeedIcon from '@mui/icons-material/RssFeed';
|
import RssFeedIcon from '@mui/icons-material/RssFeed';
|
||||||
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
import AttachFileIcon from '@mui/icons-material/AttachFile';
|
||||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
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 },
|
{ path: '/posts', label: '帖子管理', icon: ListAltIcon },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'support',
|
||||||
|
label: '反馈处理',
|
||||||
|
items: [
|
||||||
|
{ path: '/tickets', label: '工单管理', icon: SupportAgentIcon },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'users',
|
id: 'users',
|
||||||
label: '用户',
|
label: '用户',
|
||||||
@@ -116,6 +124,7 @@ export const NAV_SECTIONS = [
|
|||||||
|
|
||||||
/** 别名映射:把常见叫法指到已有菜单项或设置区块(path + 可选 anchor) */
|
/** 别名映射:把常见叫法指到已有菜单项或设置区块(path + 可选 anchor) */
|
||||||
export const NAV_ALIASES = [
|
export const NAV_ALIASES = [
|
||||||
|
{ keywords: ['工单', '反馈', 'bug', '问题', 'ticket'], path: '/tickets' },
|
||||||
{ keywords: ['邮件', 'email', 'smtp'], path: '/email' },
|
{ keywords: ['邮件', 'email', 'smtp'], path: '/email' },
|
||||||
{ keywords: ['验证码', 'captcha', 'reCAPTCHA', 'turnstile'], path: '/captcha' },
|
{ keywords: ['验证码', 'captcha', 'reCAPTCHA', 'turnstile'], path: '/captcha' },
|
||||||
{ keywords: ['rss', '订阅', 'feed'], path: '/rss' },
|
{ 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: '/', label: '首页', end: true, show: true },
|
||||||
{ to: '/blog.html', label: '博客', show: true },
|
{ to: '/blog.html', label: '博客', show: true },
|
||||||
{ to: '/forum.html', label: '论坛', show: !!user },
|
{ to: '/forum.html', label: '论坛', show: !!user },
|
||||||
|
{ to: '/tickets.html', label: '工单', show: true },
|
||||||
{ to: '/admin', label: '管理后台', show: isAdmin, fullPage: true },
|
{ to: '/admin', label: '管理后台', show: isAdmin, fullPage: true },
|
||||||
{ to: '/passwords.html', label: '密码箱', show: isAdmin },
|
{ 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>;
|
||||||
|
}
|
||||||
@@ -593,6 +593,97 @@ table tr:hover td {
|
|||||||
padding: 1px 8px;
|
padding: 1px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 工单状态:独立命名空间,兼容明暗主题。 */
|
||||||
|
.ticket-status.ticket-status--warning { background: #fff1d6; color: #6b4300; border-color: transparent; }
|
||||||
|
.ticket-status.ticket-status--primary { background: var(--md-ref-primary-container); color: var(--md-ref-on-primary-container); border-color: transparent; }
|
||||||
|
.ticket-status.ticket-status--secondary { background: var(--md-ref-secondary-container); color: var(--md-ref-on-secondary-container); border-color: transparent; }
|
||||||
|
.ticket-status.ticket-status--success { background: #d9f2df; color: #1b5e20; border-color: transparent; }
|
||||||
|
.ticket-status.ticket-status--neutral { background: transparent; color: var(--md-ref-on-surface-variant); border-color: var(--md-ref-outline-variant); }
|
||||||
|
[data-theme="dark"] .ticket-status.ticket-status--warning { background: #5a420d; color: #ffdda0; }
|
||||||
|
[data-theme="dark"] .ticket-status.ticket-status--success { background: #214d29; color: #b8efbd; }
|
||||||
|
|
||||||
|
.ticket-detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(220px, 280px); gap: 20px; }
|
||||||
|
.ticket-detail-main { min-width: 0; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.ticket-detail-layout { display: block; }
|
||||||
|
.ticket-detail-layout > aside { margin-top: 20px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工单详情页:紧凑的主内容与窄信息栏,避免复用通用页面间距造成空洞。 */
|
||||||
|
.ticket-detail-page { max-width: 1080px; margin: 0 auto; }
|
||||||
|
.ticket-detail-back { margin-bottom: 8px; }
|
||||||
|
.ticket-detail-layout { align-items: start; }
|
||||||
|
.ticket-detail-header, .ticket-detail-description, .ticket-detail-message, .ticket-detail-reply, .ticket-detail-sidebar { padding: 20px; }
|
||||||
|
.ticket-detail-header { margin-bottom: 14px; }
|
||||||
|
.ticket-detail-kicker { color: var(--md-ref-on-surface-variant); font-size: 13px; letter-spacing: .04em; }
|
||||||
|
.ticket-detail-title { margin: 5px 0 10px; font-size: clamp(22px, 3vw, 30px); line-height: 1.25; overflow-wrap: anywhere; }
|
||||||
|
.ticket-detail-tags { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
||||||
|
.ticket-detail-tags .ticket-status { font-weight: 600; }
|
||||||
|
.ticket-detail-progress { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0; list-style: none; padding: 0; margin: 22px 0 0; }
|
||||||
|
.ticket-detail-progress-item { position: relative; min-width: 0; text-align: center; color: var(--md-ref-on-surface-variant); }
|
||||||
|
.ticket-detail-progress-line { position: absolute; top: 13px; left: 0; right: 0; height: 2px; background: var(--md-ref-outline-variant); z-index: 0; }
|
||||||
|
.ticket-detail-progress-item:first-child .ticket-detail-progress-line { left: 50%; }
|
||||||
|
.ticket-detail-progress-item:last-child .ticket-detail-progress-line { right: 50%; }
|
||||||
|
.ticket-detail-progress-dot { position: relative; z-index: 1; display: inline-flex; width: 27px; height: 27px; align-items: center; justify-content: center; border: 2px solid var(--md-ref-outline-variant); border-radius: 50%; background: var(--md-ref-surface); font-size: 12px; }
|
||||||
|
.ticket-detail-progress-label { display: block; margin-top: 6px; font-size: 12px; white-space: nowrap; }
|
||||||
|
.ticket-detail-progress-item.is-done .ticket-detail-progress-line, .ticket-detail-progress-item.is-current .ticket-detail-progress-line { background: var(--md-ref-primary); }
|
||||||
|
.ticket-detail-progress-item.is-done .ticket-detail-progress-dot { border-color: var(--md-ref-primary); background: var(--md-ref-primary); color: var(--md-ref-on-primary); }
|
||||||
|
.ticket-detail-progress-item.is-current { color: var(--md-ref-on-surface); font-weight: 700; }
|
||||||
|
.ticket-detail-progress-item.is-current .ticket-detail-progress-dot { width: 33px; height: 33px; margin-top: -3px; border-color: var(--md-ref-primary); background: var(--md-ref-primary-container); color: var(--md-ref-on-primary-container); box-shadow: 0 0 0 4px color-mix(in srgb, var(--md-ref-primary) 18%, transparent); }
|
||||||
|
.ticket-detail-description { margin-bottom: 20px; }
|
||||||
|
.ticket-detail-description h2, .ticket-detail-events h2 { margin-bottom: 9px; font-size: 17px; }
|
||||||
|
.ticket-detail-description p { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.7; }
|
||||||
|
.ticket-detail-source { display: flex; gap: 9px; align-items: baseline; margin-top: 15px !important; font-size: 13px; }
|
||||||
|
.ticket-detail-source a { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.ticket-detail-section-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 9px; }
|
||||||
|
.ticket-detail-section-heading h2 { margin: 0; font-size: 19px; }
|
||||||
|
.ticket-detail-section-heading span { font-size: 13px; }
|
||||||
|
.ticket-detail-messages { display: grid; gap: 9px; list-style: none; padding: 0; margin: 0; }
|
||||||
|
.ticket-detail-message { padding: 15px 17px; }
|
||||||
|
.ticket-detail-message-meta { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; font-size: 13px; }
|
||||||
|
.ticket-detail-message p { margin: 8px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.65; }
|
||||||
|
.ticket-detail-empty { display: flex; align-items: center; gap: 9px; padding: 13px 16px; color: var(--md-ref-on-surface-variant); }
|
||||||
|
.ticket-detail-empty .material-icons { font-size: 19px; }
|
||||||
|
.ticket-detail-empty p { margin: 0; font-size: 14px; }
|
||||||
|
.ticket-detail-events { margin: 17px 0 0; padding: 14px 17px; border: 1px solid var(--md-ref-outline-variant); border-radius: 12px; }
|
||||||
|
.ticket-detail-events ol { list-style: none; padding: 0; margin: 0; }
|
||||||
|
.ticket-detail-events li { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; border-bottom: 1px solid var(--md-ref-outline-variant); font-size: 13px; }
|
||||||
|
.ticket-detail-events li:last-child { border-bottom: 0; }
|
||||||
|
.ticket-detail-reply { margin-top: 14px; }
|
||||||
|
.ticket-detail-reply label { display: block; margin-bottom: 7px; font-weight: 600; }
|
||||||
|
.ticket-detail-reply textarea { display: block; width: 100%; min-height: 120px; }
|
||||||
|
.ticket-detail-reply-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 10px; }
|
||||||
|
.ticket-detail-reply-footer p { margin: 0; font-size: 13px; }
|
||||||
|
.ticket-detail-sidebar { position: sticky; top: 76px; }
|
||||||
|
.ticket-detail-sidebar-status { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-bottom: 15px; border-bottom: 1px solid var(--md-ref-outline-variant); font-size: 13px; }
|
||||||
|
.ticket-detail-sidebar dl { display: grid; gap: 13px; margin: 16px 0 0; font-size: 13px; }
|
||||||
|
.ticket-detail-sidebar dt { color: var(--md-ref-on-surface-variant); }
|
||||||
|
.ticket-detail-sidebar dd { margin: 3px 0 0; overflow-wrap: anywhere; }
|
||||||
|
.ticket-detail-actions { display: grid; gap: 8px; margin-top: 18px; }
|
||||||
|
.ticket-detail-error { margin-top: 14px; color: var(--md-ref-error); }
|
||||||
|
.ticket-detail-guard { max-width: 460px; margin: 48px auto; }
|
||||||
|
.ticket-detail-guard h1 { font-size: 22px; margin-bottom: 8px; }
|
||||||
|
.ticket-detail-guard .btn { margin-top: 16px; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.ticket-detail-page { max-width: none; }
|
||||||
|
.ticket-detail-header, .ticket-detail-description, .ticket-detail-message, .ticket-detail-reply, .ticket-detail-sidebar { padding: 16px; }
|
||||||
|
.ticket-detail-title { font-size: 24px; }
|
||||||
|
.ticket-detail-progress { margin-top: 18px; }
|
||||||
|
.ticket-detail-progress-label { font-size: 11px; }
|
||||||
|
.ticket-detail-sidebar { position: static; }
|
||||||
|
.ticket-detail-reply-footer { align-items: flex-start; flex-direction: column; }
|
||||||
|
.ticket-detail-reply-footer .btn { width: 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.ticket-detail-progress-label { font-size: 10px; }
|
||||||
|
.ticket-detail-progress-dot { width: 24px; height: 24px; }
|
||||||
|
.ticket-detail-progress-item.is-current .ticket-detail-progress-dot { width: 29px; height: 29px; }
|
||||||
|
.ticket-detail-events li { display: block; }
|
||||||
|
.ticket-detail-events time { display: block; margin-top: 3px; }
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes chipPop {
|
@keyframes chipPop {
|
||||||
from { transform: scale(0.92); }
|
from { transform: scale(0.92); }
|
||||||
50% { transform: scale(1.06); }
|
50% { transform: scale(1.06); }
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const db = require('../db');
|
||||||
|
const { authMiddleware, adminOnly } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const CATEGORIES = new Set(['forum_bug', 'site_bug', 'feature', 'account', 'other']);
|
||||||
|
const PRIORITIES = new Set(['low', 'normal', 'high', 'urgent']);
|
||||||
|
const STATUSES = new Set(['open', 'processing', 'waiting', 'resolved', 'closed']);
|
||||||
|
const TRANSITIONS = {
|
||||||
|
open: new Set(['processing', 'closed']),
|
||||||
|
processing: new Set(['waiting', 'resolved', 'closed']),
|
||||||
|
waiting: new Set(['processing', 'closed']),
|
||||||
|
resolved: new Set(['closed', 'processing']),
|
||||||
|
closed: new Set(['processing']),
|
||||||
|
};
|
||||||
|
|
||||||
|
function text(value, max = 0) {
|
||||||
|
if (typeof value !== 'string') return '';
|
||||||
|
const valueText = value.trim();
|
||||||
|
return max && valueText.length > max ? valueText.slice(0, max) : valueText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentUserRole(userId) {
|
||||||
|
const user = db.get('SELECT role FROM users WHERE id = ?', [userId]);
|
||||||
|
return user ? user.role : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSourceUrl(value) {
|
||||||
|
if (value === undefined || value === null || value === '') return { value: '' };
|
||||||
|
if (typeof value !== 'string') return { error: '来源地址不合法' };
|
||||||
|
if (value.length > 1000 || /[\u0000-\u001f\u007f]/.test(value)) return { error: '来源地址不合法' };
|
||||||
|
const sourceUrl = value.trim();
|
||||||
|
if (!sourceUrl) return { value: '' };
|
||||||
|
if (sourceUrl.startsWith('/') && !sourceUrl.startsWith('//')) return { value: sourceUrl };
|
||||||
|
if (!/^https?:\/\//i.test(sourceUrl)) return { error: '来源地址只允许站内路径或 http/https 地址' };
|
||||||
|
try {
|
||||||
|
const parsed = new URL(sourceUrl);
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) return { error: '来源地址不合法' };
|
||||||
|
} catch {
|
||||||
|
return { error: '来源地址不合法' };
|
||||||
|
}
|
||||||
|
return { value: sourceUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageParams(query) {
|
||||||
|
const page = Number.parseInt(query.page, 10);
|
||||||
|
const pageSize = Number.parseInt(query.pageSize, 10);
|
||||||
|
return {
|
||||||
|
page: Number.isInteger(page) && page >= 1 ? page : 1,
|
||||||
|
pageSize: Number.isInteger(pageSize) && pageSize >= 1 && pageSize <= 50 ? pageSize : 20,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function idParam(value) {
|
||||||
|
const id = Number.parseInt(value, 10);
|
||||||
|
return Number.isInteger(id) && id > 0 && String(id) === String(value) ? id : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTicket(id) {
|
||||||
|
return db.get(`SELECT t.*, u.username AS requester_name, u.nickname AS requester_nickname,
|
||||||
|
a.username AS assignee_name, a.nickname AS assignee_nickname
|
||||||
|
FROM tickets t
|
||||||
|
LEFT JOIN users u ON u.id = t.requester_id
|
||||||
|
LEFT JOIN users a ON a.id = t.assignee_id
|
||||||
|
WHERE t.id = ?`, [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAccess(ticket, user, role) {
|
||||||
|
return !!ticket && !!role && (ticket.requester_id === user.id || role === 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEvent(insertEvent, ticketId, actorId, type, field = '', oldValue = '', newValue = '', detail = '') {
|
||||||
|
insertEvent.run(ticketId, actorId || null, type, field, String(oldValue ?? ''), String(newValue ?? ''), detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(ticket, status, actorId, insertEvent) {
|
||||||
|
if (!STATUSES.has(status)) return { error: '状态不合法' };
|
||||||
|
if (ticket.status === status) return { changed: false };
|
||||||
|
if (!TRANSITIONS[ticket.status] || !TRANSITIONS[ticket.status].has(status)) {
|
||||||
|
return { error: '不允许的状态流转' };
|
||||||
|
}
|
||||||
|
const now = "datetime('now')";
|
||||||
|
const values = [status];
|
||||||
|
let sql = `UPDATE tickets SET status = ?, updated_at = ${now}`;
|
||||||
|
if (status === 'resolved') sql += `, resolved_at = ${now}`;
|
||||||
|
if (status === 'closed') sql += `, closed_at = ${now}`;
|
||||||
|
if (status !== 'resolved') sql += ', resolved_at = NULL';
|
||||||
|
if (status !== 'closed') sql += ', closed_at = NULL';
|
||||||
|
sql += ' WHERE id = ?';
|
||||||
|
values.push(ticket.id);
|
||||||
|
db.getDb().prepare(sql).run(...values);
|
||||||
|
addEvent(insertEvent, ticket.id, actorId, 'status_changed', 'status', ticket.status, status);
|
||||||
|
return { changed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ticketResponse(ticket, isAdmin) {
|
||||||
|
const messages = db.all(`SELECT tm.id, tm.ticket_id, tm.author_id, tm.content, tm.is_internal,
|
||||||
|
tm.created_at, u.username AS author_name, u.nickname AS author_nickname, u.role AS author_role
|
||||||
|
FROM ticket_messages tm LEFT JOIN users u ON u.id = tm.author_id
|
||||||
|
WHERE tm.ticket_id = ? ${isAdmin ? '' : 'AND tm.is_internal = 0'}
|
||||||
|
ORDER BY tm.created_at ASC, tm.id ASC`, [ticket.id]);
|
||||||
|
const response = { ticket, messages };
|
||||||
|
if (isAdmin) {
|
||||||
|
response.events = db.all(`SELECT e.*, u.username AS actor_name, u.nickname AS actor_nickname
|
||||||
|
FROM ticket_events e LEFT JOIN users u ON u.id = e.actor_id
|
||||||
|
WHERE e.ticket_id = ? ORDER BY e.created_at ASC, e.id ASC`, [ticket.id]);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理后台列表(作为管理后台的工单子 tab 使用)
|
||||||
|
router.get('/admin', authMiddleware, adminOnly, (req, res) => {
|
||||||
|
const { page, pageSize } = pageParams(req.query);
|
||||||
|
const conditions = [];
|
||||||
|
const params = [];
|
||||||
|
const filters = [
|
||||||
|
['status', STATUSES], ['priority', PRIORITIES], ['category', CATEGORIES],
|
||||||
|
];
|
||||||
|
for (const [key, allowed] of filters) {
|
||||||
|
if (req.query[key]) {
|
||||||
|
if (!allowed.has(String(req.query[key]))) return res.status(400).json({ error: `${key} 不合法` });
|
||||||
|
conditions.push(`t.${key} = ?`); params.push(String(req.query[key]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const assigneeId = req.query.assignee_id === 'null' ? null : idParam(req.query.assignee_id || '');
|
||||||
|
if (req.query.assignee_id !== undefined) {
|
||||||
|
if (req.query.assignee_id === 'null') conditions.push('t.assignee_id IS NULL');
|
||||||
|
else if (!assigneeId) return res.status(400).json({ error: '负责人不合法' });
|
||||||
|
else { conditions.push('t.assignee_id = ?'); params.push(assigneeId); }
|
||||||
|
}
|
||||||
|
const requesterId = req.query.requester_id ? idParam(req.query.requester_id) : 0;
|
||||||
|
if (req.query.requester_id && !requesterId) return res.status(400).json({ error: '提交人不合法' });
|
||||||
|
if (requesterId) { conditions.push('t.requester_id = ?'); params.push(requesterId); }
|
||||||
|
if (req.query.q) {
|
||||||
|
const q = text(req.query.q, 100);
|
||||||
|
conditions.push('(t.ticket_no LIKE ? OR t.subject LIKE ? OR t.description LIKE ?)');
|
||||||
|
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
|
||||||
|
}
|
||||||
|
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
const total = db.get(`SELECT COUNT(*) AS count FROM tickets t ${where}`, params).count;
|
||||||
|
const list = db.all(`SELECT t.id, t.ticket_no, t.subject, t.category, t.priority, t.status,
|
||||||
|
t.requester_id, t.assignee_id, t.source, t.created_at, t.updated_at, t.last_reply_at,
|
||||||
|
u.username AS requester_name, u.nickname AS requester_nickname,
|
||||||
|
a.username AS assignee_name, a.nickname AS assignee_nickname
|
||||||
|
FROM tickets t LEFT JOIN users u ON u.id = t.requester_id LEFT JOIN users a ON a.id = t.assignee_id
|
||||||
|
${where} ORDER BY CASE t.status WHEN 'open' THEN 0 WHEN 'processing' THEN 1 WHEN 'waiting' THEN 2 WHEN 'resolved' THEN 3 ELSE 4 END,
|
||||||
|
t.updated_at DESC, t.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, (page - 1) * pageSize]);
|
||||||
|
res.json({ tickets: list, list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/admin/stats', authMiddleware, adminOnly, (req, res) => {
|
||||||
|
const row = db.get(`SELECT COUNT(*) AS total,
|
||||||
|
SUM(status = 'open') AS open, SUM(status = 'processing') AS processing,
|
||||||
|
SUM(status = 'waiting') AS waiting, SUM(status = 'resolved') AS resolved,
|
||||||
|
SUM(status = 'closed') AS closed FROM tickets`);
|
||||||
|
res.json(Object.fromEntries(Object.entries(row).map(([key, value]) => [key, Number(value) || 0])));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/admin/assignees', authMiddleware, adminOnly, (req, res) => {
|
||||||
|
res.json(db.all("SELECT id, username, nickname FROM users WHERE role = 'admin' ORDER BY id ASC"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 用户自己的工单列表
|
||||||
|
router.get('/', authMiddleware, (req, res) => {
|
||||||
|
const { page, pageSize } = pageParams(req.query);
|
||||||
|
if (req.query.status && !STATUSES.has(String(req.query.status))) return res.status(400).json({ error: '状态不合法' });
|
||||||
|
const params = [req.user.id];
|
||||||
|
const statusSql = req.query.status ? 'AND t.status = ?' : '';
|
||||||
|
if (req.query.status) params.push(String(req.query.status));
|
||||||
|
const total = db.get(`SELECT COUNT(*) AS count FROM tickets t WHERE t.requester_id = ? ${statusSql}`, params).count;
|
||||||
|
const list = db.all(`SELECT t.id, t.ticket_no, t.subject, t.category, t.priority, t.status, t.source,
|
||||||
|
t.created_at, t.updated_at, t.last_reply_at
|
||||||
|
FROM tickets t WHERE t.requester_id = ? ${statusSql}
|
||||||
|
ORDER BY t.created_at DESC, t.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, (page - 1) * pageSize]);
|
||||||
|
res.json({ tickets: list, list, total, page, pageSize, totalPages: Math.ceil(total / pageSize) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/', authMiddleware, (req, res) => {
|
||||||
|
const body = req.body || {};
|
||||||
|
const subject = text(body.subject, 120);
|
||||||
|
const description = text(body.description, 20000);
|
||||||
|
const category = text(body.category, 30) || 'other';
|
||||||
|
const priority = text(body.priority, 20) || 'normal';
|
||||||
|
const source = text(body.source, 20) || 'site';
|
||||||
|
if (subject.length < 1 || description.length < 1) return res.status(400).json({ error: '标题和问题描述不能为空' });
|
||||||
|
if (!CATEGORIES.has(category)) return res.status(400).json({ error: '问题分类不合法' });
|
||||||
|
if (!PRIORITIES.has(priority)) return res.status(400).json({ error: '优先级不合法' });
|
||||||
|
if (!['forum', 'site'].includes(source)) return res.status(400).json({ error: '来源不合法' });
|
||||||
|
const sourceUrlResult = validateSourceUrl(body.source_url);
|
||||||
|
if (sourceUrlResult.error) return res.status(400).json({ error: sourceUrlResult.error });
|
||||||
|
const sourceUrl = sourceUrlResult.value;
|
||||||
|
const sourceType = text(body.source_type, 40);
|
||||||
|
const sourceId = body.source_id === undefined || body.source_id === '' ? 0 : idParam(String(body.source_id));
|
||||||
|
if (body.source_id !== undefined && body.source_id !== '' && !sourceId) return res.status(400).json({ error: '来源编号不合法' });
|
||||||
|
if (source === 'forum' && sourceId && !db.get('SELECT id FROM forum_posts WHERE id = ?', [sourceId])) {
|
||||||
|
return res.status(400).json({ error: '关联的论坛帖子不存在' });
|
||||||
|
}
|
||||||
|
const browserInfo = text(body.browser_info, 1000);
|
||||||
|
try {
|
||||||
|
const result = db.transaction(() => {
|
||||||
|
const database = db.getDb();
|
||||||
|
const insertTicket = database.prepare(`INSERT INTO tickets
|
||||||
|
(ticket_no, requester_id, subject, description, category, priority, source, source_url, source_type, source_id, browser_info)
|
||||||
|
VALUES ('PENDING-' || hex(randomblob(8)), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||||
|
const info = insertTicket.run(req.user.id, subject, description, category, priority, source, sourceUrl, sourceType, sourceId, browserInfo);
|
||||||
|
const id = Number(info.lastInsertRowid);
|
||||||
|
const ticketNo = `RW-${String(id).padStart(6, '0')}`;
|
||||||
|
database.prepare('UPDATE tickets SET ticket_no = ? WHERE id = ?').run(ticketNo, id);
|
||||||
|
const event = database.prepare(`INSERT INTO ticket_events
|
||||||
|
(ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||||||
|
addEvent(event, id, req.user.id, 'ticket_created', '', '', 'open', '用户创建工单');
|
||||||
|
return { id, ticket_no: ticketNo };
|
||||||
|
});
|
||||||
|
res.status(201).json({ message: '工单已创建', ticket: getTicket(result.id) });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ticket create error:', e.message);
|
||||||
|
res.status(500).json({ error: '创建工单失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id/events', authMiddleware, adminOnly, (req, res) => {
|
||||||
|
const id = idParam(req.params.id);
|
||||||
|
if (!id) return res.status(404).json({ error: '工单不存在' });
|
||||||
|
if (!getTicket(id)) return res.status(404).json({ error: '工单不存在' });
|
||||||
|
res.json(db.all(`SELECT e.*, u.username AS actor_name, u.nickname AS actor_nickname
|
||||||
|
FROM ticket_events e LEFT JOIN users u ON u.id = e.actor_id
|
||||||
|
WHERE e.ticket_id = ? ORDER BY e.created_at ASC, e.id ASC`, [id]));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', authMiddleware, (req, res) => {
|
||||||
|
const id = idParam(req.params.id);
|
||||||
|
const ticket = id ? getTicket(id) : null;
|
||||||
|
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
||||||
|
const role = currentUserRole(req.user.id);
|
||||||
|
if (!role) return res.status(401).json({ error: '登录已失效' });
|
||||||
|
if (!canAccess(ticket, req.user, role)) return res.status(403).json({ error: '无权限访问该工单' });
|
||||||
|
res.json(ticketResponse(ticket, role === 'admin'));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/messages', authMiddleware, (req, res) => {
|
||||||
|
const id = idParam(req.params.id);
|
||||||
|
const ticket = id ? getTicket(id) : null;
|
||||||
|
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
||||||
|
const role = currentUserRole(req.user.id);
|
||||||
|
if (!role) return res.status(401).json({ error: '登录已失效' });
|
||||||
|
if (!canAccess(ticket, req.user, role)) return res.status(403).json({ error: '无权限访问该工单' });
|
||||||
|
const content = text(req.body && req.body.content, 20000);
|
||||||
|
if (!content) return res.status(400).json({ error: '回复内容不能为空' });
|
||||||
|
if (ticket.status === 'closed') return res.status(409).json({ error: '工单已关闭,不能回复' });
|
||||||
|
try {
|
||||||
|
db.transaction(() => {
|
||||||
|
const database = db.getDb();
|
||||||
|
database.prepare('INSERT INTO ticket_messages (ticket_id, author_id, content, is_internal) VALUES (?, ?, ?, 0)').run(id, req.user.id, content);
|
||||||
|
let nextStatus = ticket.status;
|
||||||
|
if (role !== 'admin' && ticket.status === 'waiting') nextStatus = 'processing';
|
||||||
|
database.prepare(`UPDATE tickets SET updated_at = datetime('now'), last_reply_at = datetime('now'),
|
||||||
|
first_response_at = CASE WHEN first_response_at IS NULL AND ? = 'admin' THEN datetime('now') ELSE first_response_at END,
|
||||||
|
status = ? WHERE id = ?`).run(role, nextStatus, id);
|
||||||
|
const event = database.prepare(`INSERT INTO ticket_events
|
||||||
|
(ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||||||
|
addEvent(event, id, req.user.id, 'message_added', '', '', '公开回复', '');
|
||||||
|
if (nextStatus !== ticket.status) addEvent(event, id, req.user.id, 'status_changed', 'status', ticket.status, nextStatus, '用户回复后自动进入处理中');
|
||||||
|
});
|
||||||
|
res.status(201).json(ticketResponse(getTicket(id), role === 'admin'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ticket message error:', e.message);
|
||||||
|
res.status(500).json({ error: '回复工单失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/internal-messages', authMiddleware, adminOnly, (req, res) => {
|
||||||
|
const id = idParam(req.params.id);
|
||||||
|
if (!id || !getTicket(id)) return res.status(404).json({ error: '工单不存在' });
|
||||||
|
const content = text(req.body && req.body.content, 20000);
|
||||||
|
if (!content) return res.status(400).json({ error: '内部备注不能为空' });
|
||||||
|
try {
|
||||||
|
db.transaction(() => {
|
||||||
|
const database = db.getDb();
|
||||||
|
database.prepare('INSERT INTO ticket_messages (ticket_id, author_id, content, is_internal) VALUES (?, ?, ?, 1)').run(id, req.user.id, content);
|
||||||
|
database.prepare("UPDATE tickets SET updated_at = datetime('now') WHERE id = ?").run(id);
|
||||||
|
const event = database.prepare(`INSERT INTO ticket_events
|
||||||
|
(ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||||||
|
addEvent(event, id, req.user.id, 'internal_note_added', '', '', '', '管理员添加内部备注');
|
||||||
|
});
|
||||||
|
res.status(201).json(ticketResponse(getTicket(id), true));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Ticket internal message error:', e.message);
|
||||||
|
res.status(500).json({ error: '添加内部备注失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id/status', authMiddleware, adminOnly, (req, res) => {
|
||||||
|
const id = idParam(req.params.id);
|
||||||
|
const ticket = id ? getTicket(id) : null;
|
||||||
|
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
||||||
|
const status = text(req.body && req.body.status, 20);
|
||||||
|
try {
|
||||||
|
const result = db.transaction(() => {
|
||||||
|
const database = db.getDb();
|
||||||
|
const event = database.prepare(`INSERT INTO ticket_events
|
||||||
|
(ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||||||
|
return updateStatus(ticket, status, req.user.id, event);
|
||||||
|
});
|
||||||
|
if (result.error) return res.status(result.error === '不允许的状态流转' ? 409 : 400).json({ error: result.error });
|
||||||
|
res.json({ message: '状态已更新', ticket: getTicket(id) });
|
||||||
|
} catch (e) { console.error('Ticket status error:', e.message); res.status(500).json({ error: '更新状态失败' }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id/priority', authMiddleware, adminOnly, (req, res) => {
|
||||||
|
const id = idParam(req.params.id);
|
||||||
|
const ticket = id ? getTicket(id) : null;
|
||||||
|
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
||||||
|
const priority = text(req.body && req.body.priority, 20);
|
||||||
|
if (!PRIORITIES.has(priority)) return res.status(400).json({ error: '优先级不合法' });
|
||||||
|
try {
|
||||||
|
db.transaction(() => {
|
||||||
|
const database = db.getDb();
|
||||||
|
database.prepare("UPDATE tickets SET priority = ?, updated_at = datetime('now') WHERE id = ?").run(priority, id);
|
||||||
|
const event = database.prepare(`INSERT INTO ticket_events
|
||||||
|
(ticket_id, actor_id, event_type, field_name, old_value, new_value) VALUES (?, ?, ?, ?, ?, ?)`);
|
||||||
|
addEvent(event, id, req.user.id, 'priority_changed', 'priority', ticket.priority, priority);
|
||||||
|
});
|
||||||
|
res.json({ message: '优先级已更新', ticket: getTicket(id) });
|
||||||
|
} catch (e) { console.error('Ticket priority error:', e.message); res.status(500).json({ error: '更新优先级失败' }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/:id/assignee', authMiddleware, adminOnly, (req, res) => {
|
||||||
|
const id = idParam(req.params.id);
|
||||||
|
const ticket = id ? getTicket(id) : null;
|
||||||
|
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
||||||
|
const assigneeId = req.body && (req.body.assignee_id === null || req.body.assignee_id === '' ? null : idParam(String(req.body.assignee_id)));
|
||||||
|
if (assigneeId !== null && !assigneeId) return res.status(400).json({ error: '负责人不合法' });
|
||||||
|
if (assigneeId !== null && !db.get("SELECT id FROM users WHERE id = ? AND role = 'admin'", [assigneeId])) return res.status(400).json({ error: '负责人必须是管理员' });
|
||||||
|
try {
|
||||||
|
db.transaction(() => {
|
||||||
|
const database = db.getDb();
|
||||||
|
database.prepare("UPDATE tickets SET assignee_id = ?, updated_at = datetime('now') WHERE id = ?").run(assigneeId, id);
|
||||||
|
const event = database.prepare(`INSERT INTO ticket_events
|
||||||
|
(ticket_id, actor_id, event_type, field_name, old_value, new_value) VALUES (?, ?, ?, ?, ?, ?)`);
|
||||||
|
addEvent(event, id, req.user.id, 'assignee_changed', 'assignee_id', ticket.assignee_id, assigneeId);
|
||||||
|
});
|
||||||
|
res.json({ message: '负责人已更新', ticket: getTicket(id) });
|
||||||
|
} catch (e) { console.error('Ticket assignee error:', e.message); res.status(500).json({ error: '更新负责人失败' }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/:id/close', authMiddleware, (req, res) => changeUserStatus(req, res, 'closed'));
|
||||||
|
router.post('/:id/reopen', authMiddleware, (req, res) => changeUserStatus(req, res, 'processing'));
|
||||||
|
|
||||||
|
function changeUserStatus(req, res, status) {
|
||||||
|
const id = idParam(req.params.id);
|
||||||
|
const ticket = id ? getTicket(id) : null;
|
||||||
|
if (!ticket) return res.status(404).json({ error: '工单不存在' });
|
||||||
|
if (ticket.requester_id !== req.user.id) return res.status(403).json({ error: '无权限操作该工单' });
|
||||||
|
if ((status === 'closed' && ticket.status === 'closed') || (status === 'processing' && !['resolved', 'closed'].includes(ticket.status))) {
|
||||||
|
return res.status(409).json({ error: '当前状态不支持此操作' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
db.transaction(() => {
|
||||||
|
const database = db.getDb();
|
||||||
|
const event = database.prepare(`INSERT INTO ticket_events
|
||||||
|
(ticket_id, actor_id, event_type, field_name, old_value, new_value, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||||||
|
const result = updateStatus(ticket, status, req.user.id, event);
|
||||||
|
if (result.error) throw new Error(result.error);
|
||||||
|
});
|
||||||
|
res.json({ message: status === 'closed' ? '工单已关闭' : '工单已重新打开', ticket: getTicket(id) });
|
||||||
|
} catch (e) { res.status(e.message === '不允许的状态流转' ? 409 : 500).json({ error: e.message === '不允许的状态流转' ? e.message : '更新工单失败' }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -38,6 +38,7 @@ const importRoutes = require('./routes/import');
|
|||||||
const noteRoutes = require('./routes/notes');
|
const noteRoutes = require('./routes/notes');
|
||||||
const feedRoutes = require('./routes/feed');
|
const feedRoutes = require('./routes/feed');
|
||||||
const terminalRoutes = require('./routes/terminal');
|
const terminalRoutes = require('./routes/terminal');
|
||||||
|
const ticketRoutes = require('./routes/tickets');
|
||||||
const oidcRoutes = require('./routes/oidc');
|
const oidcRoutes = require('./routes/oidc');
|
||||||
const { blogSSR, forumSSR, categorySSR, sitemapXml } = require('./ssr');
|
const { blogSSR, forumSSR, categorySSR, sitemapXml } = require('./ssr');
|
||||||
|
|
||||||
@@ -290,6 +291,7 @@ app.use('/api/proxy', proxyRoutes);
|
|||||||
app.use('/api/import', importRoutes);
|
app.use('/api/import', importRoutes);
|
||||||
app.use('/api/notes', noteRoutes);
|
app.use('/api/notes', noteRoutes);
|
||||||
app.use('/api/terminal', terminalRoutes);
|
app.use('/api/terminal', terminalRoutes);
|
||||||
|
app.use('/api/tickets', ticketRoutes);
|
||||||
|
|
||||||
// Version & Update
|
// Version & Update
|
||||||
const version = require('fs').readFileSync('./VERSION', 'utf8').trim();
|
const version = require('fs').readFileSync('./VERSION', 'utf8').trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user