Merge remote-tracking branch 'origin/server_node'
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-08 19:16:55 +08:00
69 changed files with 8262 additions and 81 deletions

View File

@@ -22,6 +22,11 @@ import {
generateCustomWorldSceneImage,
} from '../services/ai';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
} from '../services/aiService';
import {
buildCustomWorldSceneImagePrompt,
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
} from '../services/customWorld';
import {
AnimationState,
CustomWorldLandmark,

View File

@@ -2,7 +2,7 @@ import { motion } from 'motion/react';
import type {
CustomWorldGenerationProgress,
} from '../services/ai';
} from '../services/aiService';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
interface CustomWorldGenerationViewProps {

View File

@@ -0,0 +1,160 @@
import { type ReactNode, useEffect, useState } from 'react';
import {
AUTH_STATE_EVENT,
getStoredAccessToken,
} from '../../services/apiClient';
import {
type AuthUser,
ensureAutoAuthUser,
getCurrentAuthUser,
logoutAuthUser,
} from '../../services/authService';
type AuthGateProps = {
children: ReactNode;
};
type AuthStatus = 'checking' | 'recovering' | 'ready' | 'error';
export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState<AuthStatus>('checking');
const [user, setUser] = useState<AuthUser | null>(null);
const [error, setError] = useState('');
useEffect(() => {
let isActive = true;
const ensureAutoUser = async () => {
if (!isActive) {
return;
}
setStatus('recovering');
try {
const { user: nextUser } = await ensureAutoAuthUser();
if (!isActive) {
return;
}
setUser(nextUser);
setStatus('ready');
setError('');
} catch (autoAuthError) {
if (!isActive) {
return;
}
setUser(null);
setStatus('error');
setError(
autoAuthError instanceof Error
? autoAuthError.message
: '自动登录失败,请稍后再试。',
);
}
};
const hydrate = async () => {
const token = getStoredAccessToken();
if (!token) {
await ensureAutoUser();
return;
}
try {
const nextUser = await getCurrentAuthUser();
if (!isActive) {
return;
}
if (nextUser) {
setUser(nextUser);
setStatus('ready');
setError('');
return;
}
await ensureAutoUser();
} catch {
if (!isActive) {
return;
}
await ensureAutoUser();
}
};
void hydrate();
const handleAuthStateChange = () => {
setStatus('checking');
void hydrate();
};
window.addEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
return () => {
isActive = false;
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
};
}, []);
if (status === 'checking') {
return (
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
...
</div>
);
}
if (status === 'recovering') {
return (
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
...
</div>
);
}
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="mt-3 text-sm leading-6 text-zinc-300">
{error || '账号恢复失败,请刷新页面后重试。'}
</div>
<button
type="button"
className="mt-5 rounded-full border border-amber-300/30 px-4 py-2 text-sm text-amber-100 transition hover:border-amber-300/60 hover:bg-amber-300/10"
onClick={() => {
window.location.reload();
}}
>
</button>
</div>
</div>
);
}
return (
<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-amber-300/40 hover:text-amber-100"
onClick={() => {
void logoutAuthUser();
}}
>
退
</button>
</div>
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useState } from 'react';
type LoginScreenProps = {
loading: boolean;
error: string;
onSubmit: (username: string, password: string) => Promise<void>;
};
export function LoginScreen({
loading,
error,
onSubmit,
}: LoginScreenProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
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="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
</p>
<h1 className="mt-4 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>
<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(username, password);
}}
>
<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"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="hero_name"
/>
</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 位"
/>
</label>
{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={loading}
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 ? '正在进入...' : '进入游戏'}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -9,16 +9,21 @@ import {
upsertSavedCustomWorldProfile,
} from '../../data/customWorldLibrary';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import { getScenePreset } from '../../data/scenePresets';
import {
type CustomWorldGenerationProgress,
generateCustomWorldProfile,
} from '../../services/ai';
} from '../../services/aiService';
import {
buildCustomWorldCreatorIntentDisplayText,
buildCustomWorldCreatorIntentGenerationText,
createEmptyCustomWorldCreatorIntent,
} from '../../services/customWorldCreatorIntent';
import { detectCustomWorldThemeMode } from '../../services/customWorldTheme';
import {
listCustomWorldLibrary,
upsertCustomWorldProfile,
} from '../../services/storageService';
import {
type CustomWorldCreatorIntent,
type CustomWorldGenerationMode,
@@ -141,7 +146,7 @@ export function PreGameSelectionFlow({
useState<GameState['customWorldProfile']>(null);
const [savedCustomWorldProfiles, setSavedCustomWorldProfiles] = useState<
CustomWorldProfile[]
>(() => readSavedCustomWorldProfiles());
>([]);
const [showDeveloperTeamModal, setShowDeveloperTeamModal] = useState(false);
const [showCustomWorldModal, setShowCustomWorldModal] = useState(false);
const [customWorldCreatorIntent, setCustomWorldCreatorIntent] =
@@ -217,6 +222,25 @@ export function PreGameSelectionFlow({
},
[],
);
useEffect(() => {
let isActive = true;
void listCustomWorldLibrary()
.then((profiles) => {
if (!isActive) return;
setSavedCustomWorldProfiles(profiles);
})
.catch((error) => {
console.warn(
'[PreGameSelectionFlow] failed to load custom world library',
error,
);
});
return () => {
isActive = false;
};
}, []);
const leaveCustomWorldResult = () => {
setGeneratedCustomWorldProfile(null);
@@ -268,18 +292,18 @@ export function PreGameSelectionFlow({
setShowCustomWorldModal(true);
};
const saveGeneratedCustomWorld = () => {
const saveGeneratedCustomWorld = async () => {
if (!generatedCustomWorldProfile) {
return;
}
try {
setSavedCustomWorldProfiles(
upsertSavedCustomWorldProfile(generatedCustomWorldProfile),
await upsertCustomWorldProfile(generatedCustomWorldProfile),
);
} catch (error) {
setCustomWorldError(
error instanceof Error ? error.message : '本地保存自定义世界失败。',
error instanceof Error ? error.message : '保存自定义世界失败。',
);
return;
}
@@ -446,7 +470,7 @@ export function PreGameSelectionFlow({
id: generatedCustomWorldProfile.id,
}
: profile;
const savedProfiles = upsertSavedCustomWorldProfile(persistedProfile);
const savedProfiles = await upsertCustomWorldProfile(persistedProfile);
setSavedCustomWorldProfiles(savedProfiles);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
@@ -760,7 +784,24 @@ export function PreGameSelectionFlow({
onContinueExpand={() => {
void continueExpandCustomWorld();
}}
onSave={saveGeneratedCustomWorld}
onRegeneratePlayableNpc={(id) => {
void regeneratePlayableNpc(id);
}}
onRegenerateStoryNpc={(id) => {
void regenerateStoryNpc(id);
}}
onRegenerateLandmark={(id) => {
void regenerateLandmark(id);
}}
onRegenerateStoryExpansion={() => {
void regenerateStoryExpansion();
}}
onRegenerateLandmarkNetwork={() => {
void regenerateLandmarkNetwork();
}}
onSave={() => {
void saveGeneratedCustomWorld();
}}
/>
</motion.div>
)}