62 lines
5.8 KiB
React
62 lines
5.8 KiB
React
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 sourceValue = String(form.source_url ?? '');
|
||
const sourceUrl = safeSourceUrl(sourceValue);
|
||
const hasInvalidSource = sourceValue.trim() || /[\u0000-\u001f\u007f-\u009f]/.test(sourceValue) || sourceValue.includes('\\');
|
||
if (hasInvalidSource && !sourceUrl) {
|
||
setError('来源地址不合法,请填写站内路径或 http/https 地址');
|
||
return;
|
||
}
|
||
const data = await ticketsApi.createTicket({
|
||
...form,
|
||
subject,
|
||
description,
|
||
source: form.category === 'forum_bug' ? 'forum' : 'site',
|
||
source_url: sourceUrl,
|
||
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>;
|
||
}
|