feat(characters): use local character catalog

This commit is contained in:
2026-07-17 16:03:18 +08:00
parent a210a98d98
commit b3ebd5cf3b
96 changed files with 1023 additions and 522 deletions
@@ -3,9 +3,6 @@
*
*
* `SizedBox.expand(child: Assets.images.picBgHome.image(fit: BoxFit.cover))`
* 资源: /public/images/cover/elio.png
* 原名: pic_bg_home.png (snake_case)
*
* sizes 属性说明:
* 图片在 MobileShell 内(max-width: 540px)。
* - 视口 ≤ 540pxMobileShell = 100vw,图片 = 100vw
@@ -13,11 +10,15 @@
*/
import Image from "next/image";
export function SplashBackground() {
export function SplashBackground({
src = "/images/cover/elio.png",
}: {
src?: string;
}) {
return (
<div className="absolute inset-0 z-0 overflow-hidden bg-sidebar-background">
<Image
src="/images/cover/elio.png"
src={src}
alt=""
fill
priority
@@ -8,12 +8,16 @@ import styles from "./splash-latest-message.module.css";
export interface SplashLatestMessageProps {
message: string | null;
characterName: string;
avatarUrl: string;
isLoading?: boolean;
onOpenChat: () => void;
}
export function SplashLatestMessage({
message,
characterName,
avatarUrl,
isLoading = false,
onOpenChat,
}: SplashLatestMessageProps) {
@@ -26,10 +30,11 @@ export function SplashLatestMessage({
data-analytics-label="Open latest message"
className={styles.card}
onClick={onOpenChat}
aria-label="Open latest message from Elio"
aria-label={`Open latest message from ${characterName}`}
>
<span className={styles.avatarWrap}>
<CharacterAvatar
src={avatarUrl}
alt=""
size="100%"
imageSize={42}
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import type { LoginStatus } from "@/data/schemas/auth";
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
import { useHasHydrated } from "@/hooks/use-has-hydrated";
import { loadSplashLatestMessagePreview } from "@/lib/chat/splash_latest_message";
import { useSplashLatestMessageCache } from "@/providers/splash-latest-message-provider";
@@ -12,6 +13,7 @@ import { Result } from "@/utils/result";
const log = new Logger("SplashLatestMessage");
export interface UseSplashLatestMessageInput {
characterId?: string;
hasInitialized: boolean;
isAuthLoading: boolean;
loginStatus: LoginStatus;
@@ -29,6 +31,7 @@ interface SplashLatestMessageState {
}
export function useSplashLatestMessage({
characterId = DEFAULT_CHARACTER_ID,
hasInitialized,
isAuthLoading,
loginStatus,
@@ -45,7 +48,7 @@ export function useSplashLatestMessage({
hasInitialized &&
!isAuthLoading &&
loginStatus !== "notLoggedIn";
const requestKey = shouldLoad ? loginStatus : "";
const requestKey = shouldLoad ? `${loginStatus}:${characterId}` : "";
useEffect(() => {
if (!shouldLoad) return;
@@ -53,7 +56,10 @@ export function useSplashLatestMessage({
let cancelled = false;
const loadLatestMessage = async () => {
const result = await loadSplashLatestMessagePreview({ cache });
const result = await loadSplashLatestMessagePreview({
cache,
characterId,
});
if (cancelled) return;
if (Result.isErr(result)) {
@@ -77,7 +83,7 @@ export function useSplashLatestMessage({
return () => {
cancelled = true;
};
}, [cache, requestKey, shouldLoad]);
}, [cache, characterId, requestKey, shouldLoad]);
if (!shouldLoad) return { message: null, isLoading: false };
if (state.key !== requestKey || !state.loaded) {
+19 -3
View File
@@ -1,5 +1,21 @@
import { SplashScreen } from "@/app/splash/splash-screen";
import { redirect } from "next/navigation";
export default function SplashPage() {
return <SplashScreen />;
import { DEFAULT_CHARACTER_SLUG } from "@/data/constants/character";
import {
appendRouteSearchParams,
getCharacterRoutes,
type RouteSearchParams,
} from "@/router/routes";
export default async function SplashPage({
searchParams,
}: {
searchParams: Promise<RouteSearchParams>;
}) {
redirect(
appendRouteSearchParams(
getCharacterRoutes(DEFAULT_CHARACTER_SLUG).splash,
await searchParams,
),
);
}
+14 -5
View File
@@ -3,8 +3,11 @@
import { useEffect } from "react";
import { AppBottomNav, MobileShell } from "@/app/_components/core";
import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator";
import {
useActiveCharacter,
useActiveCharacterRoutes,
} from "@/providers/character-provider";
import { useAuthState } from "@/stores/auth/auth-context";
import { pwaUtil } from "@/utils/pwa";
@@ -20,8 +23,11 @@ import styles from "./components/splash-screen.module.css";
export function SplashScreen() {
const navigator = useAppNavigator();
const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes();
const authState = useAuthState();
const latestMessage = useSplashLatestMessage({
characterId: character.id,
hasInitialized: authState.hasInitialized,
isAuthLoading: authState.isLoading,
loginStatus: authState.loginStatus,
@@ -32,11 +38,11 @@ export function SplashScreen() {
};
const handleOpenPrivateRoom = () => {
navigator.push(ROUTES.privateRoom, { scroll: false });
navigator.push(characterRoutes.privateRoom, { scroll: false });
};
const handleOpenSplash = () => {
navigator.push(ROUTES.splash, { scroll: false });
navigator.push(characterRoutes.splash, { scroll: false });
};
useEffect(() => {
@@ -46,7 +52,7 @@ export function SplashScreen() {
return (
<MobileShell background="var(--color-sidebar-background)">
<div className={styles.wrapper}>
<SplashBackground />
<SplashBackground src={character.assets.cover} />
{/* 渐变叠加层:accent → transparent (bottom-left → center-right) */}
<div className={styles.gradientOverlay} aria-hidden="true" />
<div className={styles.content}>
@@ -54,6 +60,8 @@ export function SplashScreen() {
<div className={styles.spacer} />
<SplashLatestMessage
message={latestMessage.message}
characterName={character.shortName}
avatarUrl={character.assets.avatar}
isLoading={latestMessage.isLoading}
onOpenChat={handleStartChat}
/>
@@ -62,7 +70,7 @@ export function SplashScreen() {
<SplashButton onStartChat={handleStartChat} />
</div>
<p className={styles.bottom}>
Elio Silvestri, Your exclusive AI boyfriend
{character.displayName}, {character.copy.splashRelationship}
<br />
24/7 online | Chat | Companion | Heal | Sweet moments
</p>
@@ -70,6 +78,7 @@ export function SplashScreen() {
<AppBottomNav
activeItem="chat"
variant="dark"
privateRoomLabel={character.copy.privateRoomTitle}
onChatClick={handleOpenSplash}
onPrivateRoomClick={handleOpenPrivateRoom}
/>