a1b4418d55
- 二级页面返回按钮导航到设置页而非路由栈上一页 - 通用设置开关等待后端响应后再更新 UI,失败时显示 toast - 删除用户名/邮箱的硬编码默认值,使用 auth token 邮箱作为 fallback - AppShell 侧边栏显示真实头像和用户名 - 页面加载时检查 URL 语言与用户偏好是否一致,不一致则重定向
215 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|