Files
eryao/web/src/components/FeedbackPage.tsx
T
ZL-Q a1b4418d55 feat(web): 优化设置页面交互与语言同步
- 二级页面返回按钮导航到设置页而非路由栈上一页
- 通用设置开关等待后端响应后再更新 UI,失败时显示 toast
- 删除用户名/邮箱的硬编码默认值,使用 auth token 邮箱作为 fallback
- AppShell 侧边栏显示真实头像和用户名
- 页面加载时检查 URL 语言与用户偏好是否一致,不一致则重定向
2026-05-09 21:32:51 +08:00

215 lines
8.2 KiB
TypeScript

import { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { authFetch } from '../lib/auth';
import { API_ROUTES } from '../lib/api-routes';
import { apiUrl, jsonHeaders } from '../lib/api-client';
interface Props {
locale: string;
feedback: { title: string; typeLabel: string; typeBug: string; typeSuggestion: string; typeOther: string; contentLabel: string; contentPlaceholder: string; imagesLabel: string; anonymousLabel: string; anonymousHint: string; submitBtn: string; submitting: string; success: string; error: string; contentRequired: string; contentTooLong: string; tooManyImages: string; imageTooLarge: string };
}
type FeedbackType = 'bug' | 'suggestion' | 'other';
const MAX_IMAGES = 3;
const MAX_CONTENT_SIZE = 500;
const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
export default function FeedbackPage({ locale, feedback: f }: Props) {
const navigate = useNavigate();
const [selectedType, setSelectedType] = useState<FeedbackType>('bug');
const [content, setContent] = useState('');
const [images, setImages] = useState<File[]>([]);
const [isAnonymous, setIsAnonymous] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const typeOptions: { value: FeedbackType; label: string }[] = [
{ value: 'bug', label: f.typeBug },
{ value: 'suggestion', label: f.typeSuggestion },
{ value: 'other', label: f.typeOther },
];
const handleImagePick = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (images.length >= MAX_IMAGES) {
setToast({ type: 'error', message: f.tooManyImages });
return;
}
if (file.size > MAX_IMAGE_SIZE) {
setToast({ type: 'error', message: f.imageTooLarge });
return;
}
setImages([...images, file]);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const handleRemoveImage = (index: number) => {
setImages(images.filter((_, i) => i !== index));
};
const handleSubmit = async () => {
const trimmedContent = content.trim();
if (!trimmedContent) {
setToast({ type: 'error', message: f.contentRequired });
return;
}
if (trimmedContent.length > MAX_CONTENT_SIZE) {
setToast({ type: 'error', message: f.contentTooLong });
return;
}
setSubmitting(true);
setToast(null);
try {
const formData = new FormData();
formData.append('feedback_type', selectedType);
formData.append('content', trimmedContent);
formData.append('device_info', JSON.stringify({ platform: 'web', model: navigator.userAgent }));
formData.append('app_version', '1.0.0');
formData.append('os_version', navigator.platform);
for (const image of images) {
formData.append('images', image);
}
if (isAnonymous) {
// Anonymous submission - no auth header
const res = await fetch(apiUrl(API_ROUTES.feedback.submit), {
method: 'POST',
body: formData,
});
if (!res.ok) {
throw new Error('Submit failed');
}
} else {
// Authenticated submission
await authFetch(API_ROUTES.feedback.submit, {
method: 'POST',
body: formData,
});
}
setToast({ type: 'success', message: f.success });
setTimeout(() => navigate(`/${locale}/settings`), 1500);
} catch {
setToast({ type: 'error', message: f.error });
} finally {
setSubmitting(false);
}
};
return (
<div className="flex flex-col gap-6 min-h-full">
{/* Page Header */}
<div className="flex items-center gap-3">
<button
onClick={() => navigate(`/${locale}/settings`)}
className="w-8 h-8 rounded-lg bg-white border border-slate-200 flex items-center justify-center hover:bg-slate-50 transition-colors"
>
<span className="material-symbols-rounded text-slate-500 text-lg">arrow_back</span>
</button>
<h1 className="text-slate-900 text-xl font-bold">{f.title}</h1>
</div>
{/* Toast */}
{toast && (
<div className={`px-4 py-3 rounded-lg text-sm ${toast.type === 'success' ? 'bg-green-50 text-green-600' : 'bg-red-50 text-red-500'}`}>
{toast.message}
</div>
)}
{/* Feedback Type */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
<h3 className="text-slate-900 text-lg font-bold">{f.typeLabel}</h3>
<div className="flex gap-2">
{typeOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setSelectedType(opt.value)}
className={`flex-1 px-4 py-2.5 rounded-lg text-sm font-medium transition-colors ${selectedType === opt.value ? 'bg-violet-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Content */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
<h3 className="text-slate-900 text-lg font-bold">{f.contentLabel}</h3>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={f.contentPlaceholder}
rows={6}
maxLength={MAX_CONTENT_SIZE}
className="w-full px-4 py-3 rounded-xl bg-slate-50 border border-slate-200 text-sm focus:outline-none focus:border-violet-400 focus:ring-1 focus:ring-violet-400 resize-none"
/>
<p className="text-slate-400 text-xs text-right">{content.length}/{MAX_CONTENT_SIZE}</p>
</div>
{/* Images */}
<div className="bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4">
<h3 className="text-slate-900 text-lg font-bold">{f.imagesLabel}</h3>
<div className="flex flex-wrap gap-3">
{images.map((img, idx) => (
<div key={idx} className="relative w-20 h-20 rounded-xl border border-slate-200 overflow-hidden bg-slate-50">
<img src={URL.createObjectURL(img)} alt="Preview" className="w-full h-full object-cover" />
<button
onClick={() => handleRemoveImage(idx)}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center text-xs hover:bg-red-600"
>
<span className="material-symbols-rounded text-sm">close</span>
</button>
</div>
))}
{images.length < MAX_IMAGES && (
<label className="w-20 h-20 rounded-xl border border-dashed border-slate-300 bg-slate-50 flex items-center justify-center cursor-pointer hover:bg-slate-100 transition-colors">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleImagePick}
className="hidden"
/>
<span className="material-symbols-rounded text-slate-400 text-2xl">add_photo_alternate</span>
</label>
)}
</div>
</div>
{/* Anonymous Option */}
<div className="bg-white rounded-2xl p-6 border border-slate-200">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={isAnonymous}
onChange={(e) => setIsAnonymous(e.target.checked)}
className="mt-1 w-4 h-4 rounded border-slate-300 text-violet-600 focus:ring-violet-500"
/>
<div>
<p className="text-slate-900 text-sm font-medium">{f.anonymousLabel}</p>
<p className="text-slate-400 text-xs mt-0.5">{f.anonymousHint}</p>
</div>
</label>
</div>
{/* Submit Button */}
<button
onClick={handleSubmit}
disabled={submitting}
className="w-full py-3.5 rounded-xl bg-violet-600 text-white text-sm font-semibold hover:bg-violet-700 disabled:opacity-50 transition-colors"
>
{submitting ? f.submitting : f.submitBtn}
</button>
</div>
);
}