Merge branch 'master' of https://git.genarrative.world/GenarrativeAI/Genarrative
This commit is contained in:
@@ -31,6 +31,8 @@ function renderAccountModal(overrides?: {
|
||||
riskBlocks?: AuthRiskBlockSummary[];
|
||||
sessions?: AuthSessionSummary[];
|
||||
auditLogs?: AuthAuditLogEntry[];
|
||||
onRevokeSession?: (session: AuthSessionSummary) => Promise<void>;
|
||||
revokingSessionIds?: string[];
|
||||
initialSection?:
|
||||
| 'appearance'
|
||||
| 'account'
|
||||
@@ -63,7 +65,10 @@ function renderAccountModal(overrides?: {
|
||||
onRefreshSessions={vi.fn().mockResolvedValue(undefined)}
|
||||
onLogoutAll={vi.fn().mockResolvedValue(undefined)}
|
||||
onRefreshAuditLogs={vi.fn().mockResolvedValue(undefined)}
|
||||
onRevokeSession={vi.fn().mockResolvedValue(undefined)}
|
||||
onRevokeSession={
|
||||
overrides?.onRevokeSession ?? vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
revokingSessionIds={overrides?.revokingSessionIds ?? []}
|
||||
changePhoneCaptchaChallenge={null}
|
||||
onSendChangePhoneCode={vi.fn().mockResolvedValue({
|
||||
cooldownSeconds: 60,
|
||||
@@ -75,6 +80,30 @@ function renderAccountModal(overrides?: {
|
||||
);
|
||||
}
|
||||
|
||||
function buildSession(
|
||||
overrides: Partial<AuthSessionSummary> = {},
|
||||
): AuthSessionSummary {
|
||||
return {
|
||||
sessionId: 'usess_1',
|
||||
sessionIds: ['usess_1'],
|
||||
sessionCount: 1,
|
||||
clientType: 'web_browser',
|
||||
clientRuntime: 'chrome',
|
||||
clientPlatform: 'windows',
|
||||
clientLabel: 'Windows / Chrome',
|
||||
deviceDisplayName: 'Windows / Chrome',
|
||||
miniProgramAppId: null,
|
||||
miniProgramEnv: null,
|
||||
userAgent: 'Mozilla/5.0',
|
||||
ipMasked: '203.0.*.*',
|
||||
isCurrent: false,
|
||||
createdAt: '2026-05-01T10:00:00.000Z',
|
||||
lastSeenAt: '2026-05-01T10:30:00.000Z',
|
||||
expiresAt: '2026-06-01T10:30:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('settings header uses a generic title instead of the phone number', () => {
|
||||
renderAccountModal();
|
||||
|
||||
@@ -238,8 +267,10 @@ test('account panel includes merged security devices and audit sections', async
|
||||
},
|
||||
],
|
||||
sessions: [
|
||||
{
|
||||
buildSession({
|
||||
sessionId: 'session-1',
|
||||
sessionIds: ['session-1'],
|
||||
sessionCount: 1,
|
||||
clientType: 'mobile',
|
||||
clientRuntime: 'ios',
|
||||
clientPlatform: 'wechat',
|
||||
@@ -253,7 +284,7 @@ test('account panel includes merged security devices and audit sections', async
|
||||
lastSeenAt: '2026-04-20T09:00:00.000Z',
|
||||
expiresAt: '2026-04-27T09:00:00.000Z',
|
||||
ipMasked: '10.0.*.*',
|
||||
},
|
||||
}),
|
||||
],
|
||||
auditLogs: [
|
||||
{
|
||||
@@ -294,3 +325,77 @@ test('legacy nested section requests now open the merged account panel', () => {
|
||||
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('current merged session group hides kick action and shows count', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderAccountModal({
|
||||
sessions: [
|
||||
buildSession({
|
||||
sessionId: 'usess_current',
|
||||
sessionIds: ['usess_current', 'usess_rotated'],
|
||||
sessionCount: 2,
|
||||
isCurrent: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
|
||||
expect(
|
||||
within(accountDialog).queryByRole('button', { name: '踢下线' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('remote merged session group can be revoked with loading state', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRevokeSession = vi.fn().mockResolvedValue(undefined);
|
||||
const remoteSession = buildSession({
|
||||
sessionId: 'usess_remote',
|
||||
sessionIds: ['usess_remote', 'usess_remote_rotated'],
|
||||
sessionCount: 2,
|
||||
});
|
||||
|
||||
renderAccountModal({
|
||||
sessions: [remoteSession],
|
||||
onRevokeSession,
|
||||
revokingSessionIds: ['usess_remote'],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
const revokeButton = within(accountDialog).getByRole('button', {
|
||||
name: '处理中...',
|
||||
}) as HTMLButtonElement;
|
||||
expect(revokeButton.disabled).toBe(true);
|
||||
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
|
||||
expect(onRevokeSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('remote session revoke passes the grouped session payload', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRevokeSession = vi.fn().mockResolvedValue(undefined);
|
||||
const remoteSession = buildSession({
|
||||
sessionId: 'usess_remote',
|
||||
sessionIds: ['usess_remote', 'usess_remote_rotated'],
|
||||
sessionCount: 2,
|
||||
});
|
||||
|
||||
renderAccountModal({
|
||||
sessions: [remoteSession],
|
||||
onRevokeSession,
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /账号信息/ }));
|
||||
await user.click(
|
||||
within(screen.getByRole('dialog', { name: '账号信息' })).getByRole(
|
||||
'button',
|
||||
{ name: '踢下线' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onRevokeSession).toHaveBeenCalledWith(remoteSession);
|
||||
});
|
||||
|
||||
@@ -40,7 +40,8 @@ type AccountModalProps = {
|
||||
onRefreshSessions: () => Promise<void>;
|
||||
onLogoutAll: () => Promise<void>;
|
||||
onRefreshAuditLogs: () => Promise<void>;
|
||||
onRevokeSession: (sessionId: string) => Promise<void>;
|
||||
onRevokeSession: (session: AuthSessionSummary) => Promise<void>;
|
||||
revokingSessionIds: string[];
|
||||
changePhoneCaptchaChallenge: AuthCaptchaChallenge | null;
|
||||
onSendChangePhoneCode: (
|
||||
phone: string,
|
||||
@@ -298,6 +299,7 @@ export function AccountModal({
|
||||
onLogoutAll,
|
||||
onRefreshAuditLogs,
|
||||
onRevokeSession,
|
||||
revokingSessionIds,
|
||||
changePhoneCaptchaChallenge,
|
||||
onSendChangePhoneCode,
|
||||
onChangePhone,
|
||||
@@ -759,41 +761,55 @@ export function AccountModal({
|
||||
正在读取当前登录设备...
|
||||
</div>
|
||||
) : sessions.length > 0 ? (
|
||||
sessions.map((session) => (
|
||||
<div
|
||||
key={session.sessionId}
|
||||
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{session.clientLabel}</span>
|
||||
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
|
||||
{session.isCurrent ? '当前设备' : '已登录'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
|
||||
最近活跃:{formatSessionTime(session.lastSeenAt)}
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
|
||||
到期时间:{formatSessionTime(session.expiresAt)}
|
||||
</div>
|
||||
{session.ipMasked ? (
|
||||
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
|
||||
IP:{session.ipMasked}
|
||||
sessions.map((session) => {
|
||||
const isRevoking = revokingSessionIds.includes(
|
||||
session.sessionId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.sessionId}
|
||||
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{session.clientLabel}</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{session.sessionCount > 1 ? (
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
{session.sessionCount} 个会话
|
||||
</span>
|
||||
) : null}
|
||||
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
|
||||
{session.isCurrent ? '当前设备' : '已登录'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!session.isCurrent ? (
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--danger mt-3 min-h-0 h-9 px-3 text-xs"
|
||||
onClick={() => {
|
||||
void onRevokeSession(session.sessionId);
|
||||
}}
|
||||
>
|
||||
踢下线
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
|
||||
最近活跃:{formatSessionTime(session.lastSeenAt)}
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
|
||||
到期时间:{formatSessionTime(session.expiresAt)}
|
||||
</div>
|
||||
{session.ipMasked ? (
|
||||
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
|
||||
IP:{session.ipMasked}
|
||||
</div>
|
||||
) : null}
|
||||
{!session.isCurrent ? (
|
||||
<button
|
||||
type="button"
|
||||
className="platform-button platform-button--danger mt-3 h-9 min-h-0 px-3 text-xs"
|
||||
disabled={isRevoking}
|
||||
onClick={() => {
|
||||
void onRevokeSession(session);
|
||||
}}
|
||||
>
|
||||
{isRevoking ? '处理中...' : '踢下线'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
|
||||
暂无可展示的登录设备。
|
||||
|
||||
@@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
|
||||
import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments';
|
||||
import { AuthGate } from './AuthGate';
|
||||
import { useAuthUi } from './AuthUiContext';
|
||||
@@ -23,6 +23,10 @@ const authMocks = vi.hoisted(() => ({
|
||||
logoutAuthUser: vi.fn(),
|
||||
redeemRegistrationInviteCode: vi.fn(),
|
||||
resetPassword: vi.fn(),
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthRiskBlocks: vi.fn(),
|
||||
getAuthSessions: vi.fn(),
|
||||
revokeAuthSessions: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
consumeAuthCallbackResult: vi.fn(),
|
||||
@@ -42,11 +46,11 @@ vi.mock('../../services/authService', () => ({
|
||||
changePhoneNumber: vi.fn(),
|
||||
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
|
||||
getStoredLastLoginPhone: vi.fn(() => ''),
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthAuditLogs: authMocks.getAuthAuditLogs,
|
||||
getAuthLoginOptions: authMocks.getAuthLoginOptions,
|
||||
getAuthRiskBlocks: vi.fn(),
|
||||
getAuthRiskBlocks: authMocks.getAuthRiskBlocks,
|
||||
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
||||
getAuthSessions: vi.fn(),
|
||||
getAuthSessions: authMocks.getAuthSessions,
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
liftAuthRiskBlock: vi.fn(),
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
@@ -54,7 +58,7 @@ vi.mock('../../services/authService', () => ({
|
||||
logoutAuthUser: authMocks.logoutAuthUser,
|
||||
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
|
||||
resetPassword: authMocks.resetPassword,
|
||||
revokeAuthSession: vi.fn(),
|
||||
revokeAuthSessions: authMocks.revokeAuthSessions,
|
||||
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
|
||||
setStoredLastLoginPhone: vi.fn(),
|
||||
startWechatLogin: authMocks.startWechatLogin,
|
||||
@@ -73,9 +77,12 @@ vi.mock('../../hooks/useGameSettings', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./AccountModal', () => ({
|
||||
AccountModal: () => null,
|
||||
}));
|
||||
vi.mock('./AccountModal', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('./AccountModal')>('./AccountModal');
|
||||
|
||||
return actual;
|
||||
});
|
||||
|
||||
vi.mock('./BindPhoneScreen', () => ({
|
||||
BindPhoneScreen: () => <div>绑定手机号</div>,
|
||||
@@ -116,6 +123,10 @@ beforeEach(() => {
|
||||
authMocks.changePassword.mockResolvedValue(mockUser);
|
||||
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
|
||||
authMocks.logoutAuthUser.mockResolvedValue(undefined);
|
||||
authMocks.getAuthAuditLogs.mockResolvedValue([]);
|
||||
authMocks.getAuthRiskBlocks.mockResolvedValue([]);
|
||||
authMocks.getAuthSessions.mockResolvedValue([]);
|
||||
authMocks.revokeAuthSessions.mockResolvedValue(undefined);
|
||||
authMocks.redeemRegistrationInviteCode.mockResolvedValue({
|
||||
center: {
|
||||
inviteCode: 'SY12345678',
|
||||
@@ -205,6 +216,21 @@ function LogoutStateProbe() {
|
||||
);
|
||||
}
|
||||
|
||||
function AccountPanelProbe() {
|
||||
const authUi = useAuthUi();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
authUi?.openAccountModal();
|
||||
}}
|
||||
>
|
||||
打开账号面板
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
test('auth gate keeps platform content visible when phone login is available', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
@@ -786,3 +812,101 @@ test('auth gate separates sms and password login by tabs', async () => {
|
||||
expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd');
|
||||
});
|
||||
});
|
||||
|
||||
test('auth gate revokes merged session group and refreshes sessions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const initialSessions: AuthSessionSummary[] = [
|
||||
{
|
||||
sessionId: 'usess_remote',
|
||||
sessionIds: ['usess_remote', 'usess_remote_rotated'],
|
||||
sessionCount: 2,
|
||||
clientType: 'web_browser',
|
||||
clientRuntime: 'chrome',
|
||||
clientPlatform: 'windows',
|
||||
clientLabel: 'Windows / Chrome',
|
||||
deviceDisplayName: 'Windows / Chrome',
|
||||
miniProgramAppId: null,
|
||||
miniProgramEnv: null,
|
||||
userAgent: 'Mozilla/5.0',
|
||||
ipMasked: '203.0.*.*',
|
||||
isCurrent: false,
|
||||
createdAt: '2026-05-01T10:00:00.000Z',
|
||||
lastSeenAt: '2026-05-01T10:30:00.000Z',
|
||||
expiresAt: '2026-06-01T10:30:00.000Z',
|
||||
},
|
||||
];
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
authMocks.getAuthSessions
|
||||
.mockResolvedValueOnce(initialSessions)
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<AccountPanelProbe />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '打开账号面板' }));
|
||||
const accountDialog = await screen.findByRole('dialog', {
|
||||
name: '账号信息',
|
||||
});
|
||||
await user.click(within(accountDialog).getByRole('button', { name: '踢下线' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.revokeAuthSessions).toHaveBeenCalledWith([
|
||||
'usess_remote',
|
||||
'usess_remote_rotated',
|
||||
]);
|
||||
expect(authMocks.getAuthSessions).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('auth gate clears account state after password change', async () => {
|
||||
const user = userEvent.setup();
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
authMocks.getAuthSessions.mockResolvedValue([]);
|
||||
authMocks.changePassword.mockResolvedValue(mockUser);
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<div>
|
||||
<LogoutStateProbe />
|
||||
<AccountPanelProbe />
|
||||
</div>
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '打开账号面板' }));
|
||||
|
||||
const accountDialog = await screen.findByRole('dialog', {
|
||||
name: '账号信息',
|
||||
});
|
||||
await user.click(
|
||||
within(accountDialog).getByRole('button', { name: '修改密码' }),
|
||||
);
|
||||
|
||||
const passwordDialog = await screen.findByRole('dialog', {
|
||||
name: '修改登录密码',
|
||||
});
|
||||
await user.type(within(passwordDialog).getByLabelText('当前密码'), 'oldpass1');
|
||||
await user.type(within(passwordDialog).getByLabelText('新密码'), 'newpass1');
|
||||
await user.click(
|
||||
within(passwordDialog).getByRole('button', { name: '确认修改密码' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.changePassword).toHaveBeenCalledWith(
|
||||
'oldpass1',
|
||||
'newpass1',
|
||||
);
|
||||
expect(screen.getByText('当前用户:未登录')).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByRole('dialog', { name: '账号信息' })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
logoutAuthUser,
|
||||
redeemRegistrationInviteCode,
|
||||
resetPassword,
|
||||
revokeAuthSession,
|
||||
revokeAuthSessions,
|
||||
sendPhoneLoginCode,
|
||||
setStoredLastLoginPhone,
|
||||
startWechatLogin,
|
||||
@@ -121,6 +121,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
useState<PlatformSettingsSection | null>(null);
|
||||
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [revokingSessionIds, setRevokingSessionIds] = useState<string[]>([]);
|
||||
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
|
||||
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
|
||||
const [riskBlocks, setRiskBlocks] = useState<AuthRiskBlockSummary[]>([]);
|
||||
@@ -167,6 +168,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setSettingsEntryMode('settings');
|
||||
setInitialSettingsSection(null);
|
||||
setSessions([]);
|
||||
setRevokingSessionIds([]);
|
||||
setAuditLogs([]);
|
||||
setRiskBlocks([]);
|
||||
setLoginCaptchaChallenge(null);
|
||||
@@ -691,6 +693,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
loadingRiskBlocks={loadingRiskBlocks}
|
||||
loadingSessions={loadingSessions}
|
||||
loadingAuditLogs={loadingAuditLogs}
|
||||
revokingSessionIds={revokingSessionIds}
|
||||
isHydratingSettings={settings.isHydratingSettings}
|
||||
isPersistingSettings={settings.isPersistingSettings}
|
||||
settingsError={settings.settingsError}
|
||||
@@ -752,14 +755,17 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setLoadingAuditLogs(false);
|
||||
}
|
||||
}}
|
||||
onRevokeSession={async (sessionId) => {
|
||||
onRevokeSession={async (session) => {
|
||||
const sessionIds =
|
||||
session.sessionIds.length > 0
|
||||
? session.sessionIds
|
||||
: [session.sessionId];
|
||||
setRevokingSessionIds((current) =>
|
||||
Array.from(new Set([...current, session.sessionId])),
|
||||
);
|
||||
try {
|
||||
await revokeAuthSession(sessionId);
|
||||
setSessions((current) =>
|
||||
current.filter(
|
||||
(session) => session.sessionId !== sessionId,
|
||||
),
|
||||
);
|
||||
await revokeAuthSessions(sessionIds);
|
||||
setSessions(await getAuthSessions());
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (revokeError) {
|
||||
setError(
|
||||
@@ -767,6 +773,10 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
? revokeError.message
|
||||
: '移除登录设备失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setRevokingSessionIds((current) =>
|
||||
current.filter((id) => id !== session.sessionId),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onLogoutAll={logoutAllSessions}
|
||||
@@ -795,11 +805,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setUser(nextUser);
|
||||
}}
|
||||
onChangePassword={async (currentPassword, newPassword) => {
|
||||
const nextUser = await changePassword(
|
||||
currentPassword,
|
||||
newPassword,
|
||||
);
|
||||
setUser(nextUser);
|
||||
await changePassword(currentPassword, newPassword);
|
||||
clearLocalAuthenticatedState();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -17,14 +17,12 @@ import type {
|
||||
PublicUserSummary,
|
||||
} from '../../../packages/shared/src/contracts/auth';
|
||||
import type {
|
||||
CreateProfileRechargeOrderResponse,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileTaskCenterResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
import {
|
||||
ICP_RECORD_NUMBER,
|
||||
ICP_RECORD_URL,
|
||||
} from '../common/legalDocuments';
|
||||
import { ICP_RECORD_NUMBER, ICP_RECORD_URL } from '../common/legalDocuments';
|
||||
import {
|
||||
RpgEntryHomeView,
|
||||
type RpgEntryHomeViewProps,
|
||||
@@ -41,7 +39,9 @@ const {
|
||||
mockBuildReferralCenter,
|
||||
mockBuildTaskCenter,
|
||||
mockClaimRpgProfileTaskReward,
|
||||
mockCreateRpgProfileRechargeOrder,
|
||||
mockGetRpgProfileReferralInviteCenter,
|
||||
mockGetRpgProfileRechargeCenter,
|
||||
mockGetRpgProfileTasks,
|
||||
mockGetRpgProfileWalletLedger,
|
||||
mockRedeemRpgProfileReferralInviteCode,
|
||||
@@ -137,6 +137,88 @@ const {
|
||||
},
|
||||
center: buildClaimedTaskCenter(),
|
||||
})),
|
||||
mockGetRpgProfileRechargeCenter: vi.fn(async () => ({
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [
|
||||
{
|
||||
productId: 'points_60',
|
||||
title: '60光点',
|
||||
priceCents: 600,
|
||||
kind: 'points',
|
||||
pointsAmount: 60,
|
||||
bonusPoints: 60,
|
||||
durationDays: 0,
|
||||
badgeLabel: '首充双倍',
|
||||
description: '首充送60光点',
|
||||
tier: 'normal',
|
||||
},
|
||||
],
|
||||
membershipProducts: [
|
||||
{
|
||||
productId: 'member_month',
|
||||
title: '月卡',
|
||||
priceCents: 2800,
|
||||
kind: 'membership',
|
||||
pointsAmount: 0,
|
||||
bonusPoints: 0,
|
||||
durationDays: 30,
|
||||
badgeLabel: '',
|
||||
description: '30天会员',
|
||||
tier: 'month',
|
||||
},
|
||||
],
|
||||
benefits: [
|
||||
{
|
||||
benefitName: '免光点回合数',
|
||||
normalValue: '30',
|
||||
monthValue: '100',
|
||||
seasonValue: '100',
|
||||
yearValue: '100',
|
||||
},
|
||||
],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
})),
|
||||
mockCreateRpgProfileRechargeOrder: vi.fn(
|
||||
async (): Promise<CreateProfileRechargeOrderResponse> => ({
|
||||
order: {
|
||||
orderId: 'order-1',
|
||||
productId: 'points_60',
|
||||
productTitle: '60光点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'paid',
|
||||
paymentChannel: 'mock',
|
||||
paidAt: '2026-04-25T10:00:00Z',
|
||||
providerTransactionId: null,
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
pointsDelta: 120,
|
||||
membershipExpiresAt: null,
|
||||
},
|
||||
center: {
|
||||
walletBalance: 120,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({
|
||||
center: buildReferralCenter({
|
||||
invitedUsers: [],
|
||||
@@ -219,85 +301,8 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
|
||||
claimRpgProfileTaskReward: mockClaimRpgProfileTaskReward,
|
||||
redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode,
|
||||
getRpgProfileRechargeCenter: vi.fn(async () => ({
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [
|
||||
{
|
||||
productId: 'points_60',
|
||||
title: '60光点',
|
||||
priceCents: 600,
|
||||
kind: 'points',
|
||||
pointsAmount: 60,
|
||||
bonusPoints: 60,
|
||||
durationDays: 0,
|
||||
badgeLabel: '首充双倍',
|
||||
description: '首充送60光点',
|
||||
tier: 'normal',
|
||||
},
|
||||
],
|
||||
membershipProducts: [
|
||||
{
|
||||
productId: 'member_month',
|
||||
title: '月卡',
|
||||
priceCents: 2800,
|
||||
kind: 'membership',
|
||||
pointsAmount: 0,
|
||||
bonusPoints: 0,
|
||||
durationDays: 30,
|
||||
badgeLabel: '',
|
||||
description: '30天会员',
|
||||
tier: 'month',
|
||||
},
|
||||
],
|
||||
benefits: [
|
||||
{
|
||||
benefitName: '免光点回合数',
|
||||
normalValue: '30',
|
||||
monthValue: '100',
|
||||
seasonValue: '100',
|
||||
yearValue: '100',
|
||||
},
|
||||
],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
})),
|
||||
createRpgProfileRechargeOrder: vi.fn(async () => ({
|
||||
order: {
|
||||
orderId: 'order-1',
|
||||
productId: 'points_60',
|
||||
productTitle: '60光点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'paid',
|
||||
paymentChannel: 'mock',
|
||||
paidAt: '2026-04-25T10:00:00Z',
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
pointsDelta: 120,
|
||||
membershipExpiresAt: null,
|
||||
},
|
||||
center: {
|
||||
walletBalance: 120,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: true,
|
||||
},
|
||||
})),
|
||||
getRpgProfileRechargeCenter: mockGetRpgProfileRechargeCenter,
|
||||
createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder,
|
||||
}));
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
@@ -906,6 +911,106 @@ test('opens wallet ledger modal from narrative coin card', async () => {
|
||||
expect(screen.getByText('+30')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile recharge modal buys points through mock channel outside mini program', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
|
||||
renderProfileView(onRechargeSuccess);
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
await user.click(
|
||||
within(shortcutRegion).getByRole('button', { name: /充值/u }),
|
||||
);
|
||||
|
||||
expect(await screen.findByText('账户充值')).toBeTruthy();
|
||||
expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(1);
|
||||
await user.click(screen.getByRole('button', { name: /60光点/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
||||
'points_60',
|
||||
'mock',
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText('已到账')).toBeTruthy();
|
||||
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('profile recharge modal posts requestPayment params in mini program web-view', async () => {
|
||||
const user = userEvent.setup();
|
||||
window.history.replaceState(null, '', '/?clientRuntime=wechat_mini_program');
|
||||
const navigateTo = vi.fn((options: { url: string }) => {
|
||||
const url = new URL(`https://mini.test${options.url}`);
|
||||
const requestId = url.searchParams.get('requestId');
|
||||
window.location.hash = `wx_pay_result=${requestId}:success`;
|
||||
window.dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
});
|
||||
window.wx = {
|
||||
miniProgram: {
|
||||
navigateTo,
|
||||
},
|
||||
};
|
||||
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
|
||||
order: {
|
||||
orderId: 'order-wechat-1',
|
||||
productId: 'points_60',
|
||||
productTitle: '60光点',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'pending' as const,
|
||||
paymentChannel: 'wechat_mp',
|
||||
paidAt: null as string | null,
|
||||
providerTransactionId: null,
|
||||
createdAt: '2026-04-25T10:00:00Z',
|
||||
pointsDelta: 0,
|
||||
membershipExpiresAt: null,
|
||||
},
|
||||
center: {
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
},
|
||||
wechatMiniProgramPayParams: {
|
||||
timeStamp: '1777110165',
|
||||
nonceStr: 'nonce',
|
||||
package: 'prepay_id=wx-prepay',
|
||||
signType: 'RSA',
|
||||
paySign: 'signature',
|
||||
},
|
||||
});
|
||||
|
||||
renderProfileView();
|
||||
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
|
||||
await user.click(
|
||||
within(shortcutRegion).getByRole('button', { name: /充值/u }),
|
||||
);
|
||||
await user.click(await screen.findByRole('button', { name: /60光点/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateRpgProfileRechargeOrder).toHaveBeenCalledWith(
|
||||
'points_60',
|
||||
'wechat_mp',
|
||||
);
|
||||
});
|
||||
expect(navigateTo).toHaveBeenCalledWith({
|
||||
url: expect.stringContaining('/pages/wechat-pay/index?'),
|
||||
fail: expect.any(Function),
|
||||
});
|
||||
const navigateUrl = navigateTo.mock.calls[0]?.[0].url ?? '';
|
||||
expect(navigateUrl).toContain('order-wechat-1');
|
||||
expect(decodeURIComponent(navigateUrl)).toContain('prepay_id=wx-prepay');
|
||||
expect(await screen.findByText('支付已提交')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile daily task shortcut opens task center and claims reward', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRechargeSuccess = vi.fn();
|
||||
@@ -1136,22 +1241,29 @@ test('profile page shows legal entries and ICP record link', async () => {
|
||||
expect(
|
||||
shortcutRegion.querySelector('.grid')?.className.includes('grid-cols-3'),
|
||||
).toBe(true);
|
||||
expect(within(shortcutRegion).getByRole('button', { name: /每日任务/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(shortcutRegion).getByRole('button', { name: /邀请好友/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(shortcutRegion).getByRole('button', { name: /玩家社区/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(shortcutRegion).getByRole('button', { name: /反馈/u }))
|
||||
.toBeTruthy();
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /每日任务/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /邀请好友/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /玩家社区/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(shortcutRegion).getByRole('button', { name: /反馈/u }),
|
||||
).toBeTruthy();
|
||||
|
||||
const legalRegion = screen.getByRole('region', { name: '法律信息' });
|
||||
expect(within(legalRegion).getByRole('button', { name: /用户协议/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(legalRegion).getByRole('button', { name: /隐私政策/u }))
|
||||
.toBeTruthy();
|
||||
expect(within(legalRegion).getByRole('button', { name: /免责声明/u }))
|
||||
.toBeTruthy();
|
||||
expect(
|
||||
within(legalRegion).getByRole('button', { name: /用户协议/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(legalRegion).getByRole('button', { name: /隐私政策/u }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(legalRegion).getByRole('button', { name: /免责声明/u }),
|
||||
).toBeTruthy();
|
||||
|
||||
const recordLink = within(legalRegion).getByRole('link', {
|
||||
name: ICP_RECORD_NUMBER,
|
||||
@@ -1160,7 +1272,9 @@ test('profile page shows legal entries and ICP record link', async () => {
|
||||
expect(recordLink.getAttribute('target')).toBe('_blank');
|
||||
expect(recordLink.getAttribute('rel')).toBe('noreferrer');
|
||||
|
||||
await user.click(within(legalRegion).getByRole('button', { name: /隐私政策/u }));
|
||||
await user.click(
|
||||
within(legalRegion).getByRole('button', { name: /隐私政策/u }),
|
||||
);
|
||||
expect(await screen.findByRole('dialog', { name: '隐私政策' })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -1423,7 +1537,8 @@ test('mobile discover keeps baby object match works in edutainment channel only'
|
||||
await user.click(babyObjectMatchButton);
|
||||
expect(onOpenGalleryDetail).toHaveBeenCalledWith(babyObjectMatchEntry);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
const searchInput =
|
||||
screen.getByPlaceholderText('搜索作品号、名称、作者、描述');
|
||||
await user.type(searchInput, '宝贝识物水果篮{enter}');
|
||||
expect(await within(discoverPanel).findByText('搜索结果')).toBeTruthy();
|
||||
expect(within(discoverPanel).queryByText('宝贝识物水果篮')).toBeNull();
|
||||
|
||||
@@ -50,6 +50,9 @@ import type {
|
||||
ProfilePlayedWorkSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileRechargeProduct,
|
||||
WechatMiniProgramPayParams,
|
||||
ProfileSaveArchiveSummary,
|
||||
ProfileTaskCenterResponse,
|
||||
ProfileTaskItem,
|
||||
@@ -67,7 +70,9 @@ import {
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import {
|
||||
claimRpgProfileTaskReward,
|
||||
createRpgProfileRechargeOrder,
|
||||
getRpgProfileReferralInviteCenter,
|
||||
getRpgProfileRechargeCenter,
|
||||
getRpgProfileTasks,
|
||||
getRpgProfileWalletLedger,
|
||||
redeemRpgProfileReferralInviteCode,
|
||||
@@ -199,8 +204,11 @@ const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
|
||||
const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
|
||||
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
|
||||
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
|
||||
const WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL = 'wechat_mp';
|
||||
|
||||
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
|
||||
type RechargeTab = 'points' | 'membership';
|
||||
type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel';
|
||||
type DiscoverChannel =
|
||||
| 'recommend'
|
||||
| 'today'
|
||||
@@ -2141,7 +2149,9 @@ function ProfileLegalSection({
|
||||
type="button"
|
||||
onClick={() => onOpenDocument(document.id)}
|
||||
className={`flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--platform-button-secondary-fill)] ${
|
||||
index > 0 ? 'border-t border-[var(--platform-subpanel-border)]' : ''
|
||||
index > 0
|
||||
? 'border-t border-[var(--platform-subpanel-border)]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
@@ -2484,6 +2494,254 @@ function formatWalletLedgerAmount(amountDelta: number) {
|
||||
return amountDelta > 0 ? `+${amountDelta}` : `${amountDelta}`;
|
||||
}
|
||||
|
||||
function formatRechargePrice(priceCents: number) {
|
||||
const yuan = priceCents / 100;
|
||||
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function isWechatMiniProgramWebView() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return (
|
||||
params.get('clientRuntime') === 'wechat_mini_program' ||
|
||||
params.get('clientType') === 'mini_program'
|
||||
);
|
||||
}
|
||||
|
||||
function clearWechatPayResultHash() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawHash = window.location.hash.replace(/^#/, '');
|
||||
if (!rawHash.includes('wx_pay_result=')) {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams(rawHash);
|
||||
params.delete('wx_pay_result');
|
||||
const nextHash = params.toString();
|
||||
const nextUrl = `${window.location.pathname}${window.location.search}${nextHash ? `#${nextHash}` : ''}`;
|
||||
window.history.replaceState(null, '', nextUrl);
|
||||
}
|
||||
|
||||
function requestWechatMiniProgramPayment(
|
||||
payload: WechatMiniProgramPayParams | null | undefined,
|
||||
orderId: string,
|
||||
) {
|
||||
const miniProgram = window.wx?.miniProgram;
|
||||
if (
|
||||
!payload ||
|
||||
!miniProgram ||
|
||||
typeof miniProgram.navigateTo !== 'function'
|
||||
) {
|
||||
return Promise.reject(new Error('请在微信小程序内完成支付'));
|
||||
}
|
||||
const navigateTo = miniProgram.navigateTo;
|
||||
|
||||
return new Promise<WechatMiniProgramPaymentStatus>((resolve) => {
|
||||
const requestId = `wechat_pay_${orderId}_${Date.now()}`;
|
||||
const handleHashChange = () => {
|
||||
const params = new URLSearchParams(
|
||||
window.location.hash.replace(/^#/, ''),
|
||||
);
|
||||
const result = params.get('wx_pay_result') ?? '';
|
||||
const [resultRequestId, status] = result.split(':');
|
||||
if (resultRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.removeEventListener('hashchange', handleHashChange);
|
||||
resolve(
|
||||
status === 'success'
|
||||
? 'success'
|
||||
: status === 'cancel'
|
||||
? 'cancel'
|
||||
: 'fail',
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
navigateTo({
|
||||
url: `/pages/wechat-pay/index?requestId=${encodeURIComponent(requestId)}&orderId=${encodeURIComponent(orderId)}&payParams=${encodeURIComponent(JSON.stringify(payload))}`,
|
||||
fail(error) {
|
||||
window.removeEventListener('hashchange', handleHashChange);
|
||||
console.error('[wechat-pay] navigateTo failed', error);
|
||||
resolve('fail');
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function RechargeProductCard({
|
||||
product,
|
||||
submittingProductId,
|
||||
onBuy,
|
||||
}: {
|
||||
product: ProfileRechargeProduct;
|
||||
submittingProductId: string | null;
|
||||
onBuy: (product: ProfileRechargeProduct) => void;
|
||||
}) {
|
||||
const submitting = submittingProductId === product.productId;
|
||||
const value =
|
||||
product.kind === 'points'
|
||||
? `${product.pointsAmount}${product.bonusPoints > 0 ? `+${product.bonusPoints}` : ''}光点`
|
||||
: `${product.durationDays}天`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onBuy(product)}
|
||||
disabled={Boolean(submittingProductId)}
|
||||
className="platform-subpanel platform-interactive-card relative min-h-[7.25rem] rounded-[1.15rem] px-3.5 py-3.5 text-left disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{product.badgeLabel ? (
|
||||
<span className="platform-pill platform-pill--warm absolute right-3 top-3 max-w-[7rem] truncate px-2 py-0.5 text-[10px]">
|
||||
{product.badgeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="pr-20 text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{product.title}
|
||||
</div>
|
||||
<div className="mt-3 text-2xl font-black text-[var(--platform-text-strong)]">
|
||||
{value}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-bold text-[var(--platform-text-soft)]">
|
||||
{formatRechargePrice(product.priceCents)}
|
||||
</span>
|
||||
<span className="platform-primary-button rounded-full px-3 py-1.5 text-xs font-black">
|
||||
{submitting ? '处理中' : '购买'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileRechargeModal({
|
||||
center,
|
||||
isLoading,
|
||||
error,
|
||||
success,
|
||||
submittingProductId,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
onClose,
|
||||
onRetry,
|
||||
onBuy,
|
||||
}: {
|
||||
center: ProfileRechargeCenterResponse | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
success: string | null;
|
||||
submittingProductId: string | null;
|
||||
activeTab: RechargeTab;
|
||||
onTabChange: (tab: RechargeTab) => void;
|
||||
onClose: () => void;
|
||||
onRetry: () => void;
|
||||
onBuy: (product: ProfileRechargeProduct) => void;
|
||||
}) {
|
||||
const products =
|
||||
activeTab === 'points'
|
||||
? (center?.pointProducts ?? [])
|
||||
: (center?.membershipProducts ?? []);
|
||||
const memberLabel =
|
||||
center?.membership.status === 'active'
|
||||
? center.membership.expiresAt
|
||||
? `会员至 ${formatSnapshotTime(center.membership.expiresAt)}`
|
||||
: '会员已生效'
|
||||
: '普通用户';
|
||||
|
||||
return (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div className="platform-recharge-modal w-full max-w-[34rem] overflow-hidden rounded-[1.4rem]">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-base font-black">账户充值</div>
|
||||
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
{center
|
||||
? `${center.walletBalance}光点 · ${memberLabel}`
|
||||
: '读取中'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭账户充值"
|
||||
onClick={onClose}
|
||||
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[min(76vh,36rem)] overflow-y-auto px-5 py-5">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('points')}
|
||||
className={`platform-category-chip justify-center ${activeTab === 'points' ? 'platform-category-chip--active' : ''}`}
|
||||
>
|
||||
光点充值
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTabChange('membership')}
|
||||
className={`platform-category-chip justify-center ${activeTab === 'membership' ? 'platform-category-chip--active' : ''}`}
|
||||
>
|
||||
会员卡
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="platform-profile-error mt-4 rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||
<div>{error}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="platform-primary-button mt-3 rounded-2xl px-4 py-2 text-xs font-black"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{success ? (
|
||||
<div className="platform-profile-success mt-4 rounded-2xl px-3 py-2 text-xs font-semibold">
|
||||
{success}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="h-28 animate-pulse rounded-[1.15rem] bg-white/10"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : products.length > 0 ? (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{products.map((product) => (
|
||||
<RechargeProductCard
|
||||
key={product.productId}
|
||||
product={product}
|
||||
submittingProductId={submittingProductId}
|
||||
onBuy={onBuy}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="platform-subpanel mt-4 rounded-2xl px-4 py-8 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
|
||||
暂无可购买套餐
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WalletLedgerModal({
|
||||
ledger,
|
||||
fallbackBalance,
|
||||
@@ -3184,6 +3442,16 @@ export function RpgEntryHomeView({
|
||||
const [rewardCodeSuccess, setRewardCodeSuccess] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isRechargeOpen, setIsRechargeOpen] = useState(false);
|
||||
const [rechargeCenter, setRechargeCenter] =
|
||||
useState<ProfileRechargeCenterResponse | null>(null);
|
||||
const [isLoadingRechargeCenter, setIsLoadingRechargeCenter] = useState(false);
|
||||
const [rechargeError, setRechargeError] = useState<string | null>(null);
|
||||
const [rechargeSuccess, setRechargeSuccess] = useState<string | null>(null);
|
||||
const [activeRechargeTab, setActiveRechargeTab] =
|
||||
useState<RechargeTab>('points');
|
||||
const [submittingRechargeProductId, setSubmittingRechargeProductId] =
|
||||
useState<string | null>(null);
|
||||
const [isWalletLedgerOpen, setIsWalletLedgerOpen] = useState(false);
|
||||
const [walletLedger, setWalletLedger] =
|
||||
useState<ProfileWalletLedgerResponse | null>(null);
|
||||
@@ -3725,6 +3993,100 @@ export function RpgEntryHomeView({
|
||||
setIsWalletLedgerOpen(true);
|
||||
loadWalletLedger();
|
||||
};
|
||||
const loadRechargeCenter = () => {
|
||||
setRechargeError(null);
|
||||
setIsLoadingRechargeCenter(true);
|
||||
void getRpgProfileRechargeCenter()
|
||||
.then(setRechargeCenter)
|
||||
.catch((error: unknown) => {
|
||||
setRechargeCenter(null);
|
||||
setRechargeError(
|
||||
error instanceof Error ? error.message : '读取账户充值失败',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingRechargeCenter(false));
|
||||
};
|
||||
const openRechargeModal = () => {
|
||||
if (!authUi?.user) {
|
||||
authUi?.openLoginModal();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRechargeOpen(true);
|
||||
setRechargeSuccess(null);
|
||||
loadRechargeCenter();
|
||||
};
|
||||
const buyRechargeProduct = (product: ProfileRechargeProduct) => {
|
||||
if (submittingRechargeProductId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentChannel = isWechatMiniProgramWebView()
|
||||
? WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL
|
||||
: 'mock';
|
||||
setSubmittingRechargeProductId(product.productId);
|
||||
setRechargeError(null);
|
||||
setRechargeSuccess(null);
|
||||
void createRpgProfileRechargeOrder(product.productId, paymentChannel)
|
||||
.then(async (response) => {
|
||||
if (paymentChannel === WECHAT_MINI_PROGRAM_PAYMENT_CHANNEL) {
|
||||
const status = await requestWechatMiniProgramPayment(
|
||||
response.wechatMiniProgramPayParams,
|
||||
response.order.orderId,
|
||||
);
|
||||
if (status === 'cancel') {
|
||||
setRechargeCenter(response.center);
|
||||
setRechargeSuccess('支付已取消');
|
||||
return;
|
||||
}
|
||||
if (status !== 'success') {
|
||||
throw new Error('微信支付未完成');
|
||||
}
|
||||
setRechargeSuccess('支付已提交');
|
||||
loadRechargeCenter();
|
||||
} else {
|
||||
setRechargeCenter(response.center);
|
||||
setRechargeSuccess('已到账');
|
||||
}
|
||||
void onRechargeSuccess?.();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setRechargeError(error instanceof Error ? error.message : '充值失败');
|
||||
})
|
||||
.finally(() => setSubmittingRechargeProductId(null));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!isRechargeOpen) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleWechatPayResult = () => {
|
||||
const result = new URLSearchParams(
|
||||
window.location.hash.replace(/^#/, ''),
|
||||
).get('wx_pay_result');
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
const [, status] = result.split(':');
|
||||
if (status === 'success') {
|
||||
setRechargeSuccess('支付已提交');
|
||||
loadRechargeCenter();
|
||||
void onRechargeSuccess?.();
|
||||
clearWechatPayResultHash();
|
||||
} else if (status === 'cancel') {
|
||||
setRechargeSuccess('支付已取消');
|
||||
clearWechatPayResultHash();
|
||||
} else {
|
||||
setRechargeError('微信支付未完成');
|
||||
clearWechatPayResultHash();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', handleWechatPayResult);
|
||||
handleWechatPayResult();
|
||||
return () =>
|
||||
window.removeEventListener('hashchange', handleWechatPayResult);
|
||||
}, [isRechargeOpen, onRechargeSuccess]);
|
||||
const loadTaskCenter = () => {
|
||||
setTaskCenterError(null);
|
||||
setIsLoadingTaskCenter(true);
|
||||
@@ -4919,13 +5281,13 @@ export function RpgEntryHomeView({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openRewardCodeModal}
|
||||
onClick={openRechargeModal}
|
||||
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
|
||||
>
|
||||
<Ticket className="h-4 w-4" />
|
||||
<Coins className="h-4 w-4" />
|
||||
<div>
|
||||
<div className="text-xs font-bold">兑换码</div>
|
||||
<div className="text-[10px] opacity-80">光点</div>
|
||||
<div className="text-xs font-bold">充值</div>
|
||||
<div className="text-[10px] opacity-80">光点/会员</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 opacity-80" />
|
||||
</button>
|
||||
@@ -5013,6 +5375,18 @@ export function RpgEntryHomeView({
|
||||
icon={Star}
|
||||
onClick={openTaskCenterPanel}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="充值"
|
||||
subLabel="光点/会员"
|
||||
icon={Coins}
|
||||
onClick={openRechargeModal}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="兑换码"
|
||||
subLabel="福利奖励"
|
||||
icon={Ticket}
|
||||
onClick={openRewardCodeModal}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="邀请好友"
|
||||
subLabel={
|
||||
@@ -5455,6 +5829,20 @@ export function RpgEntryHomeView({
|
||||
onClose={() => setIsRewardCodeOpen(false)}
|
||||
/>
|
||||
) : null;
|
||||
const rechargeModal: ReactNode = isRechargeOpen ? (
|
||||
<ProfileRechargeModal
|
||||
center={rechargeCenter}
|
||||
isLoading={isLoadingRechargeCenter}
|
||||
error={rechargeError}
|
||||
success={rechargeSuccess}
|
||||
submittingProductId={submittingRechargeProductId}
|
||||
activeTab={activeRechargeTab}
|
||||
onTabChange={setActiveRechargeTab}
|
||||
onClose={() => setIsRechargeOpen(false)}
|
||||
onRetry={loadRechargeCenter}
|
||||
onBuy={buyRechargeProduct}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (!isDesktopLayout) {
|
||||
const isMobileRecommendTab = activeTab === 'home';
|
||||
@@ -5537,6 +5925,7 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
) : null}
|
||||
{rewardCodeModal}
|
||||
{rechargeModal}
|
||||
{isTaskCenterOpen ? (
|
||||
<ProfileTaskCenterModal
|
||||
center={taskCenter}
|
||||
@@ -5667,6 +6056,7 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
</div>
|
||||
{rewardCodeModal}
|
||||
{rechargeModal}
|
||||
{isTaskCenterOpen ? (
|
||||
<ProfileTaskCenterModal
|
||||
center={taskCenter}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildVisualNovelEntryGenerationAnchorEntries,
|
||||
buildVisualNovelEntryGenerationProgress,
|
||||
type VisualNovelEntryFormPayload,
|
||||
} from './visualNovelEntryGeneration';
|
||||
|
||||
function createVisualNovelPayload(
|
||||
overrides: Partial<VisualNovelEntryFormPayload> = {},
|
||||
): VisualNovelEntryFormPayload {
|
||||
return {
|
||||
sourceMode: 'idea',
|
||||
seedText:
|
||||
'雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。\n视觉画风:映画动画\n画风要求:电影感动画视觉小说画风。',
|
||||
sourceAssetIds: [],
|
||||
ideaText: '雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。',
|
||||
visualStyleId: 'cinematic-anime',
|
||||
visualStyleLabel: '映画动画',
|
||||
visualStylePrompt: '电影感动画视觉小说画风。',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('visualNovelEntryGeneration', () => {
|
||||
test('one-line visual novel generation exposes reference-flow stages', () => {
|
||||
const progress = buildVisualNovelEntryGenerationProgress(
|
||||
1_000,
|
||||
'generating',
|
||||
1_500,
|
||||
);
|
||||
|
||||
expect(progress.steps.map((step) => step.id)).toEqual([
|
||||
'visual-novel-intent',
|
||||
'visual-novel-world',
|
||||
'visual-novel-cast-scenes',
|
||||
'visual-novel-opening',
|
||||
'visual-novel-ready',
|
||||
]);
|
||||
expect(progress.phaseLabel).toBe('理解一句话创意');
|
||||
expect(progress.steps[0]?.detail).toBe(
|
||||
'提取核心题材、视觉画风、玩家身份和互动叙事目标。',
|
||||
);
|
||||
expect(progress.estimatedRemainingMs).toBe(44_500);
|
||||
expect(progress.overallProgress).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('one-line visual novel generation advances to opening choices before ready', () => {
|
||||
const progress = buildVisualNovelEntryGenerationProgress(
|
||||
1_000,
|
||||
'generating',
|
||||
35_000,
|
||||
);
|
||||
|
||||
expect(progress.phaseId).toBe('visual-novel-opening');
|
||||
expect(progress.phaseLabel).toBe('生成开场与选择');
|
||||
expect(progress.steps[2]?.status).toBe('completed');
|
||||
expect(progress.steps[3]?.status).toBe('active');
|
||||
expect(progress.overallProgress).toBeLessThan(99);
|
||||
});
|
||||
|
||||
test('one-line visual novel generation ready copy points to editable draft', () => {
|
||||
const progress = buildVisualNovelEntryGenerationProgress(
|
||||
1_000,
|
||||
'ready',
|
||||
46_000,
|
||||
);
|
||||
|
||||
expect(progress.phaseId).toBe('ready');
|
||||
expect(progress.phaseLabel).toBe('生成完成');
|
||||
expect(progress.phaseDetail).toBe(
|
||||
'视觉小说草稿已准备完成,可进入结果页编辑、保存并试玩。',
|
||||
);
|
||||
expect(progress.overallProgress).toBe(100);
|
||||
});
|
||||
|
||||
test('one-line visual novel generation anchors include source, style and target', () => {
|
||||
const entries = buildVisualNovelEntryGenerationAnchorEntries(
|
||||
createVisualNovelPayload(),
|
||||
);
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 'visual-novel-idea',
|
||||
label: '一句话',
|
||||
value: '雨夜旧图书馆里,失忆高中生发现一本会回应心声的日记。',
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-style',
|
||||
label: '视觉画风',
|
||||
value: '映画动画',
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-target',
|
||||
label: '生成目标',
|
||||
value: '可编辑并可试玩的视觉小说草稿',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,11 @@ export function buildVisualNovelEntryGenerationAnchorEntries(
|
||||
label: '视觉画风',
|
||||
value: payload.visualStyleLabel,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-target',
|
||||
label: '生成目标',
|
||||
value: '可编辑并可试玩的视觉小说草稿',
|
||||
},
|
||||
].filter((entry) => entry.value.trim());
|
||||
}
|
||||
|
||||
@@ -72,27 +77,55 @@ export function buildVisualNovelEntryGenerationProgress(
|
||||
weight: number;
|
||||
durationMs: number;
|
||||
},
|
||||
] = [
|
||||
{
|
||||
id: 'visual-novel-session',
|
||||
label: '创建创作会话',
|
||||
detail: '写入一句话与视觉画风,准备生成视觉小说底稿。',
|
||||
weight: 24,
|
||||
durationMs: 5_000,
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
weight: number;
|
||||
durationMs: number;
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-draft',
|
||||
label: '生成故事底稿',
|
||||
detail: '整理世界观、角色、场景和剧情阶段。',
|
||||
weight: 56,
|
||||
durationMs: 22_000,
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
weight: number;
|
||||
durationMs: number;
|
||||
},
|
||||
] = [
|
||||
{
|
||||
id: 'visual-novel-intent',
|
||||
label: '理解一句话创意',
|
||||
detail: '提取核心题材、视觉画风、玩家身份和互动叙事目标。',
|
||||
weight: 16,
|
||||
durationMs: 6_000,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-world',
|
||||
label: '扩展世界观',
|
||||
detail: '生成世界背景、故事前提、文学风格和玩家角色。',
|
||||
weight: 22,
|
||||
durationMs: 10_000,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-cast-scenes',
|
||||
label: '设计角色与场景',
|
||||
detail: '补齐主要角色、可生成立绘的外观描述和 opening 场景。',
|
||||
weight: 28,
|
||||
durationMs: 16_000,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-opening',
|
||||
label: '生成开场与选择',
|
||||
detail: '写入开场旁白、首句对白、剧情阶段和 2 到 4 个初始选择。',
|
||||
weight: 24,
|
||||
durationMs: 10_000,
|
||||
},
|
||||
{
|
||||
id: 'visual-novel-ready',
|
||||
label: '准备草稿页',
|
||||
detail: '校验可编辑字段并进入草稿页。',
|
||||
weight: 20,
|
||||
durationMs: 4_000,
|
||||
detail: '校验可编辑字段并进入结果页,后续可保存作品和试玩。',
|
||||
weight: 10,
|
||||
durationMs: 3_000,
|
||||
},
|
||||
];
|
||||
let elapsedBeforeStep = 0;
|
||||
@@ -130,9 +163,13 @@ export function buildVisualNovelEntryGenerationProgress(
|
||||
: phase === 'failed'
|
||||
? Math.max(1, completedWeight)
|
||||
: Math.min(98, completedWeight + activeStep.weight * activeRatio);
|
||||
const estimatedTotalMs = timeline.reduce(
|
||||
(sum, step) => sum + step.durationMs,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
phaseId: phase,
|
||||
phaseId: phase === 'generating' ? activeStep.id : phase,
|
||||
phaseLabel:
|
||||
phase === 'ready'
|
||||
? '生成完成'
|
||||
@@ -141,7 +178,7 @@ export function buildVisualNovelEntryGenerationProgress(
|
||||
: activeStep.label,
|
||||
phaseDetail:
|
||||
phase === 'ready'
|
||||
? '视觉小说草稿已准备完成。'
|
||||
? '视觉小说草稿已准备完成,可进入结果页编辑、保存并试玩。'
|
||||
: phase === 'failed'
|
||||
? '草稿生成失败,请返回入口页调整后重试。'
|
||||
: activeStep.detail,
|
||||
@@ -151,7 +188,7 @@ export function buildVisualNovelEntryGenerationProgress(
|
||||
totalWeight: 100,
|
||||
elapsedMs,
|
||||
estimatedRemainingMs:
|
||||
phase === 'ready' ? 0 : Math.max(0, 31_000 - elapsedMs),
|
||||
phase === 'ready' ? 0 : Math.max(0, estimatedTotalMs - elapsedMs),
|
||||
activeStepIndex: normalizedActiveStepIndex,
|
||||
steps: timeline.map((step, index) => {
|
||||
const isCompleted =
|
||||
|
||||
@@ -11,6 +11,8 @@ import { VisualNovelResultView } from './VisualNovelResultView';
|
||||
vi.mock('../../services/visual-novel-creation', () => ({
|
||||
createVisualNovelBackgroundMusicTask: vi.fn(),
|
||||
createVisualNovelSoundEffectTask: vi.fn(),
|
||||
generateVisualNovelImageAsset: vi.fn(),
|
||||
buildVisualNovelImageGenerationPrompt: vi.fn(() => '默认图片提示词'),
|
||||
listVisualNovelHistoryAssets: vi.fn().mockResolvedValue([]),
|
||||
publishVisualNovelBackgroundMusicAsset: vi.fn(),
|
||||
publishVisualNovelSoundEffectAsset: vi.fn(),
|
||||
@@ -134,3 +136,58 @@ test('visual novel result uploads scene and character assets into platform refer
|
||||
onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc,
|
||||
).toContain('/generated-custom-world-scenes/');
|
||||
});
|
||||
|
||||
test('visual novel result generates scene background from asset picker', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaveDraft = vi.fn();
|
||||
const visualNovelCreation = await import('../../services/visual-novel-creation');
|
||||
const generateImageMock = vi.mocked(
|
||||
visualNovelCreation.generateVisualNovelImageAsset,
|
||||
);
|
||||
|
||||
generateImageMock.mockResolvedValue({
|
||||
imageSrc: '/generated-custom-world-scenes/vn-profile/scene-ai.webp',
|
||||
assetId: 'asset-scene-ai',
|
||||
model: 'test-image-model',
|
||||
size: '1280*720',
|
||||
taskId: 'task-scene-ai',
|
||||
prompt: '默认图片提示词',
|
||||
});
|
||||
|
||||
render(
|
||||
<VisualNovelResultView
|
||||
draft={mockVisualNovelDraft}
|
||||
onBack={() => {}}
|
||||
onSaveDraft={onSaveDraft}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '场景' }));
|
||||
await user.click(screen.getByRole('button', { name: /风雪站台/u }));
|
||||
|
||||
const editorDialog = screen.getByRole('dialog', { name: '风雪站台' });
|
||||
await user.click(
|
||||
within(editorDialog).getAllByRole('button', { name: '背景图' })[0]!,
|
||||
);
|
||||
await user.click(
|
||||
within(screen.getByRole('dialog', { name: '背景图' })).getByRole('button', {
|
||||
name: 'AI生成',
|
||||
}),
|
||||
);
|
||||
|
||||
await user.click(within(editorDialog).getByRole('button', { name: '关闭' }));
|
||||
await user.click(screen.getAllByRole('button', { name: '保存草稿' })[1]!);
|
||||
|
||||
expect(generateImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'scene_background',
|
||||
scene: expect.objectContaining({
|
||||
sceneId: mockVisualNovelDraft.scenes[0]?.sceneId,
|
||||
}),
|
||||
prompt: '默认图片提示词',
|
||||
}),
|
||||
);
|
||||
expect(onSaveDraft.mock.calls[0]?.[0].scenes[0]?.backgroundImageSrc).toBe(
|
||||
'/generated-custom-world-scenes/vn-profile/scene-ai.webp',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,16 +4,16 @@ import {
|
||||
ImagePlus,
|
||||
Images,
|
||||
Loader2,
|
||||
type LucideIcon,
|
||||
Music,
|
||||
Save,
|
||||
PenLine,
|
||||
Play,
|
||||
Save,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Upload,
|
||||
Waves,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
@@ -27,9 +27,12 @@ import type {
|
||||
VisualNovelStoryPhaseDraft,
|
||||
VisualNovelValidationIssue,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
|
||||
import {
|
||||
buildVisualNovelImageGenerationPrompt,
|
||||
createVisualNovelBackgroundMusicTask,
|
||||
createVisualNovelSoundEffectTask,
|
||||
generateVisualNovelImageAsset,
|
||||
listVisualNovelHistoryAssets,
|
||||
publishVisualNovelBackgroundMusicAsset,
|
||||
publishVisualNovelSoundEffectAsset,
|
||||
@@ -38,7 +41,6 @@ import {
|
||||
type VisualNovelHistoryAssetKind,
|
||||
type VisualNovelUploadAssetKind,
|
||||
} from '../../services/visual-novel-creation';
|
||||
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { mockVisualNovelDraft } from '../visual-novel-runtime/visualNovelMockData';
|
||||
@@ -102,10 +104,23 @@ type VisualNovelAssetPickerConfig = {
|
||||
profileId?: string | null;
|
||||
entityId?: string | null;
|
||||
previewTone: 'image' | 'audio';
|
||||
imageGeneratorConfig?: VisualNovelImageGeneratorConfig;
|
||||
};
|
||||
|
||||
type VisualNovelAudioGeneratorKind = 'background_music' | 'sound_effect';
|
||||
|
||||
type VisualNovelImageGeneratorKind =
|
||||
| 'cover'
|
||||
| 'scene_background'
|
||||
| 'character_standee';
|
||||
|
||||
type VisualNovelImageGeneratorConfig = {
|
||||
kind: VisualNovelImageGeneratorKind;
|
||||
draft: VisualNovelResultDraft;
|
||||
scene?: VisualNovelSceneDraft | null;
|
||||
character?: VisualNovelCharacterDraft | null;
|
||||
};
|
||||
|
||||
type VisualNovelAudioGeneratorConfig = {
|
||||
kind: VisualNovelAudioGeneratorKind;
|
||||
scene: VisualNovelSceneDraft;
|
||||
@@ -404,6 +419,7 @@ function VisualNovelAssetPickerDialog({
|
||||
Boolean(config.historyKind),
|
||||
);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isGeneratingImage, setIsGeneratingImage] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -445,6 +461,42 @@ function VisualNovelAssetPickerDialog({
|
||||
};
|
||||
}, [config.historyKind]);
|
||||
|
||||
const handleGenerateImage = async () => {
|
||||
if (!config.imageGeneratorConfig || config.previewTone !== 'image') {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGeneratingImage(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await generateVisualNovelImageAsset({
|
||||
...config.imageGeneratorConfig,
|
||||
prompt: buildVisualNovelImageGenerationPrompt(config.imageGeneratorConfig),
|
||||
});
|
||||
onSelect({
|
||||
assetObjectId: result.assetId || result.taskId,
|
||||
assetKind:
|
||||
config.uploadKind === 'character_standee'
|
||||
? 'character_visual'
|
||||
: config.uploadKind === 'cover'
|
||||
? 'visual_novel_cover_image'
|
||||
: 'scene_image',
|
||||
objectKey: '',
|
||||
imageSrc: result.imageSrc,
|
||||
profileId: config.profileId ?? null,
|
||||
entityId: config.entityId ?? null,
|
||||
});
|
||||
} catch (generationError) {
|
||||
setError(
|
||||
generationError instanceof Error
|
||||
? generationError.message
|
||||
: 'AI 图片生成失败。',
|
||||
);
|
||||
} finally {
|
||||
setIsGeneratingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
@@ -512,7 +564,7 @@ function VisualNovelAssetPickerDialog({
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isUploading}
|
||||
disabled={disabled || isUploading || isGeneratingImage}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="platform-button platform-button--secondary min-h-10 px-4 py-2 text-sm"
|
||||
>
|
||||
@@ -523,11 +575,28 @@ function VisualNovelAssetPickerDialog({
|
||||
)}
|
||||
上传
|
||||
</button>
|
||||
{config.imageGeneratorConfig && config.previewTone === 'image' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || isUploading || isGeneratingImage}
|
||||
onClick={() => {
|
||||
void handleGenerateImage();
|
||||
}}
|
||||
className="platform-button platform-button--primary min-h-10 px-4 py-2 text-sm"
|
||||
>
|
||||
{isGeneratingImage ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
AI生成
|
||||
</button>
|
||||
) : null}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={config.accept}
|
||||
disabled={disabled || isUploading}
|
||||
disabled={disabled || isUploading || isGeneratingImage}
|
||||
onChange={(event) => {
|
||||
void handleUpload(event);
|
||||
}}
|
||||
@@ -609,6 +678,7 @@ function VisualNovelAssetField({
|
||||
entityId,
|
||||
historyKind,
|
||||
icon: Icon,
|
||||
imageGeneratorConfig,
|
||||
label,
|
||||
onSelect,
|
||||
previewTone,
|
||||
@@ -621,6 +691,7 @@ function VisualNovelAssetField({
|
||||
entityId?: string | null;
|
||||
historyKind?: VisualNovelHistoryAssetKind;
|
||||
icon: LucideIcon;
|
||||
imageGeneratorConfig?: VisualNovelImageGeneratorConfig;
|
||||
label: string;
|
||||
onSelect: (asset: VisualNovelAssetReference) => void;
|
||||
previewTone: 'image' | 'audio';
|
||||
@@ -710,6 +781,7 @@ function VisualNovelAssetField({
|
||||
profileId,
|
||||
entityId,
|
||||
previewTone,
|
||||
imageGeneratorConfig,
|
||||
}}
|
||||
disabled={disabled}
|
||||
onClose={() => setIsPickerOpen(false)}
|
||||
@@ -1051,6 +1123,7 @@ function VisualNovelProfileTab({
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
profileId={draft.profileId}
|
||||
previewTone="image"
|
||||
imageGeneratorConfig={{ kind: 'cover', draft }}
|
||||
onSelect={(asset) =>
|
||||
onChange({ ...draft, coverImageSrc: asset.imageSrc })
|
||||
}
|
||||
@@ -1321,10 +1394,12 @@ function VisualNovelRuntimeConfigTab({
|
||||
function VisualNovelCharacterEditor({
|
||||
item,
|
||||
disabled,
|
||||
draft,
|
||||
onChange,
|
||||
}: {
|
||||
item: VisualNovelCharacterDraft;
|
||||
disabled: boolean;
|
||||
draft: VisualNovelResultDraft;
|
||||
onChange: (item: VisualNovelCharacterDraft) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -1396,6 +1471,11 @@ function VisualNovelCharacterEditor({
|
||||
profileId={null}
|
||||
entityId={item.characterId}
|
||||
previewTone="image"
|
||||
imageGeneratorConfig={{
|
||||
kind: 'character_standee',
|
||||
draft,
|
||||
character: item,
|
||||
}}
|
||||
onSelect={(asset) =>
|
||||
onChange({
|
||||
...item,
|
||||
@@ -1432,11 +1512,13 @@ function VisualNovelSceneEditor({
|
||||
item,
|
||||
disabled,
|
||||
profileId,
|
||||
draft,
|
||||
onChange,
|
||||
}: {
|
||||
item: VisualNovelSceneDraft;
|
||||
disabled: boolean;
|
||||
profileId?: string | null;
|
||||
draft: VisualNovelResultDraft;
|
||||
onChange: (item: VisualNovelSceneDraft) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -1510,6 +1592,11 @@ function VisualNovelSceneEditor({
|
||||
profileId={profileId ?? null}
|
||||
entityId={item.sceneId}
|
||||
previewTone="image"
|
||||
imageGeneratorConfig={{
|
||||
kind: 'scene_background',
|
||||
draft,
|
||||
scene: item,
|
||||
}}
|
||||
onSelect={(asset) =>
|
||||
onChange({ ...item, backgroundImageSrc: asset.imageSrc })
|
||||
}
|
||||
@@ -1890,6 +1977,7 @@ function VisualNovelEditorDialog({
|
||||
<VisualNovelCharacterEditor
|
||||
item={target.item}
|
||||
disabled={disabled}
|
||||
draft={draft}
|
||||
onChange={updateCharacter}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1898,6 +1986,7 @@ function VisualNovelEditorDialog({
|
||||
item={target.item}
|
||||
disabled={disabled}
|
||||
profileId={draft.profileId}
|
||||
draft={draft}
|
||||
onChange={updateScene}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
authEntry,
|
||||
bindWechatPhone,
|
||||
changePhoneNumber,
|
||||
changePassword,
|
||||
consumeAuthCallbackResult,
|
||||
getAuthAuditLogs,
|
||||
getAuthLoginOptions,
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
redeemRegistrationInviteCode,
|
||||
revokeAuthSession,
|
||||
revokeAuthSessions,
|
||||
sendPhoneLoginCode,
|
||||
startWechatLogin,
|
||||
updateAuthProfile,
|
||||
@@ -154,6 +157,44 @@ describe('authService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('change password clears local auth session after backend success', async () => {
|
||||
window.localStorage.setItem(
|
||||
'genarrative:access-token',
|
||||
'jwt-before-password-change',
|
||||
);
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_1',
|
||||
publicUserCode: 'SY-00000001',
|
||||
username: 'phone_00000001',
|
||||
displayName: '旅人甲',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
createdAt: '2026-05-01T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
const user = await changePassword(' old-password ', ' new-password ');
|
||||
|
||||
expect(user.id).toBe('user_1');
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/password/change',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
currentPassword: 'old-password',
|
||||
newPassword: 'new-password',
|
||||
}),
|
||||
}),
|
||||
'修改密码失败',
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sends phone login code through the auth endpoint', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -475,8 +516,15 @@ describe('authService', () => {
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'usess_1',
|
||||
sessionIds: ['usess_1', 'usess_2'],
|
||||
sessionCount: 2,
|
||||
clientType: 'browser',
|
||||
clientRuntime: 'chrome',
|
||||
clientPlatform: 'windows',
|
||||
clientLabel: '网页端浏览器',
|
||||
deviceDisplayName: 'Windows / Chrome',
|
||||
miniProgramAppId: null,
|
||||
miniProgramEnv: null,
|
||||
userAgent: 'Mozilla/5.0',
|
||||
ipMasked: '127.0.*.*',
|
||||
isCurrent: true,
|
||||
@@ -490,6 +538,47 @@ describe('authService', () => {
|
||||
const sessions = await getAuthSessions();
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
const [session] = sessions;
|
||||
expect(session?.sessionIds).toEqual(['usess_1', 'usess_2']);
|
||||
expect(session?.sessionCount).toBe(2);
|
||||
});
|
||||
|
||||
it('revokes a single auth session by backend route', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({ ok: true });
|
||||
|
||||
await revokeAuthSession('usess_1');
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/sessions/usess_1/revoke',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'移除登录设备失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('revokes grouped auth sessions once per unique session id', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({ ok: true });
|
||||
|
||||
await revokeAuthSessions([' usess_1 ', 'usess_2', 'usess_1', '']);
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(2);
|
||||
expect(apiClientMocks.requestJson).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/auth/sessions/usess_1/revoke',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'移除登录设备失败',
|
||||
);
|
||||
expect(apiClientMocks.requestJson).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/auth/sessions/usess_2/revoke',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'移除登录设备失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads recent auth audit logs', async () => {
|
||||
|
||||
@@ -289,6 +289,7 @@ export async function changePassword(
|
||||
'修改密码失败',
|
||||
);
|
||||
|
||||
clearAuthSession();
|
||||
return response.user;
|
||||
}
|
||||
|
||||
@@ -441,6 +442,16 @@ export async function revokeAuthSession(sessionId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function revokeAuthSessions(sessionIds: string[]) {
|
||||
const uniqueSessionIds = Array.from(
|
||||
new Set(sessionIds.map((sessionId) => sessionId.trim()).filter(Boolean)),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
uniqueSessionIds.map((sessionId) => revokeAuthSession(sessionId)),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAuthAuditLogs() {
|
||||
const response = await requestJson<AuthAuditLogsResponse>(
|
||||
'/api/auth/audit-logs',
|
||||
|
||||
@@ -73,6 +73,28 @@ async function openCreationAgentSsePost(
|
||||
return response;
|
||||
}
|
||||
|
||||
type CreationAgentNormalizedStreamEvent =
|
||||
| {
|
||||
kind: 'reply_delta';
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
kind: 'session';
|
||||
session: unknown;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
message: string;
|
||||
}
|
||||
| null;
|
||||
|
||||
type CreationAgentStreamOptions = TextStreamOptions & {
|
||||
normalizeEvent?: (
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
) => CreationAgentNormalizedStreamEvent;
|
||||
};
|
||||
|
||||
/**
|
||||
* 三类作品创作 Agent 都遵循同一组 HTTP/SSE 端点形状。
|
||||
* 这里统一请求骨架,玩法 client 只保留路径、类型与中文错误文案差异。
|
||||
@@ -134,7 +156,7 @@ export function createCreationAgentClient<
|
||||
const streamMessage = async (
|
||||
sessionId: string,
|
||||
payload: TSendMessagePayload,
|
||||
options: TextStreamOptions = {},
|
||||
options: CreationAgentStreamOptions = {},
|
||||
): Promise<TSession> => {
|
||||
const response = await openCreationAgentSsePost(
|
||||
`${apiBase}/${encodeURIComponent(sessionId)}/messages/stream`,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { readCreationAgentSessionFromSse } from './creationAgentSse';
|
||||
import {
|
||||
normalizeVisualNovelAgentStreamEvent,
|
||||
readCreationAgentSessionFromSse,
|
||||
} from './creationAgentSse';
|
||||
|
||||
function createChunkedStreamResponse(chunks: Uint8Array[]) {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
@@ -76,3 +79,51 @@ test('readCreationAgentSessionFromSse keeps streamed updates before error event'
|
||||
|
||||
expect(updates).toEqual(['先把方洞万能的反差定住。']);
|
||||
});
|
||||
|
||||
test('readCreationAgentSessionFromSse can normalize typed visual novel stream events', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const session = {
|
||||
sessionId: 'vn-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
progressPercent: 100,
|
||||
stage: 'draft_ready',
|
||||
};
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
const response = createChunkedStreamResponse([
|
||||
encoder.encode(
|
||||
'data: {"type":"start","sessionId":"vn-session-1"}\n\n' +
|
||||
'data: {"type":"phase","phase":"synthesis"}\n\n' +
|
||||
'data: {"type":"text_delta","text":"视觉小说底稿已生成。"}\n\n' +
|
||||
`data: ${JSON.stringify({ type: 'complete', session })}\n\n` +
|
||||
'data: {"type":"done"}\n\n',
|
||||
),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readCreationAgentSessionFromSse(response, {
|
||||
fallbackMessage: '发送失败',
|
||||
incompleteMessage: '结果不完整',
|
||||
normalizeEvent: normalizeVisualNovelAgentStreamEvent,
|
||||
onUpdate,
|
||||
}),
|
||||
).resolves.toEqual(session);
|
||||
expect(onUpdate).toHaveBeenCalledWith('视觉小说底稿已生成。');
|
||||
});
|
||||
|
||||
test('readCreationAgentSessionFromSse surfaces typed visual novel error events', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const response = createChunkedStreamResponse([
|
||||
encoder.encode(
|
||||
'data: {"type":"error","message":"视觉小说流式创作失败","retryable":true}\n\n',
|
||||
),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
readCreationAgentSessionFromSse(response, {
|
||||
fallbackMessage: '发送失败',
|
||||
incompleteMessage: '结果不完整',
|
||||
normalizeEvent: normalizeVisualNovelAgentStreamEvent,
|
||||
}),
|
||||
).rejects.toThrow('视觉小说流式创作失败');
|
||||
});
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import type { VisualNovelAgentStreamEvent } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
|
||||
type CreationAgentSseOptions<TSession> = TextStreamOptions & {
|
||||
fallbackMessage: string;
|
||||
incompleteMessage: string;
|
||||
resolveSession?: (rawSession: unknown) => TSession | null;
|
||||
normalizeEvent?: (
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
) =>
|
||||
| {
|
||||
kind: 'reply_delta';
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
kind: 'session';
|
||||
session: unknown;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
message: string;
|
||||
}
|
||||
| null;
|
||||
};
|
||||
|
||||
function findSseEventBoundary(buffer: string) {
|
||||
@@ -65,6 +83,66 @@ function parseJsonObject(data: string) {
|
||||
}
|
||||
}
|
||||
|
||||
type NormalizedCreationAgentSseEvent = NonNullable<
|
||||
CreationAgentSseOptions<unknown>['normalizeEvent']
|
||||
> extends (eventName: string, parsed: Record<string, unknown>) => infer TResult
|
||||
? TResult
|
||||
: never;
|
||||
|
||||
function normalizeDefaultCreationAgentEvent(
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
): NormalizedCreationAgentSseEvent {
|
||||
if (eventName === 'reply_delta') {
|
||||
const text = parsed.text;
|
||||
return typeof text === 'string' ? { kind: 'reply_delta', text } : null;
|
||||
}
|
||||
|
||||
if (eventName === 'session' && parsed.session) {
|
||||
return { kind: 'session', session: parsed.session };
|
||||
}
|
||||
|
||||
if (eventName === 'error') {
|
||||
const message =
|
||||
typeof parsed.message === 'string' && parsed.message.trim()
|
||||
? parsed.message.trim()
|
||||
: '';
|
||||
return { kind: 'error', message };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function normalizeVisualNovelAgentStreamEvent(
|
||||
eventName: string,
|
||||
parsed: Record<string, unknown>,
|
||||
): NormalizedCreationAgentSseEvent {
|
||||
const typedEventName =
|
||||
eventName === 'message' && typeof parsed.type === 'string'
|
||||
? parsed.type
|
||||
: eventName;
|
||||
const event = {
|
||||
...parsed,
|
||||
type: typedEventName,
|
||||
} as VisualNovelAgentStreamEvent;
|
||||
|
||||
switch (event.type) {
|
||||
case 'text_delta':
|
||||
return typeof event.text === 'string'
|
||||
? { kind: 'reply_delta', text: event.text }
|
||||
: null;
|
||||
case 'complete':
|
||||
return event.session ? { kind: 'session', session: event.session } : null;
|
||||
case 'error':
|
||||
return {
|
||||
kind: 'error',
|
||||
message: event.message.trim(),
|
||||
};
|
||||
default:
|
||||
return normalizeDefaultCreationAgentEvent(eventName, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readCreationAgentSessionFromSse<TSession>(
|
||||
response: Response,
|
||||
options: CreationAgentSseOptions<TSession>,
|
||||
@@ -81,15 +159,10 @@ export async function readCreationAgentSessionFromSse<TSession>(
|
||||
((rawSession: unknown) => (rawSession as TSession | null) ?? null);
|
||||
let buffer = '';
|
||||
let finalSession: TSession | null = null;
|
||||
const normalizeEvent =
|
||||
options.normalizeEvent ?? normalizeDefaultCreationAgentEvent;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const consumeBuffer = () => {
|
||||
for (;;) {
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
@@ -105,70 +178,40 @@ export async function readCreationAgentSessionFromSse<TSession>(
|
||||
}
|
||||
|
||||
const parsed = parseJsonObject(data);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeEvent(eventName, parsed);
|
||||
|
||||
if (eventName === 'reply_delta' && parsed) {
|
||||
const text = parsed.text;
|
||||
if (typeof text === 'string') {
|
||||
options.onUpdate?.(text);
|
||||
}
|
||||
if (normalized?.kind === 'reply_delta') {
|
||||
options.onUpdate?.(normalized.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'session' && parsed?.session) {
|
||||
finalSession = resolveSession(parsed.session);
|
||||
if (normalized?.kind === 'session') {
|
||||
finalSession = resolveSession(normalized.session);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'error' && parsed) {
|
||||
const message =
|
||||
typeof parsed.message === 'string' && parsed.message.trim()
|
||||
? parsed.message.trim()
|
||||
: options.fallbackMessage;
|
||||
throw new Error(message);
|
||||
if (normalized?.kind === 'error') {
|
||||
throw new Error(normalized.message || options.fallbackMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
consumeBuffer();
|
||||
}
|
||||
|
||||
// 流结束后再 flush 一次解码器,避免 UTF-8 多字节字符残留在内部缓冲里。
|
||||
buffer += decoder.decode();
|
||||
|
||||
for (;;) {
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
break;
|
||||
}
|
||||
|
||||
const eventBlock = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const { eventName, data } = parseSseEventBlock(eventBlock);
|
||||
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseJsonObject(data);
|
||||
|
||||
if (eventName === 'reply_delta' && parsed) {
|
||||
const text = parsed.text;
|
||||
if (typeof text === 'string') {
|
||||
options.onUpdate?.(text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'session' && parsed?.session) {
|
||||
finalSession = resolveSession(parsed.session);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'error' && parsed) {
|
||||
const message =
|
||||
typeof parsed.message === 'string' && parsed.message.trim()
|
||||
? parsed.message.trim()
|
||||
: options.fallbackMessage;
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
consumeBuffer();
|
||||
|
||||
if (!finalSession) {
|
||||
throw new Error(options.incompleteMessage);
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function streamRpgCreationMessage(
|
||||
sessionId: string,
|
||||
payload: SendRpgAgentMessageRequest,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
): Promise<RpgAgentSessionSnapshot> {
|
||||
const response = await openRpgCreationSsePost(
|
||||
`/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
|
||||
payload,
|
||||
|
||||
@@ -90,6 +90,7 @@ export function getRpgProfileRechargeCenter(
|
||||
|
||||
export function createRpgProfileRechargeOrder(
|
||||
productId: string,
|
||||
paymentChannel = 'mock',
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<CreateProfileRechargeOrderResponse>(
|
||||
@@ -97,7 +98,7 @@ export function createRpgProfileRechargeOrder(
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ productId, paymentChannel: 'mock' }),
|
||||
body: JSON.stringify({ productId, paymentChannel }),
|
||||
},
|
||||
'充值失败',
|
||||
options,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './visualNovelCreationClient';
|
||||
export * from './visualNovelAssetClient';
|
||||
export * from './visualNovelAudioGenerationClient';
|
||||
export * from './visualNovelCreationClient';
|
||||
export * from './visualNovelImageGenerationClient';
|
||||
|
||||
@@ -9,7 +9,10 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
import { createCreationAgentClient } from '../creation-agent';
|
||||
import {
|
||||
createCreationAgentClient,
|
||||
normalizeVisualNovelAgentStreamEvent,
|
||||
} from '../creation-agent';
|
||||
|
||||
const VISUAL_NOVEL_AGENT_API_BASE = '/api/creation/visual-novel/sessions';
|
||||
const VISUAL_NOVEL_CREATION_WRITE_RETRY: ApiRetryOptions = {
|
||||
@@ -61,7 +64,10 @@ export function streamVisualNovelMessage(
|
||||
payload: SendVisualNovelMessageRequest,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
return visualNovelAgentHttpClient.streamMessage(sessionId, payload, options);
|
||||
return visualNovelAgentHttpClient.streamMessage(sessionId, payload, {
|
||||
...options,
|
||||
normalizeEvent: normalizeVisualNovelAgentStreamEvent,
|
||||
});
|
||||
}
|
||||
|
||||
export function executeVisualNovelAction(
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import type {
|
||||
VisualNovelCharacterDraft,
|
||||
VisualNovelResultDraft,
|
||||
VisualNovelSceneDraft,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type {
|
||||
CustomWorldSceneImageRequest,
|
||||
CustomWorldSceneImageResult,
|
||||
} from '../aiTypes';
|
||||
import { generateRpgWorldSceneImage } from '../rpg-creation/rpgCreationAssetClient';
|
||||
|
||||
export type VisualNovelImageGenerationKind =
|
||||
| 'cover'
|
||||
| 'scene_background'
|
||||
| 'character_standee';
|
||||
|
||||
export type VisualNovelImageGenerationRequest = {
|
||||
kind: VisualNovelImageGenerationKind;
|
||||
draft: VisualNovelResultDraft;
|
||||
scene?: VisualNovelSceneDraft | null;
|
||||
character?: VisualNovelCharacterDraft | null;
|
||||
prompt?: string;
|
||||
referenceImageSrc?: string;
|
||||
};
|
||||
|
||||
function buildVisualNovelProfile(
|
||||
draft: VisualNovelResultDraft,
|
||||
): CustomWorldSceneImageRequest['profile'] {
|
||||
return {
|
||||
id: draft.profileId?.trim() || 'visual-novel-draft',
|
||||
name: draft.workTitle.trim() || draft.world.title.trim() || '视觉小说作品',
|
||||
subtitle: draft.world.title.trim() || draft.workTitle.trim() || '视觉小说',
|
||||
summary: draft.workDescription.trim() || draft.world.summary.trim(),
|
||||
tone:
|
||||
draft.world.defaultTone.trim() || draft.world.literaryStyle.trim() || '视觉小说',
|
||||
playerGoal: draft.world.playerRole.trim() || '推进剧情并完成关键选择',
|
||||
settingText: [
|
||||
draft.world.premise,
|
||||
draft.world.background,
|
||||
draft.world.literaryStyle,
|
||||
]
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function buildVisualNovelLandmark(
|
||||
payload: VisualNovelImageGenerationRequest,
|
||||
): CustomWorldSceneImageRequest['landmark'] {
|
||||
if (payload.kind === 'scene_background' && payload.scene) {
|
||||
return {
|
||||
id: payload.scene.sceneId,
|
||||
name: payload.scene.name.trim() || '视觉小说场景',
|
||||
description: payload.scene.description.trim() || payload.draft.world.summary,
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.kind === 'character_standee' && payload.character) {
|
||||
return {
|
||||
id: payload.character.characterId,
|
||||
name: `${payload.character.name.trim() || '视觉小说角色'}立绘`,
|
||||
description: [
|
||||
payload.character.appearance,
|
||||
payload.character.personality,
|
||||
payload.character.role,
|
||||
payload.character.relationshipToPlayer,
|
||||
]
|
||||
.map((part) => part?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: payload.draft.profileId?.trim() || 'visual-novel-cover',
|
||||
name: `${payload.draft.workTitle.trim() || '视觉小说'}封面`,
|
||||
description:
|
||||
payload.draft.workDescription.trim() ||
|
||||
payload.draft.world.summary.trim() ||
|
||||
payload.draft.world.premise.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDefaultVisualNovelImagePrompt(
|
||||
payload: VisualNovelImageGenerationRequest,
|
||||
) {
|
||||
const draft = payload.draft;
|
||||
if (payload.kind === 'scene_background' && payload.scene) {
|
||||
return [
|
||||
`视觉小说场景背景:${payload.scene.name}`,
|
||||
payload.scene.description,
|
||||
draft.world.defaultTone,
|
||||
'16:9 横版背景图,无文字,无 UI,无人物特写',
|
||||
]
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
if (payload.kind === 'character_standee' && payload.character) {
|
||||
return [
|
||||
`视觉小说角色立绘:${payload.character.name}`,
|
||||
payload.character.appearance,
|
||||
payload.character.personality,
|
||||
payload.character.tone,
|
||||
'透明感二次元全身或半身立绘,干净背景,无文字,无 UI',
|
||||
]
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
return [
|
||||
`视觉小说作品封面:${draft.workTitle}`,
|
||||
draft.workDescription,
|
||||
draft.world.summary,
|
||||
draft.world.defaultTone,
|
||||
'精致视觉小说封面构图,无文字,无 UI,适合 4:3/16:9 裁切',
|
||||
]
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
function resolveVisualNovelImageSize(kind: VisualNovelImageGenerationKind) {
|
||||
if (kind === 'character_standee') {
|
||||
return '768*1024';
|
||||
}
|
||||
return '1280*720';
|
||||
}
|
||||
|
||||
export async function generateVisualNovelImageAsset(
|
||||
payload: VisualNovelImageGenerationRequest,
|
||||
): Promise<CustomWorldSceneImageResult> {
|
||||
const userPrompt =
|
||||
payload.prompt?.trim() || buildDefaultVisualNovelImagePrompt(payload);
|
||||
|
||||
if (!userPrompt.trim()) {
|
||||
throw new Error('请先补充图片生成提示词。');
|
||||
}
|
||||
|
||||
return generateRpgWorldSceneImage({
|
||||
profile: buildVisualNovelProfile(payload.draft),
|
||||
landmark: buildVisualNovelLandmark(payload),
|
||||
userPrompt,
|
||||
size: resolveVisualNovelImageSize(payload.kind),
|
||||
...(payload.referenceImageSrc?.trim()
|
||||
? { referenceImageSrc: payload.referenceImageSrc.trim() }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildVisualNovelImageGenerationPrompt(
|
||||
payload: VisualNovelImageGenerationRequest,
|
||||
) {
|
||||
return buildDefaultVisualNovelImagePrompt(payload);
|
||||
}
|
||||
12
src/vite-env.d.ts
vendored
12
src/vite-env.d.ts
vendored
@@ -3,3 +3,15 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_DEBUG_MODE?: string;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
wx?: {
|
||||
miniProgram?: {
|
||||
navigateTo?: (options: {
|
||||
url: string;
|
||||
fail?: (error: { errMsg?: string }) => void;
|
||||
}) => void;
|
||||
postMessage?: (message: unknown) => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user