From 8e890d9a96d33a5a3cc93cd244a25e2c30f3f4f2 Mon Sep 17 00:00:00 2001 From: miaomiao <2733773236@qq.com> Date: Sat, 5 Sep 2026 02:42:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=B7=A5=E5=8D=95?= =?UTF-8?q?=E5=8F=8D=E9=A6=88=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db.js | 57 ++++ frontend/src/App.jsx | 6 + frontend/src/admin/main.jsx | 2 + frontend/src/admin/navConfig.js | 9 + frontend/src/admin/pages/TicketManage.jsx | 201 ++++++++++++ frontend/src/api/tickets.js | 33 ++ frontend/src/components/Layout.jsx | 1 + frontend/src/pages/TicketCreate.jsx | 54 ++++ frontend/src/pages/TicketDetail.jsx | 74 +++++ frontend/src/pages/Tickets.jsx | 96 ++++++ public/css/style.css | 91 ++++++ routes/tickets.js | 370 ++++++++++++++++++++++ server.js | 2 + 13 files changed, 996 insertions(+) create mode 100644 frontend/src/admin/pages/TicketManage.jsx create mode 100644 frontend/src/api/tickets.js create mode 100644 frontend/src/pages/TicketCreate.jsx create mode 100644 frontend/src/pages/TicketDetail.jsx create mode 100644 frontend/src/pages/Tickets.jsx create mode 100644 routes/tickets.js diff --git a/db.js b/db.js index cfc098e..b63369a 100644 --- a/db.js +++ b/db.js @@ -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 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) { if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b2cf7f5..f9afe4d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -20,6 +20,9 @@ import Profile from './pages/Profile.jsx'; import Write from './pages/Write.jsx'; import Embed from './pages/Embed.jsx'; import Setup from './pages/Setup.jsx'; +import Tickets from './pages/Tickets.jsx'; +import TicketCreate from './pages/TicketCreate.jsx'; +import TicketDetail from './pages/TicketDetail.jsx'; export default function App() { return ( @@ -35,6 +38,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/admin/main.jsx b/frontend/src/admin/main.jsx index 64235eb..d2ddf61 100644 --- a/frontend/src/admin/main.jsx +++ b/frontend/src/admin/main.jsx @@ -27,6 +27,7 @@ import Announcements from './pages/Announcements.jsx'; import Links from './pages/Links.jsx'; import Uploads from './pages/Uploads.jsx'; import ImportDb from './pages/ImportDb.jsx'; +import TicketManage from './pages/TicketManage.jsx'; // 工作台独立分包:仅访问 /admin/workbench 时才加载(vite 自动 code-split) const Workbench = lazy(() => import('../tools/workbench/Workbench.jsx')); @@ -113,6 +114,7 @@ function AdminApp() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/admin/navConfig.js b/frontend/src/admin/navConfig.js index 73874b3..9d21dd7 100644 --- a/frontend/src/admin/navConfig.js +++ b/frontend/src/admin/navConfig.js @@ -14,6 +14,7 @@ import LinkIcon from '@mui/icons-material/Link'; import RssFeedIcon from '@mui/icons-material/RssFeed'; import AttachFileIcon from '@mui/icons-material/AttachFile'; import UploadFileIcon from '@mui/icons-material/UploadFile'; +import SupportAgentIcon from '@mui/icons-material/SupportAgent'; /** * 后台侧边栏导航配置(单一数据源): @@ -41,6 +42,13 @@ export const NAV_GROUPS = [ { path: '/posts', label: '帖子管理', icon: ListAltIcon }, ], }, + { + id: 'support', + label: '反馈处理', + items: [ + { path: '/tickets', label: '工单管理', icon: SupportAgentIcon }, + ], + }, { id: 'users', label: '用户', @@ -116,6 +124,7 @@ export const NAV_SECTIONS = [ /** 别名映射:把常见叫法指到已有菜单项或设置区块(path + 可选 anchor) */ export const NAV_ALIASES = [ + { keywords: ['工单', '反馈', 'bug', '问题', 'ticket'], path: '/tickets' }, { keywords: ['邮件', 'email', 'smtp'], path: '/email' }, { keywords: ['验证码', 'captcha', 'reCAPTCHA', 'turnstile'], path: '/captcha' }, { keywords: ['rss', '订阅', 'feed'], path: '/rss' }, diff --git a/frontend/src/admin/pages/TicketManage.jsx b/frontend/src/admin/pages/TicketManage.jsx new file mode 100644 index 0000000..9d7968f --- /dev/null +++ b/frontend/src/admin/pages/TicketManage.jsx @@ -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 ; +} +function PriorityChip({ value }) { + const item = PRIORITY[value] || { label: value || '普通', color: 'default' }; + return ; +} + +function StatCard({ label, value, tone }) { + return ( + + {label} + {value} + + ); +} + +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 ( + + + 工单管理集中处理论坛和站内问题反馈 + + + + + {statItems.map(([label, value, tone]) => )} + + + { 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})} + + + + + {error && 重试} sx={{ mb: 2 }}>{error}} + + + + 最近工单共 {total} 条 + + {loading ? : list.length === 0 ? 暂无匹配工单 : ( + + {list.map((ticket) => )} + + )} + setPage(page - 1)}>第 {page} / {totalPages} 页= totalPages || loading} onClick={() => setPage(page + 1)}> + + + + + + {!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})} + 问题描述{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 || '工单'}已更新`})} + + setReply(e.target.value)} /> setInternal(e.target.value)} /> + + ) : 工单详情不存在或加载失败。} + + + + + ); +} diff --git a/frontend/src/api/tickets.js b/frontend/src/api/tickets.js new file mode 100644 index 0000000..ce04261 --- /dev/null +++ b/frontend/src/api/tickets.js @@ -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' }); +} diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 3906002..dda93c9 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -181,6 +181,7 @@ export default function Layout() { { to: '/', label: '首页', end: true, show: true }, { to: '/blog.html', label: '博客', show: true }, { to: '/forum.html', label: '论坛', show: !!user }, + { to: '/tickets.html', label: '工单', show: true }, { to: '/admin', label: '管理后台', show: isAdmin, fullPage: true }, { to: '/passwords.html', label: '密码箱', show: isAdmin }, ]; diff --git a/frontend/src/pages/TicketCreate.jsx b/frontend/src/pages/TicketCreate.jsx new file mode 100644 index 0000000..f437566 --- /dev/null +++ b/frontend/src/pages/TicketCreate.jsx @@ -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

登录后提交工单

请先登录,再反馈论坛或站内问题。

去登录
; + + 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
+
← 返回工单

提交问题

描述得越具体,越有助于快速定位问题。

+
+ {error &&

{error}

} +

请用一句话概括遇到的问题(最多 120 字)。

+
+