Add backend feedback submission and image preview
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-08 21:47:45 +08:00
parent b2ac92e0fc
commit 199b44c18c
38 changed files with 1521 additions and 140 deletions

View File

@@ -185,7 +185,10 @@ import {
recordRpgEntryWorldGalleryPlay,
remixRpgEntryWorldGallery,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient';
import {
getRpgProfilePlayStats,
submitRpgProfileFeedback,
} from '../../services/rpg-entry/rpgProfileClient';
import { requestRpgRuntimeJson } from '../../services/rpg-runtime/rpgRuntimeRequest';
import { squareHoleCreationClient } from '../../services/square-hole-creation';
import {
@@ -5510,6 +5513,7 @@ export function PlatformEntryFlowShellImpl({
setPlatformTab('profile');
setSelectionStage('platform');
}}
onSubmit={submitRpgProfileFeedback}
/>
</motion.div>
)}

View File

@@ -1,10 +1,25 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { beforeEach, expect, test, vi } from 'vitest';
import { PlatformFeedbackView } from './PlatformFeedbackView';
class MockFileReader {
result: string | ArrayBuffer | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL(file: File) {
this.result = `data:${file.type};base64,ZmVlZGJhY2s=`;
this.onload?.();
}
}
beforeEach(() => {
vi.stubGlobal('FileReader', MockFileReader);
});
test('PlatformFeedbackView renders reference feedback fields', () => {
render(<PlatformFeedbackView onBack={vi.fn()} />);
@@ -48,11 +63,45 @@ test('PlatformFeedbackView submits trimmed payload', async () => {
expect(onSubmit).toHaveBeenCalledWith({
description: '这个反馈页面无法正常上传图片',
contactPhone: '13800000000',
evidenceFiles: [],
evidenceItems: [],
});
await waitFor(() => expect(screen.getByText('反馈已提交')).toBeTruthy());
});
test('PlatformFeedbackView previews image data urls and submits evidence items', async () => {
const onSubmit = vi.fn();
render(<PlatformFeedbackView onBack={vi.fn()} onSubmit={onSubmit} />);
const file = new File(['feedback'], 'preview.png', { type: 'image/png' });
fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, {
target: { files: [file] },
});
const preview = await screen.findByAltText('反馈凭证预览');
expect(preview.getAttribute('src')).toBe(
'data:image/png;base64,ZmVlZGJhY2s=',
);
fireEvent.change(screen.getByLabelText('问题描述'), {
target: { value: '图片上传后现在应该展示预览' },
});
fireEvent.click(screen.getByRole('button', { name: '提交' }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit).toHaveBeenCalledWith({
description: '图片上传后现在应该展示预览',
contactPhone: null,
evidenceItems: [
{
fileName: 'preview.png',
contentType: 'image/png',
sizeBytes: file.size,
dataUrl: 'data:image/png;base64,ZmVlZGJhY2s=',
},
],
});
});
test('PlatformFeedbackView calls back from header home button', () => {
const onBack = vi.fn();
render(<PlatformFeedbackView onBack={onBack} />);

View File

@@ -1,32 +1,53 @@
import { ArrowLeft, CheckCircle2, Home, ImagePlus, Send, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
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;
evidenceFiles: File[];
contactPhone?: string | null;
evidenceItems: ProfileFeedbackEvidenceItemInput[];
};
export type PlatformFeedbackViewProps = {
onBack: () => void;
onSubmit?: (payload: PlatformFeedbackPayload) => void | Promise<void>;
onSubmit?: (payload: PlatformFeedbackPayload) => void | Promise<unknown>;
};
type EvidencePreview = {
id: string;
file: File;
url: 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,
@@ -41,17 +62,6 @@ export function PlatformFeedbackView({
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);
@@ -75,16 +85,18 @@ export function PlatformFeedbackView({
};
const addEvidenceFiles = (files: FileList | null) => {
const selectedFiles = files ? Array.from(files) : [];
if (evidenceInputRef.current) {
evidenceInputRef.current.value = '';
}
if (!files || files.length === 0) {
if (selectedFiles.length === 0) {
return;
}
const selectedFiles = Array.from(files).filter((file) =>
file.type.startsWith('image/'),
);
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('最多上传四张凭证');
@@ -95,23 +107,49 @@ export function PlatformFeedbackView({
return;
}
const nextPreviews = nextFiles.map((file, index) => ({
id: buildEvidencePreviewId(file, evidencePreviews.length + index),
file,
url: URL.createObjectURL(file),
}));
setEvidencePreviews((currentPreviews) => [...currentPreviews, ...nextPreviews]);
setSubmitted(false);
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) => {
const previewToRemove = currentPreviews.find((preview) => preview.id === id);
if (previewToRemove) {
URL.revokeObjectURL(previewToRemove.url);
}
return currentPreviews.filter((preview) => preview.id !== id);
});
setEvidencePreviews((currentPreviews) =>
currentPreviews.filter((preview) => preview.id !== id),
);
setError(null);
setSubmitted(false);
};
@@ -138,7 +176,7 @@ export function PlatformFeedbackView({
setSubmitted(false);
return;
}
if (evidenceFiles.length > MAX_FEEDBACK_EVIDENCE_COUNT) {
if (evidencePreviews.length > MAX_FEEDBACK_EVIDENCE_COUNT) {
setError('最多上传四张凭证');
setSubmitted(false);
return;
@@ -146,12 +184,16 @@ export function PlatformFeedbackView({
setIsSubmitting(true);
setError(null);
// 中文注释:首版反馈页只完成前端收集与成功态;接入后端时在 onSubmit 中替换为 API 调用。
void Promise.resolve(
onSubmit?.({
description: trimmedDescription,
contactPhone: trimmedContactPhone,
evidenceFiles,
contactPhone: trimmedContactPhone || null,
evidenceItems: evidencePreviews.map((preview) => ({
fileName: preview.fileName,
contentType: preview.contentType,
sizeBytes: preview.sizeBytes,
dataUrl: preview.dataUrl,
})),
}),
)
.then(() => setSubmitted(true))
@@ -163,38 +205,32 @@ export function PlatformFeedbackView({
};
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 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 px-4 pt-5">
<div className="text-sm font-medium text-[#8b93a1]"></div>
<main className="flex flex-1 flex-col gap-3">
<div className="text-sm font-semibold text-[var(--platform-text-soft)]">
</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]">
<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
@@ -203,25 +239,25 @@ export function PlatformFeedbackView({
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]"
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-[#a5adba]">
<div className="text-right text-xs text-[var(--platform-text-soft)]">
{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]">
<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-[#e3e6eb] bg-[#f7f8fa]"
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.url}
src={preview.dataUrl}
alt="反馈凭证预览"
className="h-full w-full object-cover"
/>
@@ -239,7 +275,7 @@ export function PlatformFeedbackView({
<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]"
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>
@@ -257,8 +293,11 @@ export function PlatformFeedbackView({
/>
</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]">
<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
@@ -268,23 +307,23 @@ export function PlatformFeedbackView({
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]"
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-2xl bg-rose-50 px-4 py-3 text-sm font-medium text-rose-600">
<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-2xl bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700">
<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-2xl bg-blue-50 px-4 py-3 text-sm font-medium text-[#2f7cf6]">
<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}
@@ -293,7 +332,7 @@ export function PlatformFeedbackView({
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"
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 ? '提交中' : '提交'}
@@ -302,19 +341,10 @@ export function PlatformFeedbackView({
<button
type="button"
onClick={() => showTemporaryNotice('反馈记录暂未开放')}
className="self-center px-3 py-2 text-sm font-medium text-[#2f7cf6]"
className="self-center px-3 py-2 text-sm font-semibold text-[var(--platform-cool-text)]"
>
</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>

View File

@@ -9,6 +9,7 @@ import {
listRpgProfileBrowseHistory,
listRpgProfileSaveArchives,
resumeRpgProfileSaveArchive,
submitRpgProfileFeedback,
syncRpgProfileBrowseHistory,
upsertRpgProfileBrowseHistory,
} from './rpgProfileClient';
@@ -156,3 +157,59 @@ describe('rpgProfileClient save archive routes', () => {
);
});
});
describe('rpgProfileClient feedback routes', () => {
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({
feedback: {
feedbackId: 'feedback:user-1:1',
status: 'open',
createdAt: '2026-05-08T10:00:00Z',
evidenceItems: [],
},
});
});
it('submits profile feedback through the profile route', async () => {
await submitRpgProfileFeedback({
description: '图片上传后没有展示预览',
contactPhone: null,
evidenceItems: [
{
fileName: 'preview.png',
contentType: 'image/png',
sizeBytes: 128,
dataUrl: 'data:image/png;base64,ZmVlZGJhY2s=',
},
],
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/profile/feedback',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'提交反馈失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
expect(JSON.parse(requestJsonMock.mock.calls[0][1].body)).toEqual({
description: '图片上传后没有展示预览',
contactPhone: null,
evidenceItems: [
{
fileName: 'preview.png',
contentType: 'image/png',
sizeBytes: 128,
dataUrl: 'data:image/png;base64,ZmVlZGJhY2s=',
},
],
});
});
});

View File

@@ -15,6 +15,8 @@ import type {
RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeResponse,
RuntimeSettings,
SubmitProfileFeedbackRequest,
SubmitProfileFeedbackResponse,
} from '../../../packages/shared/src/contracts/runtime';
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
@@ -101,6 +103,22 @@ export function createRpgProfileRechargeOrder(
);
}
export function submitRpgProfileFeedback(
payload: SubmitProfileFeedbackRequest,
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<SubmitProfileFeedbackResponse>(
'/profile/feedback',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'提交反馈失败',
options,
);
}
export function getRpgProfileReferralInviteCenter(
options: RuntimeRequestOptions = {},
) {
@@ -276,6 +294,7 @@ export const rpgProfileClient = {
getWalletLedger: getRpgProfileWalletLedger,
getRechargeCenter: getRpgProfileRechargeCenter,
createRechargeOrder: createRpgProfileRechargeOrder,
submitFeedback: submitRpgProfileFeedback,
getReferralInviteCenter: getRpgProfileReferralInviteCenter,
redeemReferralInviteCode: redeemRpgProfileReferralInviteCode,
getTasks: getRpgProfileTasks,