448 lines
16 KiB
TypeScript
448 lines
16 KiB
TypeScript
import { useRef, useState } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { submitFeedback } from '../api/client';
|
||
import type { FeedbackImageInput, FeedbackSubmissionType, PortalUser } from '../types';
|
||
import {
|
||
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
|
||
CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES,
|
||
CHAT_IMAGE_MAX_SIDE,
|
||
compressImageForUpload,
|
||
} from '../utils/imageUpload';
|
||
import { FeedbackPageHeader } from './FeedbackPageHeader';
|
||
import { VoiceInputButton } from './VoiceInputButton';
|
||
|
||
const TYPE_OPTIONS: Array<{ value: FeedbackSubmissionType; label: string; hint: string }> = [
|
||
{ value: 'bug', label: 'Bug 报告', hint: '功能异常、报错、无法完成操作' },
|
||
{ value: 'feature', label: '功能建议', hint: '新能力、体验优化、流程改进' },
|
||
{ value: 'other', label: '其他反馈', hint: '账号、计费、文档等问题' },
|
||
];
|
||
|
||
const MAX_IMAGES = 5;
|
||
|
||
type PendingImage = {
|
||
id: string;
|
||
previewUrl: string;
|
||
file: File;
|
||
};
|
||
|
||
function fileToBase64(file: File): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
const result = typeof reader.result === 'string' ? reader.result : '';
|
||
const comma = result.indexOf(',');
|
||
resolve(comma >= 0 ? result.slice(comma + 1) : result);
|
||
};
|
||
reader.onerror = () => reject(new Error('图片读取失败'));
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
function buildFeedbackContext(sessionId?: string | null) {
|
||
return {
|
||
pageUrl: window.location.href,
|
||
pagePath: `${window.location.pathname}${window.location.search}`,
|
||
userAgent: navigator.userAgent,
|
||
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
||
appVersion: import.meta.env.VITE_APP_VERSION ?? undefined,
|
||
sessionId: sessionId ?? undefined,
|
||
};
|
||
}
|
||
|
||
export function FeedbackSubmitView({
|
||
user,
|
||
sessionId,
|
||
onLogout,
|
||
}: {
|
||
user?: PortalUser | null;
|
||
sessionId?: string | null;
|
||
onLogout?: () => void;
|
||
}) {
|
||
const navigate = useNavigate();
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const descriptionRef = useRef<HTMLTextAreaElement>(null);
|
||
const descriptionTextRef = useRef('');
|
||
const voiceBaseRef = useRef('');
|
||
const suppressVoiceUpdateRef = useRef(false);
|
||
|
||
const [type, setType] = useState<FeedbackSubmissionType>('bug');
|
||
const [title, setTitle] = useState('');
|
||
const [description, setDescription] = useState('');
|
||
const [contact, setContact] = useState(user?.email ?? '');
|
||
const [images, setImages] = useState<PendingImage[]>([]);
|
||
const [imageError, setImageError] = useState<string | null>(null);
|
||
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
|
||
const [voiceRecording, setVoiceRecording] = useState(false);
|
||
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [submittedId, setSubmittedId] = useState<string | null>(null);
|
||
const [uploadingImages, setUploadingImages] = useState(false);
|
||
|
||
descriptionTextRef.current = description;
|
||
|
||
const selectedType = TYPE_OPTIONS.find((item) => item.value === type) ?? TYPE_OPTIONS[0];
|
||
const voiceDisabled = submitting || uploadingImages;
|
||
|
||
const mergeVoiceText = (spoken: string) => {
|
||
const base = voiceBaseRef.current.trimEnd();
|
||
const chunk = spoken.trim();
|
||
if (!chunk) return base;
|
||
return base ? `${base} ${chunk}` : chunk;
|
||
};
|
||
|
||
const handleVoiceStart = () => {
|
||
suppressVoiceUpdateRef.current = false;
|
||
voiceBaseRef.current = descriptionTextRef.current;
|
||
setVoiceNotice(null);
|
||
};
|
||
|
||
const handleVoiceLiveTranscript = (text: string) => {
|
||
if (suppressVoiceUpdateRef.current) return;
|
||
setDescription(mergeVoiceText(text));
|
||
};
|
||
|
||
const handleVoiceTranscript = (text: string) => {
|
||
if (suppressVoiceUpdateRef.current) return;
|
||
setDescription(mergeVoiceText(text));
|
||
setVoiceNotice('已识别,可编辑后提交');
|
||
};
|
||
|
||
const handleVoiceComplete = () => {
|
||
setVoiceNotice('已识别,可编辑后提交');
|
||
};
|
||
|
||
const stopVoiceInput = () => {
|
||
suppressVoiceUpdateRef.current = true;
|
||
if (voiceRecording) {
|
||
setVoiceStopSignal((value) => value + 1);
|
||
}
|
||
setVoiceRecording(false);
|
||
};
|
||
|
||
const handlePickImages = async (files: FileList | null) => {
|
||
if (!files?.length) return;
|
||
setImageError(null);
|
||
|
||
const remaining = MAX_IMAGES - images.length;
|
||
if (remaining <= 0) {
|
||
setImageError(`最多上传 ${MAX_IMAGES} 张图片`);
|
||
return;
|
||
}
|
||
|
||
const selected = Array.from(files).slice(0, remaining);
|
||
setUploadingImages(true);
|
||
try {
|
||
const nextItems: PendingImage[] = [];
|
||
for (const file of selected) {
|
||
if (!file.type.startsWith('image/')) {
|
||
throw new Error('只支持图片文件');
|
||
}
|
||
const compressed = await compressImageForUpload(file, {
|
||
maxInputBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
|
||
maxOutputBytes: CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES,
|
||
maxDimension: CHAT_IMAGE_MAX_SIDE,
|
||
});
|
||
nextItems.push({
|
||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
previewUrl: URL.createObjectURL(compressed),
|
||
file: compressed,
|
||
});
|
||
}
|
||
setImages((prev) => [...prev, ...nextItems]);
|
||
} catch (err) {
|
||
setImageError(err instanceof Error ? err.message : '图片处理失败');
|
||
} finally {
|
||
setUploadingImages(false);
|
||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||
}
|
||
};
|
||
|
||
const removeImage = (id: string) => {
|
||
setImages((prev) => {
|
||
const target = prev.find((item) => item.id === id);
|
||
if (target) URL.revokeObjectURL(target.previewUrl);
|
||
return prev.filter((item) => item.id !== id);
|
||
});
|
||
};
|
||
|
||
const resetForm = () => {
|
||
stopVoiceInput();
|
||
setSubmittedId(null);
|
||
setTitle('');
|
||
setDescription('');
|
||
voiceBaseRef.current = '';
|
||
suppressVoiceUpdateRef.current = false;
|
||
setVoiceNotice(null);
|
||
setImages([]);
|
||
setType('bug');
|
||
setError(null);
|
||
};
|
||
|
||
const handleSubmit = async (event: React.FormEvent) => {
|
||
event.preventDefault();
|
||
setError(null);
|
||
stopVoiceInput();
|
||
|
||
const trimmedTitle = title.trim();
|
||
const trimmedDescription = description.trim();
|
||
if (!trimmedTitle) {
|
||
suppressVoiceUpdateRef.current = false;
|
||
setError('请填写标题');
|
||
return;
|
||
}
|
||
if (!trimmedDescription) {
|
||
suppressVoiceUpdateRef.current = false;
|
||
setError('请填写详细描述,或使用语音口述');
|
||
descriptionRef.current?.focus();
|
||
return;
|
||
}
|
||
|
||
setSubmitting(true);
|
||
try {
|
||
const imagePayload: FeedbackImageInput[] = [];
|
||
for (const item of images) {
|
||
imagePayload.push({
|
||
filename: item.file.name,
|
||
mimeType: item.file.type || 'image/jpeg',
|
||
dataBase64: await fileToBase64(item.file),
|
||
});
|
||
}
|
||
|
||
const result = await submitFeedback({
|
||
type,
|
||
title: trimmedTitle,
|
||
description: trimmedDescription,
|
||
contact: contact.trim() || undefined,
|
||
images: imagePayload,
|
||
context: buildFeedbackContext(sessionId),
|
||
});
|
||
setSubmittedId(result.id);
|
||
suppressVoiceUpdateRef.current = false;
|
||
setVoiceNotice(null);
|
||
} catch (err) {
|
||
suppressVoiceUpdateRef.current = false;
|
||
setError(err instanceof Error ? err.message : '提交失败,请稍后重试');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="feedback-submit-page feedback-board-page">
|
||
<FeedbackPageHeader
|
||
title={submittedId ? '反馈已收到' : '反馈与建议'}
|
||
onBack={() => navigate(-1)}
|
||
onLogout={onLogout}
|
||
/>
|
||
|
||
<main className="feedback-submit-main feedback-board-main">
|
||
{submittedId ? (
|
||
<section className="feedback-submit-card feedback-board-card feedback-submit-success-card">
|
||
<div className="feedback-board-toolbar">
|
||
<div className="feedback-board-toolbar-copy">
|
||
<p className="feedback-submit-eyebrow">提交成功</p>
|
||
<h1>感谢你的反馈</h1>
|
||
<p className="feedback-submit-desc feedback-board-desc">
|
||
我们已收到你的{selectedType.label},编号 <code>{submittedId.slice(0, 8)}</code>。
|
||
你可以在「用户反馈」中查看进度。
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="feedback-panel-close"
|
||
aria-label="查看用户反馈"
|
||
onClick={() => navigate('/feedback/list')}
|
||
>
|
||
用户反馈
|
||
</button>
|
||
</div>
|
||
|
||
<div className="feedback-submit-actions page-save-actions">
|
||
<button type="button" className="page-save-primary" onClick={() => navigate(`/feedback/${submittedId}`)}>
|
||
查看详情
|
||
</button>
|
||
<button type="button" className="ghost-btn" onClick={() => navigate('/feedback/list')}>
|
||
用户反馈
|
||
</button>
|
||
<button type="button" className="ghost-btn" onClick={resetForm}>
|
||
继续提交
|
||
</button>
|
||
</div>
|
||
</section>
|
||
) : (
|
||
<form
|
||
className="feedback-submit-card feedback-board-card feedback-submit-form-card"
|
||
onSubmit={(event) => void handleSubmit(event)}
|
||
>
|
||
<div className="feedback-board-toolbar">
|
||
<div className="feedback-board-toolbar-copy">
|
||
<p className="feedback-submit-eyebrow">帮助我们改进 TKMind</p>
|
||
<h1>提交 Bug 或需求</h1>
|
||
<p className="feedback-submit-desc feedback-board-desc">
|
||
你可以用文字描述、上传截图,或点击麦克风口述问题。我们会自动附带当前页面与设备信息,便于定位问题。
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="feedback-panel-close"
|
||
aria-label="查看用户反馈"
|
||
onClick={() => navigate('/feedback/list')}
|
||
>
|
||
用户反馈
|
||
</button>
|
||
</div>
|
||
|
||
<div className="feedback-board-scroll">
|
||
<fieldset className="feedback-submit-fieldset">
|
||
<legend>反馈类型</legend>
|
||
<div className="feedback-type-grid" role="radiogroup" aria-label="反馈类型">
|
||
{TYPE_OPTIONS.map((option) => (
|
||
<label
|
||
key={option.value}
|
||
className={`feedback-type-option${type === option.value ? ' is-selected' : ''}`}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="feedback-type"
|
||
value={option.value}
|
||
checked={type === option.value}
|
||
onChange={() => setType(option.value)}
|
||
/>
|
||
<span className="feedback-type-label">{option.label}</span>
|
||
<span className="feedback-type-hint">{option.hint}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</fieldset>
|
||
|
||
<label className="feedback-submit-label">
|
||
标题
|
||
<input
|
||
className="input"
|
||
value={title}
|
||
onChange={(event) => setTitle(event.target.value)}
|
||
placeholder={
|
||
type === 'bug'
|
||
? '例如:保存页面时按钮无响应'
|
||
: type === 'feature'
|
||
? '例如:希望支持批量导出'
|
||
: '简要概括你的问题或建议'
|
||
}
|
||
maxLength={120}
|
||
required
|
||
/>
|
||
</label>
|
||
|
||
<div className="feedback-description-block">
|
||
<label className="feedback-submit-label" htmlFor="feedback-description">
|
||
详细描述
|
||
</label>
|
||
{voiceNotice ? (
|
||
<div className="feedback-voice-notice" role="status">
|
||
{voiceNotice}
|
||
</div>
|
||
) : null}
|
||
<div className="feedback-description-shell">
|
||
<textarea
|
||
id="feedback-description"
|
||
ref={descriptionRef}
|
||
className="input feedback-description-input"
|
||
rows={5}
|
||
value={description}
|
||
disabled={voiceDisabled}
|
||
readOnly={voiceRecording}
|
||
onChange={(event) => setDescription(event.target.value)}
|
||
placeholder="请描述复现步骤、期望行为,或你想实现的功能…点击右下角麦克风可语音输入。"
|
||
maxLength={5000}
|
||
required
|
||
/>
|
||
<VoiceInputButton
|
||
disabled={voiceDisabled}
|
||
onVoiceStart={handleVoiceStart}
|
||
onLiveTranscript={handleVoiceLiveTranscript}
|
||
onTranscript={handleVoiceTranscript}
|
||
onVoiceComplete={handleVoiceComplete}
|
||
onRecordingChange={setVoiceRecording}
|
||
onError={setVoiceNotice}
|
||
stopSignal={voiceStopSignal}
|
||
/>
|
||
</div>
|
||
<p className="feedback-description-hint">
|
||
{voiceRecording ? '正在聆听,文字会实时显示在输入框中…' : '支持实时听写;识别完成后可继续编辑再提交。'}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="feedback-submit-label">
|
||
<span>截图(可选,最多 {MAX_IMAGES} 张)</span>
|
||
{imageError ? <p className="feedback-submit-inline-error">{imageError}</p> : null}
|
||
<div className="feedback-image-grid">
|
||
{images.map((item) => (
|
||
<div key={item.id} className="feedback-image-item">
|
||
<img src={item.previewUrl} alt="反馈截图预览" className="feedback-image-thumb" />
|
||
<button
|
||
type="button"
|
||
className="feedback-image-remove"
|
||
aria-label="移除图片"
|
||
onClick={() => removeImage(item.id)}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
{images.length < MAX_IMAGES ? (
|
||
<button
|
||
type="button"
|
||
className="feedback-image-add"
|
||
disabled={uploadingImages || submitting}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
>
|
||
{uploadingImages ? '处理中…' : '+ 添加图片'}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
hidden
|
||
onChange={(event) => void handlePickImages(event.target.files)}
|
||
/>
|
||
</div>
|
||
|
||
<label className="feedback-submit-label">
|
||
联系方式(可选)
|
||
<input
|
||
className="input"
|
||
value={contact}
|
||
onChange={(event) => setContact(event.target.value)}
|
||
placeholder="邮箱或微信,便于我们回复你"
|
||
maxLength={120}
|
||
autoComplete="email"
|
||
/>
|
||
</label>
|
||
|
||
<p className="feedback-submit-meta">
|
||
将自动附带:当前路径 <code>{window.location.pathname}</code>
|
||
{sessionId ? <>、会话 ID</> : null}
|
||
</p>
|
||
|
||
{error ? <p className="feedback-submit-error">{error}</p> : null}
|
||
</div>
|
||
|
||
<div className="feedback-submit-actions page-save-actions">
|
||
<button type="button" className="ghost-btn" disabled={submitting} onClick={() => navigate(-1)}>
|
||
取消
|
||
</button>
|
||
<button type="submit" className="page-save-primary" disabled={submitting || uploadingImages}>
|
||
{submitting ? '提交中…' : '提交反馈'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|