将反馈页问题描述迁移到 PlatformTextField 将反馈页联系电话迁移到 PlatformTextField 将反馈页字段标题迁移到 PlatformFieldLabel 补充反馈页公共输入字段断言 更新 PlatformUiKit 文档和 Hermes 决策记录
372 lines
12 KiB
TypeScript
372 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|