feat: add platform feedback view

This commit is contained in:
2026-05-08 12:21:55 +08:00
parent 3b0dd2ebeb
commit 7eb531ccca

View File

@@ -0,0 +1,322 @@
import { ArrowLeft, CheckCircle2, Home, ImagePlus, Send, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
const MIN_FEEDBACK_DESCRIPTION_LENGTH = 10;
const MAX_FEEDBACK_DESCRIPTION_LENGTH = 200;
const MAX_FEEDBACK_EVIDENCE_COUNT = 4;
const MAX_CONTACT_PHONE_LENGTH = 40;
export type PlatformFeedbackPayload = {
description: string;
contactPhone: string;
evidenceFiles: File[];
};
export type PlatformFeedbackViewProps = {
onBack: () => void;
onSubmit?: (payload: PlatformFeedbackPayload) => void | Promise<void>;
};
type EvidencePreview = {
id: string;
file: File;
url: string;
};
function buildEvidencePreviewId(file: File, index: number) {
return `${file.name}:${file.size}:${file.lastModified}:${index}`;
}
export function PlatformFeedbackView({
onBack,
onSubmit,
}: PlatformFeedbackViewProps) {
const evidenceInputRef = useRef<HTMLInputElement | null>(null);
const [description, setDescription] = useState('');
const [contactPhone, setContactPhone] = useState('');
const [evidencePreviews, setEvidencePreviews] = useState<EvidencePreview[]>([]);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const descriptionLength = description.length;
const evidenceFiles = useMemo(
() => evidencePreviews.map((preview) => preview.file),
[evidencePreviews],
);
useEffect(
() => () => {
evidencePreviews.forEach((preview) => URL.revokeObjectURL(preview.url));
},
[evidencePreviews],
);
const showTemporaryNotice = (message: string) => {
setNotice(message);
window.setTimeout(() => setNotice(null), 1600);
};
const updateDescription = (value: string) => {
setDescription(value.slice(0, MAX_FEEDBACK_DESCRIPTION_LENGTH));
setError(null);
setSubmitted(false);
};
const updateContactPhone = (value: string) => {
setContactPhone(value.slice(0, MAX_CONTACT_PHONE_LENGTH));
setError(null);
setSubmitted(false);
};
const openEvidencePicker = () => {
evidenceInputRef.current?.click();
};
const addEvidenceFiles = (files: FileList | null) => {
if (evidenceInputRef.current) {
evidenceInputRef.current.value = '';
}
if (!files || files.length === 0) {
return;
}
const selectedFiles = Array.from(files).filter((file) =>
file.type.startsWith('image/'),
);
const remainingCount = MAX_FEEDBACK_EVIDENCE_COUNT - evidencePreviews.length;
if (remainingCount <= 0 || selectedFiles.length > remainingCount) {
setError('最多上传四张凭证');
}
const nextFiles = selectedFiles.slice(0, Math.max(remainingCount, 0));
if (nextFiles.length === 0) {
return;
}
const nextPreviews = nextFiles.map((file, index) => ({
id: buildEvidencePreviewId(file, evidencePreviews.length + index),
file,
url: URL.createObjectURL(file),
}));
setEvidencePreviews((currentPreviews) => [...currentPreviews, ...nextPreviews]);
setSubmitted(false);
};
const removeEvidencePreview = (id: string) => {
setEvidencePreviews((currentPreviews) => {
const previewToRemove = currentPreviews.find((preview) => preview.id === id);
if (previewToRemove) {
URL.revokeObjectURL(previewToRemove.url);
}
return currentPreviews.filter((preview) => preview.id !== id);
});
setError(null);
setSubmitted(false);
};
const submitFeedback = () => {
if (isSubmitting) {
return;
}
const trimmedDescription = description.trim();
const trimmedContactPhone = contactPhone.trim();
if (trimmedDescription.length < MIN_FEEDBACK_DESCRIPTION_LENGTH) {
setError('请填写10个字以上的问题描述');
setSubmitted(false);
return;
}
if (trimmedDescription.length > MAX_FEEDBACK_DESCRIPTION_LENGTH) {
setError('问题描述不能超过 200 字');
setSubmitted(false);
return;
}
if (trimmedContactPhone.length > MAX_CONTACT_PHONE_LENGTH) {
setError('联系电话不能超过 40 字');
setSubmitted(false);
return;
}
if (evidenceFiles.length > MAX_FEEDBACK_EVIDENCE_COUNT) {
setError('最多上传四张凭证');
setSubmitted(false);
return;
}
setIsSubmitting(true);
setError(null);
// 中文注释:首版反馈页只完成前端收集与成功态;接入后端时在 onSubmit 中替换为 API 调用。
void Promise.resolve(
onSubmit?.({
description: trimmedDescription,
contactPhone: trimmedContactPhone,
evidenceFiles,
}),
)
.then(() => setSubmitted(true))
.catch((submitError: unknown) => {
setSubmitted(false);
setError(submitError instanceof Error ? submitError.message : '提交失败');
})
.finally(() => setIsSubmitting(false));
};
return (
<div className="min-h-0 flex-1 overflow-y-auto bg-[#f5f6f8] text-[#202124]">
<div className="mx-auto flex min-h-full w-full max-w-[30rem] flex-col pb-8">
<header className="sticky top-0 z-10 rounded-b-[1.35rem] bg-white px-4 pb-3 pt-4 shadow-[0_8px_24px_rgba(15,23,42,0.05)]">
<div className="grid grid-cols-[2.5rem_1fr_5.75rem] items-center gap-2">
<button
type="button"
onClick={onBack}
aria-label="返回我的页签"
className="flex h-9 w-9 items-center justify-center rounded-full text-[#24262b] transition hover:bg-slate-100"
>
<Home className="h-5 w-5" />
</button>
<div className="text-center text-base font-semibold text-[#1f2329]">
</div>
<div
className="flex h-8 items-center justify-center gap-2 rounded-full border border-[#d8dce3] px-2 text-[#1f2329]"
aria-hidden="true"
>
<span className="text-lg leading-none">···</span>
<span className="h-4 w-px bg-[#d8dce3]" />
<span className="h-0.5 w-3 rounded-full bg-[#1f2329]" />
<span className="h-3.5 w-3.5 rounded-full border-2 border-[#1f2329]" />
</div>
</div>
</header>
<main className="flex flex-1 flex-col gap-3 px-4 pt-5">
<div className="text-sm font-medium text-[#8b93a1]"></div>
<section className="rounded-2xl bg-white px-4 py-4 shadow-[0_8px_24px_rgba(15,23,42,0.03)]">
<label htmlFor="profile-feedback-description" className="block text-base font-semibold text-[#1f2329]">
</label>
<textarea
id="profile-feedback-description"
value={description}
maxLength={MAX_FEEDBACK_DESCRIPTION_LENGTH}
onChange={(event) => updateDescription(event.target.value)}
placeholder="请填写10个字以上的问题描述以便我们提供更好的帮助温馨提醒您请勿填写身份证号等个人隐私信息。"
className="mt-3 min-h-[10.5rem] w-full resize-none border-0 bg-transparent text-sm leading-6 text-[#1f2329] outline-none placeholder:text-[#b4bac4]"
/>
<div className="text-right text-xs text-[#a5adba]">
{descriptionLength}/{MAX_FEEDBACK_DESCRIPTION_LENGTH}
</div>
</section>
<section className="rounded-2xl bg-white px-4 py-4 shadow-[0_8px_24px_rgba(15,23,42,0.03)]">
<div className="text-base font-semibold text-[#1f2329]">
()
</div>
<div className="mt-4 flex flex-wrap gap-3">
{evidencePreviews.map((preview) => (
<div
key={preview.id}
className="relative h-[5.75rem] w-[5.75rem] overflow-hidden rounded-xl border border-[#e3e6eb] bg-[#f7f8fa]"
>
<img
src={preview.url}
alt="反馈凭证预览"
className="h-full w-full object-cover"
/>
<button
type="button"
onClick={() => removeEvidencePreview(preview.id)}
aria-label="移除上传凭证"
className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/55 text-white"
>
<X className="h-3 w-3" />
</button>
</div>
))}
{evidencePreviews.length < MAX_FEEDBACK_EVIDENCE_COUNT ? (
<button
type="button"
onClick={openEvidencePicker}
className="flex h-[5.75rem] w-[5.75rem] flex-col items-center justify-center rounded-xl border border-dashed border-[#cdd3dc] bg-[#fbfcfd] text-[#9aa3af] transition hover:border-[#2f7cf6] hover:text-[#2f7cf6]"
>
<ImagePlus className="h-6 w-6" />
<span className="mt-2 text-xs font-medium"></span>
<span className="mt-0.5 text-[11px]">()</span>
</button>
) : null}
</div>
<input
ref={evidenceInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => addEvidenceFiles(event.target.files)}
/>
</section>
<section className="rounded-2xl bg-white px-4 py-4 shadow-[0_8px_24px_rgba(15,23,42,0.03)]">
<label htmlFor="profile-feedback-phone" className="block text-base font-semibold text-[#1f2329]">
</label>
<input
id="profile-feedback-phone"
type="tel"
value={contactPhone}
maxLength={MAX_CONTACT_PHONE_LENGTH}
onChange={(event) => updateContactPhone(event.target.value)}
placeholder="选填,如您填写则将会同步开发者与您联系"
className="mt-3 w-full border-0 bg-transparent text-sm leading-6 text-[#1f2329] outline-none placeholder:text-[#b4bac4]"
/>
</section>
{error ? (
<div className="rounded-2xl bg-rose-50 px-4 py-3 text-sm font-medium text-rose-600">
{error}
</div>
) : null}
{submitted ? (
<div className="flex items-center gap-2 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700">
<CheckCircle2 className="h-4 w-4" />
</div>
) : null}
{notice ? (
<div className="rounded-2xl bg-blue-50 px-4 py-3 text-sm font-medium text-[#2f7cf6]">
{notice}
</div>
) : null}
<button
type="button"
onClick={submitFeedback}
disabled={isSubmitting}
className="mt-2 flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-[#2f7cf6] text-base font-semibold text-white shadow-[0_10px_22px_rgba(47,124,246,0.26)] transition hover:bg-[#1f6bea] disabled:cursor-not-allowed disabled:opacity-60"
>
<Send className="h-4 w-4" />
{isSubmitting ? '提交中' : '提交'}
</button>
<button
type="button"
onClick={() => showTemporaryNotice('反馈记录暂未开放')}
className="self-center px-3 py-2 text-sm font-medium text-[#2f7cf6]"
>
</button>
<button
type="button"
onClick={onBack}
className="mt-auto flex items-center justify-center gap-2 self-center px-3 py-2 text-xs font-medium text-[#8b93a1]"
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
</main>
</div>
</div>
);
}