Files
Genarrative/src/components/platform-entry/PlatformFeedbackView.tsx
kdletters 199b44c18c
Some checks failed
CI / verify (push) Has been cancelled
Add backend feedback submission and image preview
2026-05-08 21:47:45 +08:00

353 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ArrowLeft, CheckCircle2, ImagePlus, Send, X } from 'lucide-react';
import { useRef, useState } from 'react';
import type { ProfileFeedbackEvidenceItemInput } from '../../../packages/shared/src/contracts/runtime';
const MIN_FEEDBACK_DESCRIPTION_LENGTH = 10;
const MAX_FEEDBACK_DESCRIPTION_LENGTH = 200;
const MAX_FEEDBACK_EVIDENCE_COUNT = 4;
const MAX_CONTACT_PHONE_LENGTH = 40;
const MAX_FEEDBACK_EVIDENCE_BYTES = 1_048_576;
const MAX_FEEDBACK_EVIDENCE_TOTAL_BYTES = 4_194_304;
export type PlatformFeedbackPayload = {
description: string;
contactPhone?: string | null;
evidenceItems: ProfileFeedbackEvidenceItemInput[];
};
export type PlatformFeedbackViewProps = {
onBack: () => void;
onSubmit?: (payload: PlatformFeedbackPayload) => void | Promise<unknown>;
};
type EvidencePreview = {
id: string;
fileName: string;
contentType: string;
sizeBytes: number;
dataUrl: string;
};
function buildEvidencePreviewId(file: File, index: number) {
return `${file.name}:${file.size}:${file.lastModified}:${index}`;
}
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('图片读取失败'));
}
};
reader.onerror = () => reject(new Error('图片读取失败'));
reader.readAsDataURL(file);
});
}
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 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) => {
const selectedFiles = files ? Array.from(files) : [];
if (evidenceInputRef.current) {
evidenceInputRef.current.value = '';
}
if (selectedFiles.length === 0) {
return;
}
if (selectedFiles.some((file) => !file.type.startsWith('image/'))) {
setError('反馈凭证只支持图片类型');
return;
}
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;
}
if (nextFiles.some((file) => file.size > MAX_FEEDBACK_EVIDENCE_BYTES)) {
setError('单张反馈凭证不能超过 1MB');
return;
}
const currentTotalBytes = evidencePreviews.reduce(
(total, preview) => total + preview.sizeBytes,
0,
);
const nextTotalBytes = nextFiles.reduce(
(total, file) => total + file.size,
currentTotalBytes,
);
if (nextTotalBytes > MAX_FEEDBACK_EVIDENCE_TOTAL_BYTES) {
setError('反馈凭证总大小不能超过 4MB');
return;
}
Promise.all(
nextFiles.map(async (file, index) => ({
id: buildEvidencePreviewId(file, evidencePreviews.length + index),
fileName: file.name,
contentType: file.type,
sizeBytes: file.size,
dataUrl: await readFileAsDataUrl(file),
})),
)
.then((nextPreviews) => {
setEvidencePreviews((currentPreviews) => [
...currentPreviews,
...nextPreviews,
]);
setError(null);
setSubmitted(false);
})
.catch((readError: unknown) => {
setError(readError instanceof Error ? readError.message : '图片读取失败');
});
};
const removeEvidencePreview = (id: string) => {
setEvidencePreviews((currentPreviews) =>
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 (evidencePreviews.length > MAX_FEEDBACK_EVIDENCE_COUNT) {
setError('最多上传四张凭证');
setSubmitted(false);
return;
}
setIsSubmitting(true);
setError(null);
void Promise.resolve(
onSubmit?.({
description: trimmedDescription,
contactPhone: trimmedContactPhone || null,
evidenceItems: evidencePreviews.map((preview) => ({
fileName: preview.fileName,
contentType: preview.contentType,
sizeBytes: preview.sizeBytes,
dataUrl: preview.dataUrl,
})),
}),
)
.then(() => setSubmitted(true))
.catch((submitError: unknown) => {
setSubmitted(false);
setError(submitError instanceof Error ? submitError.message : '提交失败');
})
.finally(() => setIsSubmitting(false));
};
return (
<div className="platform-page-stage platform-remap-surface min-h-0 flex-1 overflow-y-auto text-[var(--platform-text-strong)]">
<div className="mx-auto flex min-h-full w-full max-w-[30rem] flex-col px-4 pb-6 pt-4">
<header className="flex shrink-0 items-center gap-3 pb-3">
<button
type="button"
onClick={onBack}
aria-label="返回我的页签"
className="platform-button platform-button--ghost h-10 w-10 shrink-0 justify-center rounded-full p-0"
>
<ArrowLeft className="h-[1.125rem] w-[1.125rem]" />
</button>
<div className="min-w-0 text-base font-black text-[var(--platform-text-strong)]">
</div>
</header>
<main className="flex flex-1 flex-col gap-3">
<div className="text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
<section className="platform-subpanel rounded-[1.2rem] px-4 py-4">
<label
htmlFor="profile-feedback-description"
className="block text-base font-semibold text-[var(--platform-text-strong)]"
>
</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-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/>
<div className="text-right text-xs text-[var(--platform-text-soft)]">
{descriptionLength}/{MAX_FEEDBACK_DESCRIPTION_LENGTH}
</div>
</section>
<section className="platform-subpanel rounded-[1.2rem] px-4 py-4">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
()
</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-[var(--platform-subpanel-border)] bg-[var(--platform-input-fill)]"
>
<img
src={preview.dataUrl}
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-[var(--platform-subpanel-border)] bg-[var(--platform-input-fill)] text-[var(--platform-text-soft)] transition hover:border-[var(--platform-surface-hover-border)] hover:text-[var(--platform-text-strong)]"
>
<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="platform-subpanel rounded-[1.2rem] px-4 py-4">
<label
htmlFor="profile-feedback-phone"
className="block text-base font-semibold text-[var(--platform-text-strong)]"
>
</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-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
/>
</section>
{error ? (
<div className="rounded-[1.2rem] border border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] px-4 py-3 text-sm font-medium text-[var(--platform-button-danger-text)]">
{error}
</div>
) : null}
{submitted ? (
<div className="flex items-center gap-2 rounded-[1.2rem] border border-[var(--platform-success-border)] bg-[var(--platform-success-bg)] px-4 py-3 text-sm font-medium text-[var(--platform-success-text)]">
<CheckCircle2 className="h-4 w-4" />
</div>
) : null}
{notice ? (
<div className="rounded-[1.2rem] border border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] px-4 py-3 text-sm font-medium text-[var(--platform-cool-text)]">
{notice}
</div>
) : null}
<button
type="button"
onClick={submitFeedback}
disabled={isSubmitting}
className="platform-button platform-button--primary mt-2 h-12 w-full justify-center text-base 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-semibold text-[var(--platform-cool-text)]"
>
</button>
</main>
</div>
</div>
);
}