Files
Genarrative/src/components/platform-entry/PlatformFeedbackView.tsx
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

424 lines
14 KiB
TypeScript

import { ArrowLeft, CheckCircle2, Send } from 'lucide-react';
import { useRef, useState } from 'react';
import type { ProfileFeedbackEvidenceItemInput } from '../../../packages/shared/src/contracts/runtime';
import {
canUseNativeHostCapability,
captureHostImageFile,
type HostFileImportImageResult,
importHostImageFile,
} from '../../services/host-bridge/hostBridge';
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);
});
}
function hostEvidenceImageResultToFile(result: HostFileImportImageResult) {
const binary = atob(result.base64Data);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new File([bytes], result.fileName, { type: result.mimeType });
}
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 canCaptureHostEvidenceImage =
canUseNativeHostCapability('file.captureImage');
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 addHostEvidenceImage = (imageFile: HostFileImportImageResult) => {
addEvidenceFiles([hostEvidenceImageResultToFile(imageFile)]);
};
const openEvidencePicker = () => {
if (canUseNativeHostCapability('file.importImage')) {
void (async () => {
const importedImageFile = await importHostImageFile();
if (!importedImageFile) {
return;
}
addHostEvidenceImage(importedImageFile);
})();
return;
}
evidenceInputRef.current?.click();
};
const captureEvidenceImage = () => {
if (!canCaptureHostEvidenceImage) {
return;
}
void (async () => {
const capturedImageFile = await captureHostImageFile();
if (!capturedImageFile) {
return;
}
addHostEvidenceImage(capturedImageFile);
})();
};
const addEvidenceFiles = (files: FileList | File[] | 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="(最多四张)"
/>
{canCaptureHostEvidenceImage ? (
<PlatformUploadTile
onClick={captureEvidenceImage}
label="拍摄凭证"
hint="(最多四张)"
/>
) : null}
</>
) : 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>
);
}