Files
Genarrative/src/components/platform-entry/PlatformFeedbackView.tsx
kdletters e019ece907 收口反馈页输入字段
将反馈页问题描述迁移到 PlatformTextField

将反馈页联系电话迁移到 PlatformTextField

将反馈页字段标题迁移到 PlatformFieldLabel

补充反馈页公共输入字段断言

更新 PlatformUiKit 文档和 Hermes 决策记录
2026-06-10 14:20:36 +08:00

372 lines
12 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, Send } from 'lucide-react';
import { useRef, useState } from 'react';
import type { ProfileFeedbackEvidenceItemInput } from '../../../packages/shared/src/contracts/runtime';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { PlatformTextField } from '../common/PlatformTextField';
import { PlatformUploadPreviewCard } from '../common/PlatformUploadPreviewCard';
import { PlatformUploadTile } from '../common/PlatformUploadTile';
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">
<PlatformActionButton
onClick={onBack}
aria-label="返回我的页签"
tone="ghost"
shape="pill"
size="xs"
className="h-10 w-10 shrink-0 p-0"
>
<ArrowLeft className="h-[1.125rem] w-[1.125rem]" />
</PlatformActionButton>
<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>
<PlatformSubpanel radius="md">
<label
htmlFor="profile-feedback-description"
className="block"
>
<PlatformFieldLabel
variant="form"
className="mb-0 text-base font-semibold"
>
</PlatformFieldLabel>
</label>
<PlatformTextField
variant="textarea"
id="profile-feedback-description"
value={description}
maxLength={MAX_FEEDBACK_DESCRIPTION_LENGTH}
onChange={(event) => updateDescription(event.target.value)}
placeholder="请填写10个字以上的问题描述以便我们提供更好的帮助温馨提醒您请勿填写身份证号等个人隐私信息。"
density="roomy"
size="md"
className="mt-3 min-h-[10.5rem] !rounded-none !border-0 !bg-transparent !px-0 !py-0 text-[var(--platform-text-strong)] placeholder:text-[var(--platform-text-soft)] focus:!bg-transparent focus:!ring-0"
/>
<div className="text-right text-xs text-[var(--platform-text-soft)]">
{descriptionLength}/{MAX_FEEDBACK_DESCRIPTION_LENGTH}
</div>
</PlatformSubpanel>
<PlatformSubpanel radius="md">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
()
</div>
<div className="mt-4 flex flex-wrap gap-3">
{evidencePreviews.map((preview) => (
<PlatformUploadPreviewCard
key={preview.id}
imageSrc={preview.dataUrl}
imageAlt="反馈凭证预览"
removeLabel="移除上传凭证"
onRemove={() => removeEvidencePreview(preview.id)}
/>
))}
{evidencePreviews.length < MAX_FEEDBACK_EVIDENCE_COUNT ? (
<PlatformUploadTile
onClick={openEvidencePicker}
label="上传凭证"
hint="(最多四张)"
/>
) : null}
</div>
<input
ref={evidenceInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => addEvidenceFiles(event.target.files)}
/>
</PlatformSubpanel>
<PlatformSubpanel radius="md">
<label
htmlFor="profile-feedback-phone"
className="block"
>
<PlatformFieldLabel
variant="form"
className="mb-0 text-base font-semibold"
>
</PlatformFieldLabel>
</label>
<PlatformTextField
id="profile-feedback-phone"
type="tel"
value={contactPhone}
maxLength={MAX_CONTACT_PHONE_LENGTH}
onChange={(event) => updateContactPhone(event.target.value)}
placeholder="选填,如您填写则将会同步开发者与您联系"
size="md"
density="compact"
className="mt-3 !rounded-none !border-0 !bg-transparent !px-0 !py-0 text-[var(--platform-text-strong)] placeholder:text-[var(--platform-text-soft)] focus:!bg-transparent focus:!ring-0"
/>
</PlatformSubpanel>
{error ? (
<PlatformStatusMessage tone="error" surface="profile">
{error}
</PlatformStatusMessage>
) : null}
{submitted ? (
<PlatformStatusMessage tone="success" surface="profile">
<CheckCircle2 className="h-4 w-4" />
</PlatformStatusMessage>
) : null}
{notice ? (
<PlatformStatusMessage tone="info" surface="profile">
{notice}
</PlatformStatusMessage>
) : null}
<PlatformActionButton
fullWidth
size="md"
className="mt-2 h-12 text-base"
onClick={submitFeedback}
disabled={isSubmitting}
>
<Send className="h-4 w-4" />
{isSubmitting ? '提交中' : '提交'}
</PlatformActionButton>
<PlatformActionButton
tone="ghost"
shape="pill"
size="xs"
onClick={() => showTemporaryNotice('反馈记录暂未开放')}
className="self-center"
>
</PlatformActionButton>
</main>
</div>
</div>
);
}