1
This commit is contained in:
@@ -2,7 +2,7 @@ import { motion } from 'motion/react';
|
||||
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
} from '../services/aiService';
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
|
||||
interface CustomWorldGenerationViewProps {
|
||||
|
||||
@@ -11,9 +11,13 @@ import {
|
||||
createInventoryItemFromCatalogEntry,
|
||||
ITEM_CATALOG_API_PATH,
|
||||
ITEM_CATEGORY_OPTIONS,
|
||||
ITEM_OVERRIDES_API_PATH,
|
||||
} from '../data/itemCatalog';
|
||||
import { fetchJson, saveJsonObject } from '../editor/shared/jsonClient';
|
||||
import {
|
||||
EDITOR_JSON_RESOURCE_IDS,
|
||||
fetchEditorJsonResource,
|
||||
saveEditorJsonResource,
|
||||
} from '../editor/shared/editorApiClient';
|
||||
import { fetchJson } from '../editor/shared/jsonClient';
|
||||
import { SectionCard as Section } from '../editor/shared/SectionCard';
|
||||
import { type ItemCatalogOverride, type ItemRarity, type TimedBuildBuff,WorldType } from '../types';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
@@ -176,7 +180,9 @@ export function ItemCatalogEditor() {
|
||||
try {
|
||||
const [catalogResponse, overridesResponse] = await Promise.all([
|
||||
fetchJson<ItemCatalogAssetResponse>(ITEM_CATALOG_API_PATH),
|
||||
fetchJson<Record<string, ItemCatalogOverride>>(ITEM_OVERRIDES_API_PATH),
|
||||
fetchEditorJsonResource<Record<string, ItemCatalogOverride>>(
|
||||
EDITOR_JSON_RESOURCE_IDS.itemOverrides,
|
||||
),
|
||||
]);
|
||||
|
||||
if (disposed) return;
|
||||
@@ -416,7 +422,11 @@ export function ItemCatalogEditor() {
|
||||
setSaveMessage(null);
|
||||
|
||||
try {
|
||||
await saveJsonObject(ITEM_OVERRIDES_API_PATH, overrideMap as Record<string, unknown>);
|
||||
await saveEditorJsonResource(
|
||||
EDITOR_JSON_RESOURCE_IDS.itemOverrides,
|
||||
overrideMap as Record<string, unknown>,
|
||||
'保存失败',
|
||||
);
|
||||
setSaveMessage('物品覆盖已保存到 src/data/itemOverrides.json。');
|
||||
setTimeout(() => setSaveMessage(null), 5000);
|
||||
} catch (error) {
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
type ScenePreset,
|
||||
} from '../data/scenePresets';
|
||||
import stateFunctionOverridesJson from '../data/stateFunctionOverrides.json';
|
||||
import {
|
||||
EDITOR_JSON_RESOURCE_IDS,
|
||||
saveEditorJsonResource,
|
||||
} from '../editor/shared/editorApiClient';
|
||||
import {
|
||||
buildStateFunctionDefinitions,
|
||||
type FunctionAvailabilityContext,
|
||||
@@ -30,7 +34,6 @@ import {
|
||||
type StateFunctionOverrideMap,
|
||||
} from '../data/stateFunctions';
|
||||
import {cloneValue} from '../editor/shared/cloneValue';
|
||||
import {saveJsonObject} from '../editor/shared/jsonClient';
|
||||
import {SectionCard} from '../editor/shared/SectionCard';
|
||||
import {type ResolvedChoiceState,useCombatFlow} from '../hooks/useCombatFlow';
|
||||
import {
|
||||
@@ -1134,8 +1137,8 @@ export function StateFunctionEditor() {
|
||||
setIsSaving(true);
|
||||
setSaveMessage(null);
|
||||
try {
|
||||
await saveJsonObject(
|
||||
'/api/state-function-overrides',
|
||||
await saveEditorJsonResource(
|
||||
EDITOR_JSON_RESOURCE_IDS.stateFunctionOverrides,
|
||||
overrideMap as Record<string, unknown>,
|
||||
'保存选项行为覆盖失败',
|
||||
);
|
||||
|
||||
478
src/components/auth/AccountModal.tsx
Normal file
478
src/components/auth/AccountModal.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type {
|
||||
AuthAuditLogEntry,
|
||||
AuthCaptchaChallenge,
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
} from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type AccountModalProps = {
|
||||
user: AuthUser;
|
||||
isOpen: boolean;
|
||||
riskBlocks: AuthRiskBlockSummary[];
|
||||
sessions: AuthSessionSummary[];
|
||||
auditLogs: AuthAuditLogEntry[];
|
||||
loadingRiskBlocks: boolean;
|
||||
loadingSessions: boolean;
|
||||
loadingAuditLogs: boolean;
|
||||
onClose: () => void;
|
||||
onLogout: () => Promise<void>;
|
||||
onRefreshRiskBlocks: () => Promise<void>;
|
||||
onLiftRiskBlock: (scopeType: 'phone' | 'ip') => Promise<void>;
|
||||
onRefreshSessions: () => Promise<void>;
|
||||
onLogoutAll: () => Promise<void>;
|
||||
onRefreshAuditLogs: () => Promise<void>;
|
||||
onRevokeSession: (sessionId: string) => Promise<void>;
|
||||
changePhoneCaptchaChallenge: AuthCaptchaChallenge | null;
|
||||
onSendChangePhoneCode: (
|
||||
phone: string,
|
||||
captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) => Promise<{
|
||||
cooldownSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onChangePhone: (phone: string, code: string) => Promise<void>;
|
||||
};
|
||||
|
||||
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())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function AccountModal({
|
||||
user,
|
||||
isOpen,
|
||||
riskBlocks,
|
||||
sessions,
|
||||
auditLogs,
|
||||
loadingRiskBlocks,
|
||||
loadingSessions,
|
||||
loadingAuditLogs,
|
||||
onClose,
|
||||
onLogout,
|
||||
onRefreshRiskBlocks,
|
||||
onLiftRiskBlock,
|
||||
onRefreshSessions,
|
||||
onLogoutAll,
|
||||
onRefreshAuditLogs,
|
||||
onRevokeSession,
|
||||
changePhoneCaptchaChallenge,
|
||||
onSendChangePhoneCode,
|
||||
onChangePhone,
|
||||
}: AccountModalProps) {
|
||||
const [editingPhone, setEditingPhone] = useState(false);
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [changePhoneError, setChangePhoneError] = useState('');
|
||||
const [changePhoneHint, setChangePhoneHint] = useState('');
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [changingPhone, setChangingPhone] = useState(false);
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [cooldownSeconds]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-end justify-center bg-black/62 px-4 py-4 sm:items-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,_rgba(20,23,31,0.96),_rgba(10,12,18,0.98))] p-5 shadow-[0_24px_80px_rgba(0,0,0,0.58)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.28em] text-amber-200/70">
|
||||
账号信息
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">
|
||||
{user.displayName}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 text-sm text-zinc-200">
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
登录方式:{resolveLoginMethodLabel(user.loginMethod)}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
手机号:{user.phoneNumberMasked || '未绑定'}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
微信绑定:{user.wechatBound ? '已绑定' : '未绑定'}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
账号状态:
|
||||
{user.bindingStatus === 'pending_bind_phone'
|
||||
? ' 待绑定手机号'
|
||||
: ' 已激活'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
|
||||
当前安全状态
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
onClick={() => {
|
||||
void onRefreshRiskBlocks();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
{loadingRiskBlocks ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
正在读取安全状态...
|
||||
</div>
|
||||
) : riskBlocks.length > 0 ? (
|
||||
riskBlocks.map((block) => (
|
||||
<div
|
||||
key={`${block.scopeType}:${block.expiresAt}`}
|
||||
className="rounded-2xl border border-amber-300/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-50"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{block.title}</span>
|
||||
<span className="text-xs text-amber-100/75">
|
||||
剩余约 {Math.max(1, Math.ceil(block.remainingSeconds / 60))} 分钟
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-amber-100/85">
|
||||
{block.detail}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 h-9 rounded-2xl border border-emerald-300/20 px-3 text-xs text-emerald-50 transition hover:border-emerald-300/45 hover:bg-emerald-400/10"
|
||||
onClick={() => {
|
||||
void onLiftRiskBlock(block.scopeType);
|
||||
}}
|
||||
>
|
||||
解除保护
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
当前没有生效中的安全限制。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
|
||||
登录设备
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
onClick={() => {
|
||||
void onRefreshSessions();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
{loadingSessions ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
正在读取当前登录设备...
|
||||
</div>
|
||||
) : sessions.length > 0 ? (
|
||||
sessions.map((session) => (
|
||||
<div
|
||||
key={session.sessionId}
|
||||
className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-200"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{session.clientLabel}</span>
|
||||
<span className="text-xs text-emerald-200/85">
|
||||
{session.isCurrent ? '当前设备' : '已登录'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-zinc-400">
|
||||
最近活跃:{formatSessionTime(session.lastSeenAt)}
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-zinc-500">
|
||||
到期时间:{formatSessionTime(session.expiresAt)}
|
||||
</div>
|
||||
{session.ipMasked ? (
|
||||
<div className="text-xs leading-5 text-zinc-500">
|
||||
IP:{session.ipMasked}
|
||||
</div>
|
||||
) : null}
|
||||
{!session.isCurrent ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 h-9 rounded-2xl border border-rose-400/20 px-3 text-xs text-rose-100 transition hover:border-rose-400/45 hover:bg-rose-500/10"
|
||||
onClick={() => {
|
||||
void onRevokeSession(session.sessionId);
|
||||
}}
|
||||
>
|
||||
踢下线
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
暂无可展示的登录设备。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
|
||||
更换手机号
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
onClick={() => {
|
||||
setEditingPhone((current) => !current);
|
||||
setChangePhoneError('');
|
||||
setChangePhoneHint('');
|
||||
}}
|
||||
>
|
||||
{editingPhone ? '收起' : '修改'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editingPhone ? (
|
||||
<div className="mt-3 grid gap-3 rounded-2xl border border-white/8 bg-white/5 px-4 py-4">
|
||||
<label className="grid gap-2 text-sm text-zinc-200">
|
||||
<span>新手机号</span>
|
||||
<input
|
||||
className="h-11 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/40 focus:bg-black/40"
|
||||
value={phone}
|
||||
inputMode="numeric"
|
||||
placeholder="13800000000"
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-zinc-200">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="h-11 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/40 focus:bg-black/40"
|
||||
value={code}
|
||||
inputMode="numeric"
|
||||
placeholder="输入验证码"
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="h-11 shrink-0 rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setSendingCode(true);
|
||||
setChangePhoneError('');
|
||||
try {
|
||||
const result = await onSendChangePhoneCode(phone, {
|
||||
challengeId: changePhoneCaptchaChallenge?.challengeId,
|
||||
answer: captchaAnswer,
|
||||
});
|
||||
setCooldownSeconds(result.cooldownSeconds);
|
||||
setChangePhoneHint(
|
||||
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||||
);
|
||||
setCaptchaAnswer('');
|
||||
} catch (error) {
|
||||
setChangePhoneError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '发送验证码失败,请稍后再试。',
|
||||
);
|
||||
setChangePhoneHint('');
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中...'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
{changePhoneHint ? (
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||
{changePhoneHint}
|
||||
</div>
|
||||
) : null}
|
||||
<CaptchaChallengeField
|
||||
challenge={changePhoneCaptchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={setCaptchaAnswer}
|
||||
/>
|
||||
{changePhoneError ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{changePhoneError}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={changingPhone || !phone.trim() || !code.trim()}
|
||||
className="h-11 rounded-2xl border border-sky-300/20 px-4 text-sm font-medium text-sky-100 transition hover:border-sky-300/45 hover:bg-sky-500/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setChangingPhone(true);
|
||||
setChangePhoneError('');
|
||||
try {
|
||||
await onChangePhone(phone, code);
|
||||
setChangePhoneHint('手机号已更新。');
|
||||
setPhone('');
|
||||
setCode('');
|
||||
} catch (error) {
|
||||
setChangePhoneError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '更换手机号失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setChangingPhone(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{changingPhone ? '提交中...' : '确认更换手机号'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
|
||||
最近账号操作
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
onClick={() => {
|
||||
void onRefreshAuditLogs();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
{loadingAuditLogs ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
正在读取账号操作记录...
|
||||
</div>
|
||||
) : auditLogs.length > 0 ? (
|
||||
auditLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-200"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{log.title}</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{formatSessionTime(log.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-zinc-400">
|
||||
{log.detail}
|
||||
</div>
|
||||
{log.ipMasked ? (
|
||||
<div className="text-xs leading-5 text-zinc-500">
|
||||
IP:{log.ipMasked}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
暂无账号操作记录。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-5 h-11 w-full rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10"
|
||||
onClick={() => {
|
||||
void onLogout();
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 h-11 w-full rounded-2xl border border-rose-400/20 px-4 text-sm font-medium text-rose-100 transition hover:border-rose-400/45 hover:bg-rose-500/10"
|
||||
onClick={() => {
|
||||
void onLogoutAll();
|
||||
}}
|
||||
>
|
||||
退出全部设备
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,22 +5,69 @@ import {
|
||||
getStoredAccessToken,
|
||||
} from '../../services/apiClient';
|
||||
import {
|
||||
type AuthAuditLogEntry,
|
||||
type AuthCaptchaChallenge,
|
||||
type AuthRiskBlockSummary,
|
||||
type AuthSessionSummary,
|
||||
type AuthUser,
|
||||
bindWechatPhone,
|
||||
changePhoneNumber,
|
||||
consumeAuthCallbackResult,
|
||||
ensureAutoAuthUser,
|
||||
getAuthAuditLogs,
|
||||
getAuthRiskBlocks,
|
||||
getAuthSessions,
|
||||
getCaptchaChallengeFromError,
|
||||
getCurrentAuthUser,
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
logoutAuthUser,
|
||||
revokeAuthSession,
|
||||
sendPhoneLoginCode,
|
||||
startWechatLogin,
|
||||
} from '../../services/authService';
|
||||
import { AccountModal } from './AccountModal';
|
||||
import { BindPhoneScreen } from './BindPhoneScreen';
|
||||
import { LoginScreen } from './LoginScreen';
|
||||
|
||||
type AuthGateProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type AuthStatus = 'checking' | 'recovering' | 'ready' | 'error';
|
||||
type AuthStatus =
|
||||
| 'checking'
|
||||
| 'recovering'
|
||||
| 'unauthenticated'
|
||||
| 'pending_bind_phone'
|
||||
| 'ready'
|
||||
| 'error';
|
||||
|
||||
const allowDevGuestAutoAuth =
|
||||
import.meta.env.DEV &&
|
||||
import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST === 'true';
|
||||
|
||||
export function AuthGate({ children }: AuthGateProps) {
|
||||
const [status, setStatus] = useState<AuthStatus>('checking');
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
const [bindingPhone, setBindingPhone] = useState(false);
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
const [showAccountModal, setShowAccountModal] = useState(false);
|
||||
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
|
||||
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
|
||||
const [riskBlocks, setRiskBlocks] = useState<AuthRiskBlockSummary[]>([]);
|
||||
const [loadingRiskBlocks, setLoadingRiskBlocks] = useState(false);
|
||||
const [loginCaptchaChallenge, setLoginCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
const [bindCaptchaChallenge, setBindCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
@@ -57,31 +104,58 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
};
|
||||
|
||||
const hydrate = async () => {
|
||||
const callbackResult = consumeAuthCallbackResult();
|
||||
if (callbackResult?.error && isActive) {
|
||||
setError(callbackResult.error);
|
||||
}
|
||||
|
||||
const token = getStoredAccessToken();
|
||||
if (!token) {
|
||||
await ensureAutoUser();
|
||||
if (allowDevGuestAutoAuth) {
|
||||
await ensureAutoUser();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextUser = await getCurrentAuthUser();
|
||||
const nextSession = await getCurrentAuthUser();
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextUser) {
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
setError('');
|
||||
if (!nextSession.user) {
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureAutoUser();
|
||||
setUser(nextSession.user);
|
||||
setStatus(
|
||||
nextSession.user.bindingStatus === 'pending_bind_phone'
|
||||
? 'pending_bind_phone'
|
||||
: 'ready',
|
||||
);
|
||||
setError(callbackResult?.error ?? '');
|
||||
} catch {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
await ensureAutoUser();
|
||||
|
||||
if (allowDevGuestAutoAuth) {
|
||||
await ensureAutoUser();
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,6 +174,91 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAccountModal || status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
|
||||
let isActive = true;
|
||||
setLoadingRiskBlocks(true);
|
||||
setLoadingSessions(true);
|
||||
setLoadingAuditLogs(true);
|
||||
void getAuthRiskBlocks()
|
||||
.then((nextBlocks) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setRiskBlocks(nextBlocks);
|
||||
})
|
||||
.catch((blockError) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
blockError instanceof Error
|
||||
? blockError.message
|
||||
: '读取安全状态失败,请稍后再试。',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setLoadingRiskBlocks(false);
|
||||
});
|
||||
void getAuthSessions()
|
||||
.then((nextSessions) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setSessions(nextSessions);
|
||||
})
|
||||
.catch((sessionError) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
sessionError instanceof Error
|
||||
? sessionError.message
|
||||
: '读取登录设备失败,请稍后再试。',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setLoadingSessions(false);
|
||||
});
|
||||
|
||||
void getAuthAuditLogs()
|
||||
.then((nextLogs) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setAuditLogs(nextLogs);
|
||||
})
|
||||
.catch((auditError) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
auditError instanceof Error
|
||||
? auditError.message
|
||||
: '读取账号操作记录失败,请稍后再试。',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setLoadingAuditLogs(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [showAccountModal, status]);
|
||||
|
||||
if (status === 'checking') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
|
||||
@@ -116,11 +275,135 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<LoginScreen
|
||||
sendingCode={sendingCode}
|
||||
loggingIn={loggingIn}
|
||||
wechatLoading={wechatLoading}
|
||||
error={error}
|
||||
captchaChallenge={loginCaptchaChallenge}
|
||||
onSendCode={async (phone, captcha) => {
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(phone, 'login', captcha);
|
||||
setLoginCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setLoginCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
setError(
|
||||
sendError instanceof Error
|
||||
? sendError.message
|
||||
: '发送验证码失败,请稍后再试。',
|
||||
);
|
||||
throw sendError;
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={async (phone, code) => {
|
||||
setLoggingIn(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await loginWithPhoneCode(phone, code);
|
||||
setLoginCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
} catch (loginError) {
|
||||
setError(
|
||||
loginError instanceof Error
|
||||
? loginError.message
|
||||
: '登录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}}
|
||||
onStartWechatLogin={async () => {
|
||||
setWechatLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await startWechatLogin();
|
||||
} catch (wechatError) {
|
||||
setError(
|
||||
wechatError instanceof Error
|
||||
? wechatError.message
|
||||
: '微信登录暂不可用,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setWechatLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'pending_bind_phone' && user) {
|
||||
return (
|
||||
<BindPhoneScreen
|
||||
user={user}
|
||||
sendingCode={sendingCode}
|
||||
binding={bindingPhone}
|
||||
error={error}
|
||||
captchaChallenge={bindCaptchaChallenge}
|
||||
onSendCode={async (phone, captcha) => {
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(phone, 'bind_phone', captcha);
|
||||
setBindCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setBindCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
setError(
|
||||
sendError instanceof Error
|
||||
? sendError.message
|
||||
: '发送验证码失败,请稍后再试。',
|
||||
);
|
||||
throw sendError;
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={async (phone, code) => {
|
||||
setBindingPhone(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await bindWechatPhone(phone, code);
|
||||
setBindCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
} catch (bindError) {
|
||||
setError(
|
||||
bindError instanceof Error
|
||||
? bindError.message
|
||||
: '绑定手机号失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setBindingPhone(false);
|
||||
}
|
||||
}}
|
||||
onLogout={async () => {
|
||||
await logoutAuthUser();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== 'ready' || !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] px-6 text-zinc-200">
|
||||
<div className="max-w-md rounded-3xl border border-white/10 bg-black/40 px-6 py-7 text-center shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
|
||||
<div className="text-base font-medium text-zinc-50">自动登录失败</div>
|
||||
<div className="text-base font-medium text-zinc-50">登录状态异常</div>
|
||||
<div className="mt-3 text-sm leading-6 text-zinc-300">
|
||||
{error || '账号恢复失败,请刷新页面后重试。'}
|
||||
</div>
|
||||
@@ -142,7 +425,13 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs text-zinc-200 backdrop-blur">
|
||||
<span>{user.username}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-white/25 hover:text-white"
|
||||
onClick={() => setShowAccountModal(true)}
|
||||
>
|
||||
{user.displayName}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-amber-300/40 hover:text-amber-100"
|
||||
@@ -154,6 +443,118 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AccountModal
|
||||
user={user}
|
||||
isOpen={showAccountModal}
|
||||
riskBlocks={riskBlocks}
|
||||
sessions={sessions}
|
||||
auditLogs={auditLogs}
|
||||
loadingRiskBlocks={loadingRiskBlocks}
|
||||
loadingSessions={loadingSessions}
|
||||
loadingAuditLogs={loadingAuditLogs}
|
||||
onClose={() => setShowAccountModal(false)}
|
||||
onLogout={async () => {
|
||||
await logoutAuthUser();
|
||||
setShowAccountModal(false);
|
||||
}}
|
||||
onRefreshRiskBlocks={async () => {
|
||||
setLoadingRiskBlocks(true);
|
||||
try {
|
||||
setRiskBlocks(await getAuthRiskBlocks());
|
||||
} catch (blockError) {
|
||||
setError(
|
||||
blockError instanceof Error
|
||||
? blockError.message
|
||||
: '读取安全状态失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingRiskBlocks(false);
|
||||
}
|
||||
}}
|
||||
onLiftRiskBlock={async (scopeType) => {
|
||||
try {
|
||||
await liftAuthRiskBlock(scopeType);
|
||||
setRiskBlocks(await getAuthRiskBlocks());
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (liftError) {
|
||||
setError(
|
||||
liftError instanceof Error
|
||||
? liftError.message
|
||||
: '解除保护失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onRefreshSessions={async () => {
|
||||
setLoadingSessions(true);
|
||||
try {
|
||||
setSessions(await getAuthSessions());
|
||||
} catch (sessionError) {
|
||||
setError(
|
||||
sessionError instanceof Error
|
||||
? sessionError.message
|
||||
: '读取登录设备失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
}}
|
||||
onRefreshAuditLogs={async () => {
|
||||
setLoadingAuditLogs(true);
|
||||
try {
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (auditError) {
|
||||
setError(
|
||||
auditError instanceof Error
|
||||
? auditError.message
|
||||
: '读取账号操作记录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingAuditLogs(false);
|
||||
}
|
||||
}}
|
||||
onRevokeSession={async (sessionId) => {
|
||||
try {
|
||||
await revokeAuthSession(sessionId);
|
||||
setSessions((current) =>
|
||||
current.filter((session) => session.sessionId !== sessionId),
|
||||
);
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (revokeError) {
|
||||
setError(
|
||||
revokeError instanceof Error
|
||||
? revokeError.message
|
||||
: '移除登录设备失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onLogoutAll={async () => {
|
||||
await logoutAllAuthSessions();
|
||||
setShowAccountModal(false);
|
||||
}}
|
||||
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
|
||||
onSendChangePhoneCode={async (phone, captcha) => {
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(
|
||||
phone,
|
||||
'change_phone',
|
||||
captcha,
|
||||
);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setChangePhoneCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
throw sendError;
|
||||
}
|
||||
}}
|
||||
onChangePhone={async (phone, code) => {
|
||||
const nextUser = await changePhoneNumber(phone, code);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
179
src/components/auth/BindPhoneScreen.tsx
Normal file
179
src/components/auth/BindPhoneScreen.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type BindPhoneScreenProps = {
|
||||
user: AuthUser;
|
||||
sendingCode: boolean;
|
||||
binding: boolean;
|
||||
error: string;
|
||||
captchaChallenge: AuthCaptchaChallenge | null;
|
||||
onSendCode: (
|
||||
phone: string,
|
||||
captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) => Promise<{
|
||||
cooldownSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onSubmit: (phone: string, code: string) => Promise<void>;
|
||||
onLogout: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function BindPhoneScreen({
|
||||
user,
|
||||
sendingCode,
|
||||
binding,
|
||||
error,
|
||||
captchaChallenge,
|
||||
onSendCode,
|
||||
onSubmit,
|
||||
onLogout,
|
||||
}: BindPhoneScreenProps) {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
const [hint, setHint] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [cooldownSeconds]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(16,185,129,0.14),_transparent_42%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-6 text-zinc-100 sm:py-8">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
|
||||
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-emerald-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="border-b border-emerald-200/10 bg-[linear-gradient(135deg,_rgba(16,185,129,0.14),_rgba(59,130,246,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<div className="selection-hero-brand selection-hero-brand--left">
|
||||
<div className="selection-hero-brand__title">叙世</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
</div>
|
||||
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-emerald-200/70">
|
||||
账号激活
|
||||
</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
绑定手机号
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
|
||||
微信身份已建立,还差最后一步。绑定手机号后,你的账号才会正式激活,并同步到后端存档体系。
|
||||
</p>
|
||||
<div className="mt-8 rounded-2xl border border-white/8 bg-white/5 px-4 py-4 text-sm text-zinc-300">
|
||||
当前登录身份:{user.displayName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit(phone, code);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="h-12 shrink-0 rounded-2xl border border-emerald-300/25 px-4 text-sm font-medium text-emerald-100 transition hover:border-emerald-300/55 hover:bg-emerald-300/10 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await onSendCode(phone, {
|
||||
challengeId: captchaChallenge?.challengeId,
|
||||
answer: captchaAnswer,
|
||||
});
|
||||
setCooldownSeconds(result.cooldownSeconds);
|
||||
setHint(
|
||||
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||||
);
|
||||
setCaptchaAnswer('');
|
||||
} catch {
|
||||
setHint('');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中...'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{hint ? (
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||
{hint}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CaptchaChallengeField
|
||||
challenge={captchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={setCaptchaAnswer}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={binding || !phone.trim() || !code.trim()}
|
||||
className="h-12 rounded-2xl bg-[linear-gradient(135deg,_#10b981,_#22c55e)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{binding ? '正在绑定...' : '绑定手机号并进入游戏'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="h-11 rounded-2xl border border-white/10 px-4 text-sm text-zinc-300 transition hover:border-white/25 hover:text-white"
|
||||
onClick={() => {
|
||||
void onLogout();
|
||||
}}
|
||||
>
|
||||
返回其他登录方式
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/auth/CaptchaChallengeField.tsx
Normal file
34
src/components/auth/CaptchaChallengeField.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { AuthCaptchaChallenge } from '../../services/authService';
|
||||
|
||||
type CaptchaChallengeFieldProps = {
|
||||
challenge: AuthCaptchaChallenge | null;
|
||||
answer: string;
|
||||
onAnswerChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function CaptchaChallengeField({
|
||||
challenge,
|
||||
answer,
|
||||
onAnswerChange,
|
||||
}: CaptchaChallengeFieldProps) {
|
||||
if (!challenge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 rounded-2xl border border-sky-300/20 bg-sky-500/10 px-4 py-4">
|
||||
<div className="text-sm leading-6 text-sky-100">{challenge.promptText}</div>
|
||||
<img
|
||||
src={challenge.imageDataUrl}
|
||||
alt="图形验证码"
|
||||
className="h-14 w-40 rounded-2xl border border-white/10 bg-black/20 object-cover"
|
||||
/>
|
||||
<input
|
||||
className="h-11 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-sky-300/45 focus:bg-black/40"
|
||||
value={answer}
|
||||
placeholder="输入图形验证码"
|
||||
onChange={(event) => onAnswerChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { AuthCaptchaChallenge } from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type LoginScreenProps = {
|
||||
loading: boolean;
|
||||
sendingCode: boolean;
|
||||
loggingIn: boolean;
|
||||
wechatLoading: boolean;
|
||||
error: string;
|
||||
onSubmit: (username: string, password: string) => Promise<void>;
|
||||
captchaChallenge: AuthCaptchaChallenge | null;
|
||||
onSendCode: (
|
||||
phone: string,
|
||||
captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) => Promise<{
|
||||
cooldownSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onSubmit: (phone: string, code: string) => Promise<void>;
|
||||
onStartWechatLogin: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function LoginScreen({
|
||||
loading,
|
||||
sendingCode,
|
||||
loggingIn,
|
||||
wechatLoading,
|
||||
error,
|
||||
captchaChallenge,
|
||||
onSendCode,
|
||||
onSubmit,
|
||||
onStartWechatLogin,
|
||||
}: LoginScreenProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
const [hint, setHint] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [cooldownSeconds]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.18),_transparent_38%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-8 text-zinc-100">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-5xl items-center justify-center">
|
||||
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-amber-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.15fr_0.85fr]">
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.18),_transparent_38%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-6 text-zinc-100 sm:py-8">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
|
||||
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-amber-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.08fr_0.92fr]">
|
||||
<div className="border-b border-amber-200/10 bg-[linear-gradient(135deg,_rgba(245,158,11,0.16),_rgba(20,184,166,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-amber-200/70">
|
||||
Genarrative
|
||||
<div className="selection-hero-brand selection-hero-brand--left">
|
||||
<div className="selection-hero-brand__title">叙世</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
</div>
|
||||
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-amber-200/70">
|
||||
账号系统
|
||||
</p>
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
登录后进入冒险
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
账号登录
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
|
||||
当前版本已切到后端账号模式。输入用户名和密码即可直接进入,用户名不存在时会自动创建账号。
|
||||
先登录账号,再同步你的冒险进度。本轮先开放手机号验证码登录,微信登录将在下一阶段接入。
|
||||
</p>
|
||||
<div className="mt-8 grid gap-3 text-sm text-zinc-300">
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
用户名:3 到 24 位字母、数字、下划线
|
||||
手机号登录后可在不同设备继续同一份存档
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
第一次提交会自动注册,后续同名即登录
|
||||
验证码登录优先适配移动端,网页端也可直接使用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,32 +85,78 @@ export function LoginScreen({
|
||||
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit(username, password);
|
||||
void onSubmit(phone, code);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>用户名</span>
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="hero_name"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>密码</span>
|
||||
<input
|
||||
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="h-12 shrink-0 rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await onSendCode(phone, {
|
||||
challengeId: captchaChallenge?.challengeId,
|
||||
answer: captchaAnswer,
|
||||
});
|
||||
setCooldownSeconds(result.cooldownSeconds);
|
||||
setHint(
|
||||
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||||
);
|
||||
setCaptchaAnswer('');
|
||||
} catch {
|
||||
setHint('');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中...'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{hint ? (
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||
{hint}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CaptchaChallengeField
|
||||
challenge={captchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={setCaptchaAnswer}
|
||||
/>
|
||||
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-300">
|
||||
手机验证码适合直接登录;如果你更习惯微信,也可以先走微信登录再绑定手机号。
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{error}
|
||||
@@ -76,10 +165,21 @@ export function LoginScreen({
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
disabled={loggingIn || !phone.trim() || !code.trim()}
|
||||
className="mt-2 h-12 rounded-2xl bg-[linear-gradient(135deg,_#f59e0b,_#f97316)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? '正在进入...' : '进入游戏'}
|
||||
{loggingIn ? '正在进入...' : '登录并进入游戏'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={wechatLoading || sendingCode || loggingIn}
|
||||
className="h-12 rounded-2xl border border-white/12 bg-white/5 px-4 text-base font-medium text-zinc-100 transition hover:border-emerald-300/35 hover:bg-emerald-400/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => {
|
||||
void onStartWechatLogin();
|
||||
}}
|
||||
>
|
||||
{wechatLoading ? '正在跳转微信...' : '微信登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
GameState,
|
||||
} from '../../types';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import type { GameShellDialogueIndicator } from './types';
|
||||
import { GameCanvas } from '../GameCanvas';
|
||||
|
||||
export function GameShellCanvasStage({
|
||||
@@ -21,11 +22,7 @@ export function GameShellCanvasStage({
|
||||
visibleGameState: GameState;
|
||||
hideSelectionHero: boolean;
|
||||
canvasCompanionRenderStates: CompanionRenderState[];
|
||||
dialogueIndicator: {
|
||||
showPlayer: boolean;
|
||||
showEncounter: boolean;
|
||||
activeSpeaker?: 'player' | 'npc' | null;
|
||||
} | null;
|
||||
dialogueIndicator: GameShellDialogueIndicator | null;
|
||||
sceneTransitionPhase: 'idle' | 'exiting' | 'entering';
|
||||
sceneTransitionToken: number;
|
||||
setSelectedSceneEntity: (selection: GameCanvasEntitySelection | null) => void;
|
||||
@@ -36,9 +33,9 @@ export function GameShellCanvasStage({
|
||||
<div className={`relative ${hideSelectionHero ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}>
|
||||
{gameState.currentScene === 'Selection' && !hideSelectionHero ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.14),transparent_40%),linear-gradient(180deg,rgba(8,10,14,0.2),rgba(8,10,14,0.82))]">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-black tracking-[0.14em] text-white sm:text-6xl">叙世</div>
|
||||
<div className="mt-3 text-sm tracking-[0.44em] text-zinc-300 sm:text-base">GENARRATIVE</div>
|
||||
<div className="selection-hero-brand px-6 text-center">
|
||||
<div className="selection-hero-brand__title">叙世</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -20,22 +20,7 @@ import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import { CharacterSelectionFlow } from './CharacterSelectionFlow';
|
||||
import { GameShellStoryPanels } from './GameShellStoryPanels';
|
||||
import { PreGameSelectionFlow, type SelectionStage } from './PreGameSelectionFlow';
|
||||
|
||||
type AdventureStatistics = {
|
||||
playTimeMs: number;
|
||||
hostileNpcsDefeated: number;
|
||||
questsAccepted: number;
|
||||
questsCompleted: number;
|
||||
questsTurnedIn: number;
|
||||
itemsUsed: number;
|
||||
scenesTraveled: number;
|
||||
currentSceneName: string;
|
||||
playerCurrency: number;
|
||||
inventoryItemCount: number;
|
||||
inventoryStackCount: number;
|
||||
activeCompanionCount: number;
|
||||
rosterCompanionCount: number;
|
||||
};
|
||||
import type { GameShellAdventureStatistics } from './types';
|
||||
|
||||
export function GameShellMainContent({
|
||||
gameState,
|
||||
@@ -106,7 +91,7 @@ export function GameShellMainContent({
|
||||
openOverlayPanel: (panel: 'character' | 'inventory') => void;
|
||||
openCampModal: () => void;
|
||||
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
|
||||
adventureStatistics: AdventureStatistics;
|
||||
adventureStatistics: GameShellAdventureStatistics;
|
||||
musicVolume: number;
|
||||
onMusicVolumeChange: (value: number) => void;
|
||||
resetForSaveAndExit: () => void;
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
|
||||
import {getLiveGamePlayTimeMs} from '../../data/runtimeStats';
|
||||
import {getWorldCampScenePreset} from '../../data/scenePresets';
|
||||
import type {StoryOption} from '../../types';
|
||||
import {UI_CHROME} from '../../uiAssets';
|
||||
import {GameShellCanvasStage} from './GameShellCanvasStage';
|
||||
import {GameShellMainContent} from './GameShellMainContent';
|
||||
import {GameShellOverlays} from './GameShellOverlays';
|
||||
import {type GameShellProps} from './types';
|
||||
import {useGameShellViewModel} from './useGameShellViewModel';
|
||||
import {SCENE_TRANSITION_FUNCTION_MODES, useSceneTransitionModel} from './useSceneTransitionModel';
|
||||
import { UI_CHROME } from '../../uiAssets';
|
||||
import { GameShellCanvasStage } from './GameShellCanvasStage';
|
||||
import { GameShellMainContent } from './GameShellMainContent';
|
||||
import { GameShellOverlays } from './GameShellOverlays';
|
||||
import type { GameShellProps } from './types';
|
||||
import { useGameShellRuntimeViewModel } from './useGameShellRuntimeViewModel';
|
||||
|
||||
export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) {
|
||||
const {
|
||||
gameState,
|
||||
currentStory,
|
||||
isLoading,
|
||||
aiError,
|
||||
bottomTab,
|
||||
@@ -26,7 +19,6 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleChoice,
|
||||
handleMapTravelToScene,
|
||||
npcUi,
|
||||
characterChatUi,
|
||||
@@ -46,18 +38,10 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
} = entry;
|
||||
const {
|
||||
companionRenderStates,
|
||||
buildCompanionRenderStates,
|
||||
onBenchCompanion,
|
||||
onActivateRosterCompanion,
|
||||
} = companions;
|
||||
const {musicVolume, onMusicVolumeChange} = audio;
|
||||
|
||||
const [clockNow, setClockNow] = useState(() => Date.now());
|
||||
const openingCampSceneId = useMemo(
|
||||
() => (gameState.worldType ? getWorldCampScenePreset(gameState.worldType)?.id ?? null : null),
|
||||
[gameState.worldType],
|
||||
);
|
||||
const hasNpcModalOpen = Boolean(npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal);
|
||||
const {
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
@@ -77,120 +61,24 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
shouldMountMapModal,
|
||||
shouldMountCharacterChatModal,
|
||||
shouldMountNpcModals,
|
||||
} = useGameShellViewModel({
|
||||
gameState,
|
||||
isMapOpen,
|
||||
characterChatModalOpen: Boolean(characterChatUi.modal),
|
||||
hasNpcModalOpen,
|
||||
});
|
||||
const {
|
||||
visibleGameState,
|
||||
visibleCurrentStory,
|
||||
sceneTransitionPhase,
|
||||
sceneTransitionToken,
|
||||
setSceneTransitionDurations,
|
||||
beginSceneTransition,
|
||||
} = useSceneTransitionModel({
|
||||
gameState,
|
||||
currentStory,
|
||||
openingCampSceneId,
|
||||
isCharacterSelectionStage,
|
||||
shouldHideStoryOptions,
|
||||
hideSelectionHero,
|
||||
dialogueIndicator,
|
||||
characterChatSummaries,
|
||||
canvasCompanionRenderStates,
|
||||
adventureStatistics,
|
||||
handleSceneTransitionChoice,
|
||||
} = useGameShellRuntimeViewModel({
|
||||
session,
|
||||
story,
|
||||
companions,
|
||||
});
|
||||
const isCharacterSelectionStage =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
Boolean(gameState.worldType) &&
|
||||
!gameState.playerCharacter;
|
||||
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
||||
const hideSelectionHero =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
selectionStage !== 'start';
|
||||
|
||||
const dialogueIndicator = useMemo(() => {
|
||||
if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastSpeaker = visibleCurrentStory.dialogue?.[visibleCurrentStory.dialogue.length - 1]?.speaker ?? null;
|
||||
return {
|
||||
showPlayer: true,
|
||||
showEncounter: true,
|
||||
activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null,
|
||||
} as const;
|
||||
}, [visibleCurrentStory?.dialogue, visibleCurrentStory?.displayMode, visibleGameState.currentEncounter?.kind, isLoading]);
|
||||
|
||||
const characterChatSummaries = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [characterId, record.summary]),
|
||||
),
|
||||
[gameState.characterChats],
|
||||
);
|
||||
|
||||
const visibleCompanionRenderStates = useMemo(
|
||||
() => buildCompanionRenderStates(visibleGameState),
|
||||
[buildCompanionRenderStates, visibleGameState],
|
||||
);
|
||||
|
||||
const canvasCompanionRenderStates = useMemo(() => {
|
||||
const activeEncounterNpcId = visibleGameState.currentEncounter?.kind === 'npc'
|
||||
? visibleGameState.currentEncounter.id ?? null
|
||||
: null;
|
||||
if (!activeEncounterNpcId) return visibleCompanionRenderStates;
|
||||
return visibleCompanionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId);
|
||||
}, [visibleCompanionRenderStates, visibleGameState.currentEncounter]);
|
||||
|
||||
const livePlayTimeMs = useMemo(
|
||||
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
|
||||
[clockNow, gameState.runtimeStats],
|
||||
);
|
||||
|
||||
const adventureStatistics = useMemo(
|
||||
() => ({
|
||||
playTimeMs: livePlayTimeMs,
|
||||
hostileNpcsDefeated: gameState.runtimeStats.hostileNpcsDefeated,
|
||||
questsAccepted: gameState.runtimeStats.questsAccepted,
|
||||
questsCompleted: visibleGameState.quests.filter(quest => quest.status === 'completed' || quest.status === 'turned_in').length,
|
||||
questsTurnedIn: visibleGameState.quests.filter(quest => quest.status === 'turned_in').length,
|
||||
itemsUsed: gameState.runtimeStats.itemsUsed,
|
||||
scenesTraveled: gameState.runtimeStats.scenesTraveled,
|
||||
currentSceneName: visibleGameState.currentScenePreset?.name ?? '当前区域',
|
||||
playerCurrency: visibleGameState.playerCurrency,
|
||||
inventoryItemCount: visibleGameState.playerInventory.reduce((sum, item) => sum + item.quantity, 0),
|
||||
inventoryStackCount: visibleGameState.playerInventory.length,
|
||||
activeCompanionCount: visibleGameState.companions.length,
|
||||
rosterCompanionCount: visibleGameState.roster.length,
|
||||
}),
|
||||
[
|
||||
gameState.runtimeStats.itemsUsed,
|
||||
gameState.runtimeStats.hostileNpcsDefeated,
|
||||
gameState.runtimeStats.questsAccepted,
|
||||
gameState.runtimeStats.scenesTraveled,
|
||||
livePlayTimeMs,
|
||||
visibleGameState.companions.length,
|
||||
visibleGameState.currentScenePreset?.name,
|
||||
visibleGameState.playerCurrency,
|
||||
visibleGameState.playerInventory,
|
||||
visibleGameState.quests,
|
||||
visibleGameState.roster.length,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
|
||||
return;
|
||||
}
|
||||
|
||||
setClockNow(Date.now());
|
||||
const intervalId = window.setInterval(() => setClockNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [gameState.currentScene, gameState.playerCharacter]);
|
||||
|
||||
const handleSceneTransitionChoice = useCallback((option: StoryOption) => {
|
||||
const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId];
|
||||
if (transitionMode) {
|
||||
beginSceneTransition(transitionMode);
|
||||
}
|
||||
handleChoice(option);
|
||||
}, [beginSceneTransition, handleChoice]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getNineSliceStyle,TAB_ICONS, UI_CHROME } from '../../uiAssets';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { PanelLoadingFallback } from './GameShellLoaders';
|
||||
import type { GameShellAdventureStatistics } from './types';
|
||||
|
||||
const AdventurePanel = lazy(async () => {
|
||||
const module = await import('../AdventurePanel');
|
||||
@@ -38,22 +39,6 @@ const InventoryPanel = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
type AdventureStatistics = {
|
||||
playTimeMs: number;
|
||||
hostileNpcsDefeated: number;
|
||||
questsAccepted: number;
|
||||
questsCompleted: number;
|
||||
questsTurnedIn: number;
|
||||
itemsUsed: number;
|
||||
scenesTraveled: number;
|
||||
currentSceneName: string;
|
||||
playerCurrency: number;
|
||||
inventoryItemCount: number;
|
||||
inventoryStackCount: number;
|
||||
activeCompanionCount: number;
|
||||
rosterCompanionCount: number;
|
||||
};
|
||||
|
||||
export function GameShellStoryPanels({
|
||||
visibleGameState,
|
||||
visibleCurrentStory,
|
||||
@@ -102,7 +87,7 @@ export function GameShellStoryPanels({
|
||||
openOverlayPanel: (panel: 'character' | 'inventory') => void;
|
||||
openCampModal: () => void;
|
||||
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
|
||||
adventureStatistics: AdventureStatistics;
|
||||
adventureStatistics: GameShellAdventureStatistics;
|
||||
musicVolume: number;
|
||||
onMusicVolumeChange: (value: number) => void;
|
||||
onSaveAndExit: () => void;
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
} from '../../data/characterPresets';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { JsonObject } from '../../../packages/shared/src/contracts/common';
|
||||
import {
|
||||
readSavedCustomWorldProfiles,
|
||||
upsertSavedCustomWorldProfile,
|
||||
@@ -11,7 +15,6 @@ import {
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import { getScenePreset } from '../../data/scenePresets';
|
||||
import {
|
||||
type CustomWorldGenerationProgress,
|
||||
generateCustomWorldProfile,
|
||||
} from '../../services/aiService';
|
||||
import {
|
||||
@@ -365,7 +368,9 @@ export function PreGameSelectionFlow({
|
||||
settingText:
|
||||
generatedCustomWorldProfile.settingText.trim() ||
|
||||
customWorldSettingPreview,
|
||||
creatorIntent: generatedCustomWorldProfile.creatorIntent,
|
||||
creatorIntent:
|
||||
(generatedCustomWorldProfile.creatorIntent as JsonObject | null) ??
|
||||
null,
|
||||
generationMode:
|
||||
options.generationMode ??
|
||||
generatedCustomWorldProfile.generationMode ??
|
||||
@@ -453,7 +458,7 @@ export function PreGameSelectionFlow({
|
||||
const profile = await generateCustomWorldProfile(
|
||||
{
|
||||
settingText,
|
||||
creatorIntent: customWorldCreatorIntent,
|
||||
creatorIntent: customWorldCreatorIntent as unknown as JsonObject,
|
||||
generationMode: customWorldGenerationMode,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -63,6 +63,28 @@ export interface GameShellAudioProps {
|
||||
onMusicVolumeChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export interface GameShellDialogueIndicator {
|
||||
showPlayer: boolean;
|
||||
showEncounter: boolean;
|
||||
activeSpeaker?: 'player' | 'npc' | null;
|
||||
}
|
||||
|
||||
export interface GameShellAdventureStatistics {
|
||||
playTimeMs: number;
|
||||
hostileNpcsDefeated: number;
|
||||
questsAccepted: number;
|
||||
questsCompleted: number;
|
||||
questsTurnedIn: number;
|
||||
itemsUsed: number;
|
||||
scenesTraveled: number;
|
||||
currentSceneName: string;
|
||||
playerCurrency: number;
|
||||
inventoryItemCount: number;
|
||||
inventoryStackCount: number;
|
||||
activeCompanionCount: number;
|
||||
rosterCompanionCount: number;
|
||||
}
|
||||
|
||||
export interface GameShellProps {
|
||||
session: GameShellSessionProps;
|
||||
story: GameShellStoryProps;
|
||||
|
||||
289
src/components/game-shell/useGameShellRuntimeViewModel.test.ts
Normal file
289
src/components/game-shell/useGameShellRuntimeViewModel.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { CharacterChatRecord, CompanionRenderState, GameState, StoryMoment } from '../../types';
|
||||
import { AnimationState, WorldType } from '../../types';
|
||||
import {
|
||||
buildAdventureStatistics,
|
||||
buildCanvasCompanionRenderStates,
|
||||
buildCharacterChatSummaries,
|
||||
buildGameShellDialogueIndicator,
|
||||
} from './useGameShellRuntimeViewModel';
|
||||
|
||||
function createBaseGameState(): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 3,
|
||||
questsAccepted: 2,
|
||||
itemsUsed: 4,
|
||||
scenesTraveled: 5,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: {
|
||||
id: 'scene-1',
|
||||
name: '断桥旧哨',
|
||||
description: '测试场景',
|
||||
imageSrc: '/scene.png',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 18,
|
||||
playerInventory: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '药草',
|
||||
description: '恢复道具',
|
||||
quantity: 2,
|
||||
category: 'consumable',
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
name: '布料',
|
||||
description: '材料',
|
||||
quantity: 3,
|
||||
category: 'material',
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [
|
||||
{
|
||||
id: 'quest-1',
|
||||
issuerNpcId: 'npc-1',
|
||||
issuerNpcName: '老周',
|
||||
sceneId: 'scene-1',
|
||||
title: '寻回包裹',
|
||||
description: '找回丢失的包裹',
|
||||
summary: '一项测试任务',
|
||||
objective: {
|
||||
kind: 'deliver_item',
|
||||
targetItemId: 'item-1',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 1,
|
||||
status: 'completed',
|
||||
reward: {
|
||||
affinityBonus: 2,
|
||||
currency: 10,
|
||||
items: [],
|
||||
},
|
||||
rewardText: '测试奖励',
|
||||
},
|
||||
{
|
||||
id: 'quest-2',
|
||||
issuerNpcId: 'npc-2',
|
||||
issuerNpcName: '阿青',
|
||||
sceneId: 'scene-1',
|
||||
title: '护送商队',
|
||||
description: '保护商队通行',
|
||||
summary: '另一项测试任务',
|
||||
objective: {
|
||||
kind: 'reach_scene',
|
||||
targetSceneId: 'scene-2',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 1,
|
||||
status: 'turned_in',
|
||||
reward: {
|
||||
affinityBonus: 3,
|
||||
currency: 20,
|
||||
items: [],
|
||||
},
|
||||
rewardText: '测试奖励',
|
||||
},
|
||||
],
|
||||
roster: [
|
||||
{
|
||||
npcId: 'npc-roster',
|
||||
characterId: 'char-roster',
|
||||
joinedAtAffinity: 10,
|
||||
hp: 90,
|
||||
maxHp: 90,
|
||||
mana: 12,
|
||||
maxMana: 12,
|
||||
skillCooldowns: {},
|
||||
},
|
||||
],
|
||||
companions: [
|
||||
{
|
||||
npcId: 'npc-active',
|
||||
characterId: 'char-active',
|
||||
joinedAtAffinity: 18,
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 16,
|
||||
maxMana: 16,
|
||||
skillCooldowns: {},
|
||||
},
|
||||
{
|
||||
npcId: 'npc-encounter',
|
||||
characterId: 'char-encounter',
|
||||
joinedAtAffinity: 22,
|
||||
hp: 88,
|
||||
maxHp: 88,
|
||||
mana: 14,
|
||||
maxMana: 14,
|
||||
skillCooldowns: {},
|
||||
},
|
||||
],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useGameShellRuntimeViewModel helpers', () => {
|
||||
it('builds a dialogue indicator only for active npc dialogue playback', () => {
|
||||
const state = {
|
||||
...createBaseGameState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-encounter',
|
||||
kind: 'npc' as const,
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路人',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道相遇',
|
||||
},
|
||||
};
|
||||
const story = {
|
||||
text: '继续对话',
|
||||
displayMode: 'dialogue' as const,
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc' as const,
|
||||
text: '先别急着出手。',
|
||||
},
|
||||
{
|
||||
speaker: 'player' as const,
|
||||
text: '那你想说什么?',
|
||||
},
|
||||
],
|
||||
options: [],
|
||||
} satisfies StoryMoment;
|
||||
|
||||
expect(
|
||||
buildGameShellDialogueIndicator({
|
||||
isLoading: true,
|
||||
visibleGameState: state,
|
||||
visibleCurrentStory: story,
|
||||
}),
|
||||
).toEqual({
|
||||
showPlayer: true,
|
||||
showEncounter: true,
|
||||
activeSpeaker: 'player',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildGameShellDialogueIndicator({
|
||||
isLoading: false,
|
||||
visibleGameState: state,
|
||||
visibleCurrentStory: story,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('derives compact chat summaries and hides the active encounter companion from canvas renders', () => {
|
||||
const chatSummaries = buildCharacterChatSummaries({
|
||||
'char-active': {
|
||||
history: [],
|
||||
summary: '已经建立起稳定默契。',
|
||||
updatedAt: null,
|
||||
},
|
||||
'char-roster': {
|
||||
history: [],
|
||||
summary: '仍在营地观望局势。',
|
||||
updatedAt: null,
|
||||
},
|
||||
} satisfies Record<string, CharacterChatRecord>);
|
||||
|
||||
expect(chatSummaries).toEqual({
|
||||
'char-active': '已经建立起稳定默契。',
|
||||
'char-roster': '仍在营地观望局势。',
|
||||
});
|
||||
|
||||
const visibleCompanionRenderStates = [
|
||||
{ npcId: 'npc-active' },
|
||||
{ npcId: 'npc-encounter' },
|
||||
] as CompanionRenderState[];
|
||||
const visibleGameState = {
|
||||
...createBaseGameState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-encounter',
|
||||
kind: 'npc' as const,
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路人',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道相遇',
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
buildCanvasCompanionRenderStates({
|
||||
visibleCompanionRenderStates,
|
||||
visibleGameState,
|
||||
}),
|
||||
).toEqual([{ npcId: 'npc-active' }]);
|
||||
});
|
||||
|
||||
it('aggregates adventure statistics from runtime and visible state slices', () => {
|
||||
const gameState = createBaseGameState();
|
||||
const statistics = buildAdventureStatistics({
|
||||
gameState,
|
||||
visibleGameState: gameState,
|
||||
livePlayTimeMs: 3210,
|
||||
});
|
||||
|
||||
expect(statistics).toEqual({
|
||||
playTimeMs: 3210,
|
||||
hostileNpcsDefeated: 3,
|
||||
questsAccepted: 2,
|
||||
questsCompleted: 2,
|
||||
questsTurnedIn: 1,
|
||||
itemsUsed: 4,
|
||||
scenesTraveled: 5,
|
||||
currentSceneName: '断桥旧哨',
|
||||
playerCurrency: 18,
|
||||
inventoryItemCount: 5,
|
||||
inventoryStackCount: 2,
|
||||
activeCompanionCount: 2,
|
||||
rosterCompanionCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
235
src/components/game-shell/useGameShellRuntimeViewModel.ts
Normal file
235
src/components/game-shell/useGameShellRuntimeViewModel.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getLiveGamePlayTimeMs } from '../../data/runtimeStats';
|
||||
import { getWorldCampScenePreset } from '../../data/scenePresets';
|
||||
import type {
|
||||
CharacterChatRecord,
|
||||
CompanionRenderState,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type {
|
||||
GameShellAdventureStatistics,
|
||||
GameShellDialogueIndicator,
|
||||
GameShellProps,
|
||||
} from './types';
|
||||
import { useGameShellViewModel } from './useGameShellViewModel';
|
||||
import {
|
||||
SCENE_TRANSITION_FUNCTION_MODES,
|
||||
useSceneTransitionModel,
|
||||
} from './useSceneTransitionModel';
|
||||
|
||||
export function buildGameShellDialogueIndicator(params: {
|
||||
isLoading: boolean;
|
||||
visibleGameState: GameState;
|
||||
visibleCurrentStory: StoryMoment | null;
|
||||
}): GameShellDialogueIndicator | null {
|
||||
const { isLoading, visibleGameState, visibleCurrentStory } = params;
|
||||
if (
|
||||
!isLoading ||
|
||||
visibleCurrentStory?.displayMode !== 'dialogue' ||
|
||||
visibleGameState.currentEncounter?.kind !== 'npc'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastSpeaker =
|
||||
visibleCurrentStory.dialogue?.[visibleCurrentStory.dialogue.length - 1]
|
||||
?.speaker ?? null;
|
||||
|
||||
return {
|
||||
showPlayer: true,
|
||||
showEncounter: true,
|
||||
activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCharacterChatSummaries(
|
||||
characterChats: Record<string, CharacterChatRecord> | undefined,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(characterChats ?? {}).map(([characterId, record]) => [
|
||||
characterId,
|
||||
record.summary,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCanvasCompanionRenderStates(params: {
|
||||
visibleCompanionRenderStates: CompanionRenderState[];
|
||||
visibleGameState: GameState;
|
||||
}) {
|
||||
const activeEncounterNpcId =
|
||||
params.visibleGameState.currentEncounter?.kind === 'npc'
|
||||
? params.visibleGameState.currentEncounter.id ?? null
|
||||
: null;
|
||||
if (!activeEncounterNpcId) {
|
||||
return params.visibleCompanionRenderStates;
|
||||
}
|
||||
|
||||
return params.visibleCompanionRenderStates.filter(
|
||||
(companion) => companion.npcId !== activeEncounterNpcId,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAdventureStatistics(params: {
|
||||
gameState: GameState;
|
||||
visibleGameState: GameState;
|
||||
livePlayTimeMs: number;
|
||||
}): GameShellAdventureStatistics {
|
||||
const { gameState, visibleGameState, livePlayTimeMs } = params;
|
||||
|
||||
return {
|
||||
playTimeMs: livePlayTimeMs,
|
||||
hostileNpcsDefeated: gameState.runtimeStats.hostileNpcsDefeated,
|
||||
questsAccepted: gameState.runtimeStats.questsAccepted,
|
||||
questsCompleted: visibleGameState.quests.filter(
|
||||
(quest) => quest.status === 'completed' || quest.status === 'turned_in',
|
||||
).length,
|
||||
questsTurnedIn: visibleGameState.quests.filter(
|
||||
(quest) => quest.status === 'turned_in',
|
||||
).length,
|
||||
itemsUsed: gameState.runtimeStats.itemsUsed,
|
||||
scenesTraveled: gameState.runtimeStats.scenesTraveled,
|
||||
currentSceneName: visibleGameState.currentScenePreset?.name ?? '当前区域',
|
||||
playerCurrency: visibleGameState.playerCurrency,
|
||||
inventoryItemCount: visibleGameState.playerInventory.reduce(
|
||||
(sum, item) => sum + item.quantity,
|
||||
0,
|
||||
),
|
||||
inventoryStackCount: visibleGameState.playerInventory.length,
|
||||
activeCompanionCount: visibleGameState.companions.length,
|
||||
rosterCompanionCount: visibleGameState.roster.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function useGameShellRuntimeViewModel(params: Pick<
|
||||
GameShellProps,
|
||||
'session' | 'story' | 'companions'
|
||||
>) {
|
||||
const { session, story, companions } = params;
|
||||
const {
|
||||
gameState,
|
||||
currentStory,
|
||||
isLoading,
|
||||
isMapOpen,
|
||||
} = session;
|
||||
const { npcUi, characterChatUi, handleChoice } = story;
|
||||
const { buildCompanionRenderStates } = companions;
|
||||
|
||||
const [clockNow, setClockNow] = useState(() => Date.now());
|
||||
const openingCampSceneId = useMemo(
|
||||
() =>
|
||||
gameState.worldType
|
||||
? getWorldCampScenePreset(gameState.worldType)?.id ?? null
|
||||
: null,
|
||||
[gameState.worldType],
|
||||
);
|
||||
const hasNpcModalOpen = Boolean(
|
||||
npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal,
|
||||
);
|
||||
const shellViewModel = useGameShellViewModel({
|
||||
gameState,
|
||||
isMapOpen,
|
||||
characterChatModalOpen: Boolean(characterChatUi.modal),
|
||||
hasNpcModalOpen,
|
||||
});
|
||||
const sceneTransitionModel = useSceneTransitionModel({
|
||||
gameState,
|
||||
currentStory,
|
||||
openingCampSceneId,
|
||||
});
|
||||
const {
|
||||
visibleGameState,
|
||||
visibleCurrentStory,
|
||||
sceneTransitionPhase,
|
||||
beginSceneTransition,
|
||||
} = sceneTransitionModel;
|
||||
const isCharacterSelectionStage =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
Boolean(gameState.worldType) &&
|
||||
!gameState.playerCharacter;
|
||||
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
||||
const hideSelectionHero =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
shellViewModel.selectionStage !== 'start';
|
||||
|
||||
const dialogueIndicator = useMemo(
|
||||
() =>
|
||||
buildGameShellDialogueIndicator({
|
||||
isLoading,
|
||||
visibleGameState,
|
||||
visibleCurrentStory,
|
||||
}),
|
||||
[isLoading, visibleCurrentStory, visibleGameState],
|
||||
);
|
||||
|
||||
const characterChatSummaries = useMemo(
|
||||
() => buildCharacterChatSummaries(gameState.characterChats),
|
||||
[gameState.characterChats],
|
||||
);
|
||||
|
||||
const visibleCompanionRenderStates = useMemo(
|
||||
() => buildCompanionRenderStates(visibleGameState),
|
||||
[buildCompanionRenderStates, visibleGameState],
|
||||
);
|
||||
|
||||
const canvasCompanionRenderStates = useMemo(
|
||||
() =>
|
||||
buildCanvasCompanionRenderStates({
|
||||
visibleCompanionRenderStates,
|
||||
visibleGameState,
|
||||
}),
|
||||
[visibleCompanionRenderStates, visibleGameState],
|
||||
);
|
||||
|
||||
const livePlayTimeMs = useMemo(
|
||||
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
|
||||
[clockNow, gameState.runtimeStats],
|
||||
);
|
||||
|
||||
const adventureStatistics = useMemo(
|
||||
() =>
|
||||
buildAdventureStatistics({
|
||||
gameState,
|
||||
visibleGameState,
|
||||
livePlayTimeMs,
|
||||
}),
|
||||
[gameState, livePlayTimeMs, visibleGameState],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
|
||||
return;
|
||||
}
|
||||
|
||||
setClockNow(Date.now());
|
||||
const intervalId = window.setInterval(() => setClockNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [gameState.currentScene, gameState.playerCharacter]);
|
||||
|
||||
const handleSceneTransitionChoice = useCallback(
|
||||
(option: StoryOption) => {
|
||||
const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId];
|
||||
if (transitionMode) {
|
||||
beginSceneTransition(transitionMode);
|
||||
}
|
||||
handleChoice(option);
|
||||
},
|
||||
[beginSceneTransition, handleChoice],
|
||||
);
|
||||
|
||||
return {
|
||||
...shellViewModel,
|
||||
...sceneTransitionModel,
|
||||
isCharacterSelectionStage,
|
||||
shouldHideStoryOptions,
|
||||
hideSelectionHero,
|
||||
dialogueIndicator,
|
||||
characterChatSummaries,
|
||||
canvasCompanionRenderStates,
|
||||
adventureStatistics,
|
||||
handleSceneTransitionChoice,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { MedievalNpcVisualOverride } from '../data/medievalNpcVisuals';
|
||||
import {
|
||||
buildEditorJsonApiPath,
|
||||
EDITOR_JSON_RESOURCE_IDS,
|
||||
} from '../editor/shared/editorApiClient';
|
||||
import { saveJsonObject } from '../editor/shared/jsonClient';
|
||||
import {
|
||||
buildNpcVisualSavePayload,
|
||||
@@ -6,18 +10,27 @@ import {
|
||||
} from './npcVisualEditorModel';
|
||||
import { cloneNpcLayoutConfig, type NpcLayoutConfig } from './npcVisualShared';
|
||||
|
||||
export const NPC_VISUAL_OVERRIDES_API_PATH = '/api/npc-visual-overrides';
|
||||
export const NPC_LAYOUT_CONFIG_API_PATH = '/api/npc-layout-config';
|
||||
export const NPC_VISUAL_OVERRIDES_API_PATH = buildEditorJsonApiPath(
|
||||
EDITOR_JSON_RESOURCE_IDS.npcVisualOverrides,
|
||||
);
|
||||
export const NPC_LAYOUT_CONFIG_API_PATH = buildEditorJsonApiPath(
|
||||
EDITOR_JSON_RESOURCE_IDS.npcLayoutConfig,
|
||||
);
|
||||
|
||||
type SaveJsonObjectFn = typeof saveJsonObject;
|
||||
type SaveEditorJsonFn = typeof saveJsonObject;
|
||||
|
||||
export async function persistNpcVisualOverrides(params: {
|
||||
overrideMap: Record<string, MedievalNpcVisualOverride>;
|
||||
npcId: string;
|
||||
editorState: EditableNpcVisualState;
|
||||
saveJson?: SaveJsonObjectFn;
|
||||
saveJson?: SaveEditorJsonFn;
|
||||
}) {
|
||||
const { overrideMap, npcId, editorState, saveJson = saveJsonObject } = params;
|
||||
const {
|
||||
overrideMap,
|
||||
npcId,
|
||||
editorState,
|
||||
saveJson = saveJsonObject,
|
||||
} = params;
|
||||
const nextOverrideMap = buildNpcVisualSavePayload(overrideMap, npcId, editorState);
|
||||
|
||||
await saveJson(
|
||||
@@ -34,7 +47,7 @@ export async function persistNpcVisualOverrides(params: {
|
||||
|
||||
export async function persistNpcLayoutConfig(params: {
|
||||
layoutDraft: NpcLayoutConfig;
|
||||
saveJson?: SaveJsonObjectFn;
|
||||
saveJson?: SaveEditorJsonFn;
|
||||
}) {
|
||||
const { layoutDraft, saveJson = saveJsonObject } = params;
|
||||
const nextLayout = cloneNpcLayoutConfig(layoutDraft);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { validateCharacterOverrides } from '../../data/editorValidation';
|
||||
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
|
||||
import { getScenePresetsByWorld } from '../../data/scenePresets';
|
||||
import { cloneValue } from '../../editor/shared/cloneValue';
|
||||
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
|
||||
import { EditorEmptyState } from '../../editor/shared/EditorEmptyState';
|
||||
import { EditorSelectionCard } from '../../editor/shared/EditorSelectionCard';
|
||||
import {
|
||||
@@ -83,7 +84,7 @@ export function CharacterPresetPanel() {
|
||||
)
|
||||
: null;
|
||||
const { isSaving, saveMessage, save } = useJsonSave({
|
||||
endpoint: '/api/character-overrides',
|
||||
resourceId: EDITOR_JSON_RESOURCE_IDS.characterOverrides,
|
||||
payload: overrideMap as Record<string, unknown>,
|
||||
validate: () =>
|
||||
validateCharacterOverrides(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type MonsterPresetOverride,
|
||||
} from '../../data/hostileNpcPresets';
|
||||
import monsterOverridesJson from '../../data/monsterOverrides.json';
|
||||
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
|
||||
import { EditorSelectionCard } from '../../editor/shared/EditorSelectionCard';
|
||||
import {
|
||||
NumberField,
|
||||
@@ -43,7 +44,7 @@ export function MonsterPresetPanel() {
|
||||
const [previewAnimation, setPreviewAnimation] =
|
||||
useState<(typeof MONSTER_ANIMATION_OPTIONS)[number]>('idle');
|
||||
const { isSaving, saveMessage, save } = useJsonSave({
|
||||
endpoint: '/api/monster-overrides',
|
||||
resourceId: EDITOR_JSON_RESOURCE_IDS.monsterOverrides,
|
||||
payload: overrideMap as Record<string, unknown>,
|
||||
validate: () => validateMonsterOverrides(overrideMap, allMonsters),
|
||||
successMessage: '敌人预设覆盖已保存到 src/data/monsterOverrides.json。',
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { validateSceneNpcOverrides } from '../../data/editorValidation';
|
||||
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
|
||||
import sceneNpcOverridesJson from '../../data/sceneNpcOverrides.json';
|
||||
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
|
||||
import {
|
||||
getScenePresetsByWorld,
|
||||
type SceneNpcPresetOverride,
|
||||
@@ -116,7 +117,7 @@ export function SceneNpcPresetPanel() {
|
||||
(effectiveNpc?.initialAffinity ?? 0) < 0,
|
||||
);
|
||||
const { isSaving, saveMessage, save } = useJsonSave({
|
||||
endpoint: '/api/scene-npc-overrides',
|
||||
resourceId: EDITOR_JSON_RESOURCE_IDS.sceneNpcOverrides,
|
||||
payload: overrideMap as Record<string, unknown>,
|
||||
validate: () =>
|
||||
validateSceneNpcOverrides(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { validateSceneOverrides } from '../../data/editorValidation';
|
||||
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
|
||||
import { createSceneHostileNpcsFromIds } from '../../data/hostileNpcs';
|
||||
import sceneOverridesJson from '../../data/sceneOverrides.json';
|
||||
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
|
||||
import {
|
||||
getSceneHostileNpcPresetIds,
|
||||
getSceneHostileNpcs,
|
||||
@@ -46,7 +47,7 @@ export function ScenePresetPanel() {
|
||||
);
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>('monster');
|
||||
const { isSaving, saveMessage, save } = useJsonSave({
|
||||
endpoint: '/api/scene-overrides',
|
||||
resourceId: EDITOR_JSON_RESOURCE_IDS.sceneOverrides,
|
||||
payload: overrideMap as Record<string, unknown>,
|
||||
validate: () =>
|
||||
validateSceneOverrides(overrideMap, allScenes, MONSTER_PRESETS_BY_WORLD),
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import {
|
||||
fetchJson,
|
||||
parseApiErrorMessage,
|
||||
} from '../../editor/shared/jsonClient';
|
||||
ASSET_API_PATHS,
|
||||
postApiJson,
|
||||
} from '../../editor/shared/editorApiClient';
|
||||
import { fetchJson } from '../../editor/shared/jsonClient';
|
||||
|
||||
export const CHARACTER_VISUAL_GENERATE_API_PATH =
|
||||
'/api/character-visual/generate';
|
||||
ASSET_API_PATHS.characterVisualGenerate;
|
||||
export const CHARACTER_VISUAL_PUBLISH_API_PATH =
|
||||
'/api/character-visual/publish';
|
||||
export const CHARACTER_VISUAL_JOB_API_PATH = '/api/character-visual/jobs';
|
||||
export const CHARACTER_ANIMATION_GENERATE_API_PATH = '/api/animation/generate';
|
||||
export const CHARACTER_ANIMATION_PUBLISH_API_PATH = '/api/animation/publish';
|
||||
export const CHARACTER_ANIMATION_JOB_API_PATH = '/api/animation/jobs';
|
||||
ASSET_API_PATHS.characterVisualPublish;
|
||||
export const CHARACTER_VISUAL_JOB_API_PATH = ASSET_API_PATHS.characterVisualJobs;
|
||||
export const CHARACTER_ANIMATION_GENERATE_API_PATH =
|
||||
ASSET_API_PATHS.characterAnimationGenerate;
|
||||
export const CHARACTER_ANIMATION_PUBLISH_API_PATH =
|
||||
ASSET_API_PATHS.characterAnimationPublish;
|
||||
export const CHARACTER_ANIMATION_JOB_API_PATH =
|
||||
ASSET_API_PATHS.characterAnimationJobs;
|
||||
export const CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH =
|
||||
'/api/animation/import-video';
|
||||
ASSET_API_PATHS.characterAnimationImportVideo;
|
||||
export const CHARACTER_ANIMATION_TEMPLATES_API_PATH =
|
||||
'/api/animation/templates';
|
||||
ASSET_API_PATHS.characterAnimationTemplates;
|
||||
|
||||
export type CharacterVisualSourceMode =
|
||||
| 'text-to-image'
|
||||
@@ -119,26 +123,13 @@ export type CharacterAssetJobStatus = {
|
||||
export async function generateCharacterVisualCandidates(
|
||||
payload: CharacterVisualGenerationPayload,
|
||||
) {
|
||||
const response = await fetch(CHARACTER_VISUAL_GENERATE_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
parseApiErrorMessage(responseText, '生成角色主形象候选失败'),
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.parse(responseText) as {
|
||||
return postApiJson<{
|
||||
ok: true;
|
||||
taskId: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
drafts: CharacterVisualDraft[];
|
||||
};
|
||||
}>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象候选失败');
|
||||
}
|
||||
|
||||
export async function fetchCharacterVisualJobStatus(taskId: string) {
|
||||
@@ -151,41 +142,19 @@ export async function fetchCharacterVisualJobStatus(taskId: string) {
|
||||
export async function publishCharacterVisualAsset(
|
||||
payload: CharacterVisualPublishPayload,
|
||||
) {
|
||||
const response = await fetch(CHARACTER_VISUAL_PUBLISH_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(parseApiErrorMessage(responseText, '发布角色主形象失败'));
|
||||
}
|
||||
|
||||
return JSON.parse(responseText) as {
|
||||
return postApiJson<{
|
||||
ok: true;
|
||||
assetId: string;
|
||||
portraitPath: string;
|
||||
overrideMap: Record<string, unknown>;
|
||||
saveMessage: string;
|
||||
};
|
||||
}>(CHARACTER_VISUAL_PUBLISH_API_PATH, payload, '发布角色主形象失败');
|
||||
}
|
||||
|
||||
export async function generateCharacterAnimationDraft(
|
||||
payload: CharacterAnimationGenerationPayload,
|
||||
) {
|
||||
const response = await fetch(CHARACTER_ANIMATION_GENERATE_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(parseApiErrorMessage(responseText, '生成角色动作草稿失败'));
|
||||
}
|
||||
|
||||
return JSON.parse(responseText) as
|
||||
return postApiJson<
|
||||
| {
|
||||
ok: true;
|
||||
taskId: string;
|
||||
@@ -201,7 +170,8 @@ export async function generateCharacterAnimationDraft(
|
||||
model: string;
|
||||
prompt: string;
|
||||
previewVideoPath: string;
|
||||
};
|
||||
}
|
||||
>(CHARACTER_ANIMATION_GENERATE_API_PATH, payload, '生成角色动作草稿失败');
|
||||
}
|
||||
|
||||
export async function fetchCharacterAnimationJobStatus(taskId: string) {
|
||||
@@ -224,23 +194,12 @@ export async function importCharacterAnimationVideo(payload: {
|
||||
videoSource: string;
|
||||
sourceLabel?: string;
|
||||
}) {
|
||||
const response = await fetch(CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(parseApiErrorMessage(responseText, '导入动作视频失败'));
|
||||
}
|
||||
|
||||
return JSON.parse(responseText) as {
|
||||
return postApiJson<{
|
||||
ok: true;
|
||||
importedVideoPath: string;
|
||||
draftId: string;
|
||||
saveMessage: string;
|
||||
};
|
||||
}>(CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH, payload, '导入动作视频失败');
|
||||
}
|
||||
|
||||
export async function publishCharacterAnimationAssets(payload: {
|
||||
@@ -249,22 +208,11 @@ export async function publishCharacterAnimationAssets(payload: {
|
||||
animations: Record<string, CharacterAnimationDraftPayload>;
|
||||
updateCharacterOverride?: boolean;
|
||||
}) {
|
||||
const response = await fetch(CHARACTER_ANIMATION_PUBLISH_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(parseApiErrorMessage(responseText, '发布角色基础动作失败'));
|
||||
}
|
||||
|
||||
return JSON.parse(responseText) as {
|
||||
return postApiJson<{
|
||||
ok: true;
|
||||
animationSetId: string;
|
||||
overrideMap: Record<string, unknown>;
|
||||
animationMap: Record<string, unknown>;
|
||||
saveMessage: string;
|
||||
};
|
||||
}>(CHARACTER_ANIMATION_PUBLISH_API_PATH, payload, '发布角色基础动作失败');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user