Update spacetime-client bindings and frontend

Large update across server and web clients: regenerated/added many spacetime-client module bindings and input types (including new delete/work_delete input types and numerous procedure/reducer files), updates to server-rs API modules (bark_battle, jump_hop, wooden_fish, auth, module-runtime and shared contracts), and fixes in module-runtime behavior and domain logic. Frontend changes include new/updated components and tests (creative audio helpers, bark-battle/jump-hop/wooden-fish clients and views, unified generation pages, RPG entry views, and runtime shells), plus CSS and service updates. Documentation and operational notes updated (.hermes pitfalls and multiple PRD/docs) to cover daily-task refresh, banner asset fallback, recommend-key bug, and other platform behaviors. Tests and verification steps added/updated alongside these changes.
This commit is contained in:
2026-06-04 22:44:19 +08:00
parent 2678954627
commit 27b30f974b
326 changed files with 4374 additions and 2539 deletions

View File

@@ -17,10 +17,12 @@ const baseUser: AuthUser = {
displayName: '138****8000',
avatarUrl: null,
publicUserCode: 'user-tester',
phoneNumber: '13800138000',
phoneNumberMasked: '138****8000',
loginMethod: 'phone',
bindingStatus: 'active',
wechatBound: true,
wechatAccount: 'wx-openid-bind-001',
};
function renderAccountModal(overrides?: {
@@ -112,6 +114,10 @@ test('settings header uses a generic title instead of the phone number', () => {
expect(screen.queryByText('当前主题')).toBeNull();
expect(screen.queryByRole('button', { name: '退出登录' })).toBeNull();
expect(screen.queryByRole('button', { name: '退出全部设备' })).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
});
test('direct account entry does not render the settings shell as another dialog', () => {
@@ -129,12 +135,52 @@ test('direct account entry does not render the settings shell as another dialog'
).toBeNull();
});
test('account panel uses compact binding cards and keeps logout actions at the bottom', () => {
renderAccountModal({ entryMode: 'account' });
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('账号信息')).toBeTruthy();
expect(within(accountDialog).queryByText('身份信息')).toBeNull();
expect(
within(accountDialog).queryByText(
'统一查看身份、安全状态、登录设备与最近操作。',
),
).toBeNull();
expect(within(accountDialog).queryByText('登录方式')).toBeNull();
expect(within(accountDialog).getByText('绑定手机号')).toBeTruthy();
expect(within(accountDialog).getByText('13800138000')).toBeTruthy();
expect(within(accountDialog).queryByText('138****8000')).toBeNull();
expect(within(accountDialog).getByText('绑定微信')).toBeTruthy();
expect(within(accountDialog).getByText('wx-openid-bind-001')).toBeTruthy();
const compactCards = accountDialog.querySelectorAll(
'[data-account-binding-card]',
);
expect(compactCards).toHaveLength(2);
expect(
within(compactCards[0] as HTMLElement).getByRole('button', {
name: '更换手机号',
}),
).toBeTruthy();
expect(
within(compactCards[1] as HTMLElement).getByRole('button', {
name: '更换微信号',
}),
).toBeTruthy();
const accountContent =
accountDialog.querySelector('[data-account-content]') ?? accountDialog;
expect(
accountContent.lastElementChild?.getAttribute('data-account-actions'),
).toBe('true');
});
test('account actions open in independent panels instead of inline expansion', async () => {
const user = userEvent.setup();
renderAccountModal();
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(accountDialog).toBeTruthy();
@@ -162,7 +208,7 @@ test('nested settings panels keep back navigation without an extra close action'
renderAccountModal();
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const accountHeader = accountDialog.firstElementChild as HTMLElement | null;
@@ -201,7 +247,7 @@ test('settings overlays move focus away from inert triggers and restore it on ba
renderAccountModal();
const accountTrigger = screen.getByRole('button', { name: /账号信息/ });
const accountTrigger = screen.getByRole('button', { name: /账号与安全/ });
expect(document.activeElement).not.toBe(accountTrigger);
await user.click(accountTrigger);
@@ -283,7 +329,7 @@ test('account panel includes merged security devices and audit sections', async
],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
@@ -324,7 +370,7 @@ test('current merged session group hides kick action and shows count', async ()
],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
@@ -348,7 +394,7 @@ test('remote merged session group can be revoked with loading state', async () =
revokingSessionIds: ['usess_remote'],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const revokeButton = within(accountDialog).getByRole('button', {
@@ -373,7 +419,7 @@ test('remote session revoke passes the grouped session payload', async () => {
onRevokeSession,
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
await user.click(
within(screen.getByRole('dialog', { name: '账号信息' })).getByRole(
'button',

View File

@@ -1,3 +1,4 @@
import { ArrowLeft } from 'lucide-react';
import {
type ReactNode,
useCallback,
@@ -65,8 +66,8 @@ const SETTINGS_SECTIONS: Array<{
label: string;
detail: string;
}> = [
{ id: 'appearance', label: '主题外观', detail: '亮暗主题' },
{ id: 'account', label: '账号信息', detail: '身份与安全' },
{ id: 'appearance', label: '主题设置', detail: '亮暗主题' },
{ id: 'account', label: '账号与安全', detail: '身份与设备' },
];
const ACCOUNT_MODAL_MAX_HEIGHT =
@@ -93,17 +94,6 @@ function normalizeSettingsSection(
return null;
}
function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) {
switch (loginMethod) {
case 'wechat':
return '微信登录';
case 'phone':
return '手机号登录';
default:
return '账号登录';
}
}
function formatSessionTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
@@ -166,7 +156,7 @@ function OverlayPanel({
onClose,
children,
}: {
eyebrow: string;
eyebrow?: string;
title: string;
description?: string;
action?: ReactNode;
@@ -184,12 +174,16 @@ function OverlayPanel({
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
{eyebrow ? (
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
) : null}
<div
className={`${eyebrow ? 'mt-2 text-2xl' : 'text-xl sm:text-2xl'} font-semibold text-[var(--platform-text-strong)]`}
>
{title}
</div>
{description ? (
@@ -204,9 +198,10 @@ function OverlayPanel({
<button
type="button"
autoFocus
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
className="platform-button platform-button--ghost min-h-0 gap-1.5 rounded-full px-3 py-1.5 text-xs"
onClick={onBack}
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
) : (
@@ -446,17 +441,16 @@ export function AccountModal({
? '正在同步平台设置...'
: '平台设置已同步';
const accountSummaryCards = [
['登录方式', resolveLoginMethodLabel(user.loginMethod)],
['手机号', user.phoneNumberMasked || '未绑定'],
['微信绑定', user.wechatBound ? '已绑定' : '未绑定'],
] as const;
const boundPhoneNumber =
user.phoneNumber?.trim() || user.phoneNumberMasked || '未绑定';
const boundWechatAccount =
user.wechatAccount?.trim() || (user.wechatBound ? '已绑定' : '未绑定');
const sectionSummaries: Record<PrimarySettingsSection, string> = {
appearance:
platformTheme === 'dark' ? '当前使用暗色主题。' : '当前使用亮色主题。',
account:
user.phoneNumberMasked || user.wechatBound
user.phoneNumber || user.phoneNumberMasked || user.wechatBound
? '查看身份、安全状态、登录设备与操作记录。'
: '查看账号绑定状态与安全记录。',
};
@@ -524,7 +518,7 @@ export function AccountModal({
{activeSection === 'appearance' ? (
<OverlayPanel
eyebrow="平台偏好"
title="主题外观"
title="主题设置"
description="切换平台亮色或暗色主题。"
onBack={closeSectionPanel}
onClose={onClose}
@@ -568,70 +562,79 @@ export function AccountModal({
{activeSection === 'account' ? (
<OverlayPanel
eyebrow="身份信息"
title="账号信息"
description="统一查看身份、安全状态、登录设备与最近操作。"
standalone={isDirectAccountMode}
onBack={isDirectAccountMode ? undefined : closeSectionPanel}
onClose={onClose}
>
<div className="flex min-h-0 flex-col gap-4">
<div data-account-content className="flex min-h-0 flex-col gap-3">
{accountNotice ? (
<div className="platform-banner platform-banner--success text-sm">
{accountNotice}
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
{accountSummaryCards.map(([label, value]) => (
<div
key={label}
className="platform-subpanel rounded-2xl px-4 py-3"
>
<div className="text-xs tracking-[0.18em] text-[var(--platform-text-soft)]">
{label}
</div>
<div className="mt-2 text-sm font-semibold text-[var(--platform-text-strong)]">
{value}
<div className="grid gap-2.5 sm:grid-cols-2">
<div
data-account-binding-card
className="platform-subpanel rounded-2xl px-3.5 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
onClick={(event) => {
changePhoneTriggerRef.current = event.currentTarget;
setAccountNotice('');
resetChangePhoneDraft();
setIsChangePhonePanelOpen(true);
}}
>
</button>
</div>
))}
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
{boundPhoneNumber}
</div>
</div>
<div
data-account-binding-card
className="platform-subpanel rounded-2xl px-3.5 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
onClick={() => {
setAccountNotice('更换微信号功能暂未接入。');
}}
>
</button>
</div>
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
{boundWechatAccount}
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
className="platform-button platform-button--ghost h-11 w-full text-sm"
onClick={() => {
void onLogout();
}}
>
退
</button>
<button
type="button"
className="platform-button platform-button--danger h-11 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
onClick={(event) => {
passwordTriggerRef.current = event.currentTarget;
setAccountNotice('');
@@ -644,40 +647,12 @@ export function AccountModal({
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={(event) => {
changePhoneTriggerRef.current = event.currentTarget;
setAccountNotice('');
resetChangePhoneDraft();
setIsChangePhonePanelOpen(true);
}}
>
</button>
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
@@ -690,7 +665,7 @@ export function AccountModal({
</button>
</div>
<div className="mt-4 grid gap-3">
<div className="mt-3 grid gap-2.5">
{loadingRiskBlocks ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
@@ -734,15 +709,12 @@ export function AccountModal({
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
@@ -755,7 +727,7 @@ export function AccountModal({
</button>
</div>
<div className="mt-4 grid gap-3">
<div className="mt-3 grid gap-2.5">
{loadingSessions ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
@@ -818,15 +790,12 @@ export function AccountModal({
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
@@ -839,7 +808,7 @@ export function AccountModal({
</button>
</div>
<div className="mt-4 grid gap-3">
<div className="mt-3 grid gap-2.5">
{loadingAuditLogs ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
@@ -873,6 +842,30 @@ export function AccountModal({
)}
</div>
</div>
<div
data-account-actions
className="grid gap-2.5 pt-1 sm:grid-cols-2"
>
<button
type="button"
className="platform-button platform-button--ghost h-10 w-full text-sm"
onClick={() => {
void onLogout();
}}
>
退
</button>
<button
type="button"
className="platform-button platform-button--danger h-10 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div>
</div>
{isChangePhonePanelOpen ? (