198 lines
6.0 KiB
TypeScript
198 lines
6.0 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { fetchJson } from '../auth/api-client';
|
|
import { useAuth } from '../auth/useAuth';
|
|
import { canManageFeedback } from '../shared/auth/roles';
|
|
import FeedbackHistoryDrawer from './FeedbackHistoryDrawer';
|
|
import FeedbackLauncher from './feedback/FeedbackLauncher';
|
|
import FeedbackModal from './feedback/FeedbackModal';
|
|
import type { FeedbackStep, FeedbackType, UploadedImg } from './feedback/types';
|
|
|
|
const MAX_SCREENSHOTS = 6;
|
|
const MAX_IMG_SIZE_MB = 5;
|
|
|
|
async function uploadImage(file: File): Promise<string> {
|
|
const fd = new FormData();
|
|
fd.append('file', file);
|
|
const token = sessionStorage.getItem('bi_jwt');
|
|
const res = await fetch('/api/feedback/upload', {
|
|
method: 'POST',
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
body: fd,
|
|
});
|
|
const json = await res.json();
|
|
if (!res.ok || !json.ok) throw new Error(json.message || `上传失败 (${res.status})`);
|
|
return json.url as string;
|
|
}
|
|
|
|
function readAsDataUrl(file: File): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const r = new FileReader();
|
|
r.onload = () => resolve(String(r.result || ''));
|
|
r.onerror = reject;
|
|
r.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
function detectModule(): string {
|
|
const hash = (window.location.hash || '').slice(1);
|
|
const path = window.location.pathname;
|
|
if (path.includes('/ele/')) return 'ele';
|
|
if (hash.includes('ele')) return 'ele';
|
|
if (hash.startsWith('/')) return hash.split('/')[1] || '';
|
|
return hash || '';
|
|
}
|
|
|
|
interface Props {
|
|
/** 显式覆盖当前模块(否则自动从 URL 检测) */
|
|
module?: string;
|
|
}
|
|
|
|
export default function FeedbackFab({ module: moduleProp }: Props = {}) {
|
|
const { user } = useAuth();
|
|
const isAdmin = canManageFeedback(user?.roles);
|
|
const [open, setOpen] = useState(false);
|
|
const [historyOpen, setHistoryOpen] = useState(false);
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [step, setStep] = useState<FeedbackStep>(1);
|
|
const [type, setType] = useState<FeedbackType | null>(null);
|
|
const [mod, setMod] = useState<string>('');
|
|
const [content, setContent] = useState('');
|
|
const [shots, setShots] = useState<UploadedImg[]>([]);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const taRef = useRef<HTMLTextAreaElement>(null);
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
|
|
const addFiles = async (files: FileList | File[]) => {
|
|
const list = Array.from(files).filter(f => f.type.startsWith('image/'));
|
|
if (list.length === 0) return;
|
|
setError(null);
|
|
setUploading(true);
|
|
try {
|
|
for (const f of list) {
|
|
if (shots.length >= MAX_SCREENSHOTS) break;
|
|
if (f.size > MAX_IMG_SIZE_MB * 1024 * 1024) {
|
|
setError(`「${f.name}」超过 ${MAX_IMG_SIZE_MB}MB`);
|
|
continue;
|
|
}
|
|
const thumbDataUrl = await readAsDataUrl(f);
|
|
const url = await uploadImage(f);
|
|
setShots(prev => prev.length >= MAX_SCREENSHOTS ? prev : [...prev, { url, thumbDataUrl }]);
|
|
}
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (open && step === 1) {
|
|
setMod(moduleProp ?? detectModule());
|
|
}
|
|
}, [open, step, moduleProp]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const prev = document.body.style.overflow;
|
|
document.body.style.overflow = 'hidden';
|
|
return () => { document.body.style.overflow = prev; };
|
|
}, [open]);
|
|
|
|
const reset = () => {
|
|
setStep(1);
|
|
setType(null);
|
|
setContent('');
|
|
setShots([]);
|
|
setError(null);
|
|
};
|
|
|
|
const close = () => {
|
|
setOpen(false);
|
|
setTimeout(reset, 300);
|
|
};
|
|
|
|
const submit = async () => {
|
|
if (!type || !content.trim()) return;
|
|
setSubmitting(true);
|
|
setError(null);
|
|
try {
|
|
await fetchJson('/api/feedback/submit', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
type,
|
|
module: mod || null,
|
|
content: content.trim(),
|
|
screenshots: shots.map(s => s.url),
|
|
userAgent: navigator.userAgent.slice(0, 500),
|
|
}),
|
|
});
|
|
setStep(3);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const next = () => {
|
|
if (step === 1) {
|
|
if (!type) return;
|
|
setStep(2);
|
|
return;
|
|
}
|
|
if (step === 2) {
|
|
if (!content.trim()) { taRef.current?.focus(); return; }
|
|
submit();
|
|
return;
|
|
}
|
|
};
|
|
|
|
const back = () => setStep((Math.max(1, step - 1)) as FeedbackStep);
|
|
|
|
const canNext = step === 1 ? !!type : step === 2 ? content.trim().length > 0 : true;
|
|
const progress = step >= 3 ? 100 : (step / 2) * 100;
|
|
|
|
return (
|
|
<>
|
|
<FeedbackLauncher
|
|
isAdmin={isAdmin}
|
|
menuOpen={menuOpen}
|
|
onCloseMenu={() => setMenuOpen(false)}
|
|
onOpenFeedback={() => { setMenuOpen(false); setOpen(true); }}
|
|
onOpenHistory={() => { setMenuOpen(false); setHistoryOpen(true); }}
|
|
onToggleMenu={() => setMenuOpen(current => !current)}
|
|
/>
|
|
|
|
<FeedbackHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
|
|
|
|
<FeedbackModal
|
|
canNext={canNext}
|
|
content={content}
|
|
error={error}
|
|
fileRef={fileRef}
|
|
maxScreenshots={MAX_SCREENSHOTS}
|
|
module={mod}
|
|
open={open}
|
|
progress={progress}
|
|
screenshots={shots}
|
|
step={step}
|
|
submitting={submitting}
|
|
textareaRef={taRef}
|
|
type={type}
|
|
uploading={uploading}
|
|
onAddFiles={addFiles}
|
|
onBack={back}
|
|
onChangeContent={setContent}
|
|
onChangeModule={setMod}
|
|
onClose={close}
|
|
onNext={next}
|
|
onRemoveScreenshot={(index) => setShots(prev => prev.filter((_, current) => current !== index))}
|
|
onSelectType={(selectedType) => { setType(selectedType); setStep(2); }}
|
|
/>
|
|
</>
|
|
);
|
|
}
|