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
+12 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { getRouteAccess, isInternalRoute } from "../route-meta";
import { ALL_STATIC_ROUTES, ROUTES } from "../routes";
import { ALL_STATIC_ROUTES, getCharacterRoutes, ROUTES } from "../routes";
describe("route meta", () => {
it("classifies static routes by access level", () => {
@@ -21,6 +21,17 @@ describe("route meta", () => {
expect(getRouteAccess("/chat/image/msg_1")).toBe("public");
});
it("classifies character-scoped routes by their page", () => {
const routes = getCharacterRoutes("maya");
expect(getRouteAccess(routes.splash)).toBe("authOnly");
expect(getRouteAccess(routes.chat)).toBe("guestEntry");
expect(getRouteAccess(routes.privateRoom)).toBe("guestEntry");
expect(getRouteAccess(routes.tip)).toBe("public");
expect(getRouteAccess(routes.sidebar)).toBe("session");
expect(getRouteAccess(routes.feedback)).toBe("session");
expect(getRouteAccess(routes.coinsRules)).toBe("public");
});
it("includes private room in static routes", () => {
expect(ALL_STATIC_ROUTES).toContain(ROUTES.privateRoom);
});
+15 -1
View File
@@ -21,8 +21,22 @@ const STATIC_ROUTE_ACCESS: Partial<Record<string, RouteAccess>> = {
[ROUTES.coinsRules]: "public",
};
const CHARACTER_ROUTE_ACCESS: Readonly<Record<string, RouteAccess>> = {
splash: "authOnly",
chat: "guestEntry",
"private-room": "guestEntry",
tip: "public",
sidebar: "session",
feedback: "session",
"coins-rules": "public",
};
export function getRouteAccess(pathname: string): RouteAccess {
return STATIC_ROUTE_ACCESS[pathname] ?? "public";
const staticAccess = STATIC_ROUTE_ACCESS[pathname];
if (staticAccess) return staticAccess;
const match = pathname.match(/^\/characters\/[^/]+\/([^/]+)\/?$/);
return (match?.[1] && CHARACTER_ROUTE_ACCESS[match[1]]) || "public";
}
export function isInternalRoute(value: string | null | undefined): boolean {
+51
View File
@@ -9,6 +9,7 @@
import type { PayChannel } from "@/data/schemas/payment";
import type { AppSubscriptionReturnTo } from "@/router/navigation-types";
import type { CharacterProfile } from "@/data/constants/character";
import {
PAYMENT_ANALYTICS_ENTRY_PARAM,
PAYMENT_ANALYTICS_REASON_PARAM,
@@ -32,6 +33,52 @@ export const ROUTES = {
export type StaticRoute = (typeof ROUTES)[keyof typeof ROUTES];
export interface CharacterRoutes {
readonly splash: string;
readonly chat: string;
readonly privateRoom: string;
readonly tip: string;
readonly sidebar: string;
readonly feedback: string;
readonly coinsRules: string;
}
export function getCharacterRoutes(
characterSlug: CharacterProfile["slug"],
): CharacterRoutes {
const root = `/characters/${encodeURIComponent(characterSlug)}`;
return {
splash: `${root}/splash`,
chat: `${root}/chat`,
privateRoom: `${root}/private-room`,
tip: `${root}/tip`,
sidebar: `${root}/sidebar`,
feedback: `${root}/feedback`,
coinsRules: `${root}/coins-rules`,
};
}
export type RouteSearchParams = Record<
string,
string | readonly string[] | undefined
>;
export function appendRouteSearchParams(
pathname: string,
searchParams: RouteSearchParams,
): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(searchParams)) {
if (typeof value === "string") {
params.set(key, value);
continue;
}
value?.forEach((item) => params.append(key, item));
}
const query = params.toString();
return query ? `${pathname}?${query}` : pathname;
}
/** 动态查询路由构造器 */
export const ROUTE_BUILDERS = {
subscription: (
@@ -39,12 +86,16 @@ export const ROUTE_BUILDERS = {
options: {
payChannel?: PayChannel;
returnTo?: Exclude<AppSubscriptionReturnTo, null>;
characterSlug?: string;
analytics?: PaymentAnalyticsContext;
} = {},
): `/subscription?${string}` => {
const params = new URLSearchParams({ type });
if (options.payChannel) params.set("payChannel", options.payChannel);
if (options.returnTo) params.set("returnTo", options.returnTo);
if (options.characterSlug) {
params.set("character", options.characterSlug);
}
if (options.analytics) {
params.set(
PAYMENT_ANALYTICS_ENTRY_PARAM,
+59 -25
View File
@@ -16,10 +16,13 @@ import {
} from "@/lib/analytics";
import { useAuthSelector } from "@/stores/auth/auth-context";
import { useUserSelector } from "@/stores/user/user-context";
import {
useActiveCharacter,
useActiveCharacterRoutes,
} from "@/providers/character-provider";
import {
ROUTE_BUILDERS,
ROUTES,
type Route,
} from "./routes";
import {
@@ -39,8 +42,14 @@ export interface AppNavigator {
openAuth: (redirectTo?: string) => void;
openChat: (options?: { replace?: boolean; scroll?: boolean }) => void;
openSubscription: (input: OpenSubscriptionInput) => void;
exitSubscription: (returnTo: SubscriptionReturnTo) => void;
exitSubscriptionAfterSuccess: (returnTo: SubscriptionReturnTo) => void;
exitSubscription: (
returnTo: SubscriptionReturnTo,
characterSlug?: string,
) => void;
exitSubscriptionAfterSuccess: (
returnTo: SubscriptionReturnTo,
characterSlug?: string,
) => void;
startMessageUnlock: (input: StartMessageUnlockInput) => void;
openSubscriptionForPendingUnlock: (
input: OpenSubscriptionForPendingUnlockInput,
@@ -51,6 +60,8 @@ export interface AppNavigator {
export function useAppNavigator(): AppNavigator {
const router = useRouter();
const character = useActiveCharacter();
const characterRoutes = useActiveCharacterRoutes();
const loginStatus = useAuthSelector((state) => state.context.loginStatus);
const countryCode = useUserSelector(
(state) => state.context.currentUser?.countryCode,
@@ -69,18 +80,24 @@ export function useAppNavigator(): AppNavigator {
router.back();
}, [router]);
const openAuth = useCallback((redirectTo: string = ROUTES.chat): void => {
router.push(ROUTE_BUILDERS.authWithRedirect(redirectTo));
}, [router]);
const openAuth = useCallback(
(redirectTo: string = characterRoutes.chat): void => {
router.push(ROUTE_BUILDERS.authWithRedirect(redirectTo));
},
[characterRoutes.chat, router],
);
const openChat = useCallback((options: { replace?: boolean; scroll?: boolean } = {}): void => {
const navOptions = { scroll: options.scroll ?? false };
if (options.replace) {
router.replace(ROUTES.chat, navOptions);
return;
}
router.push(ROUTES.chat, navOptions);
}, [router]);
const openChat = useCallback(
(options: { replace?: boolean; scroll?: boolean } = {}): void => {
const navOptions = { scroll: options.scroll ?? false };
if (options.replace) {
router.replace(characterRoutes.chat, navOptions);
return;
}
router.push(characterRoutes.chat, navOptions);
},
[characterRoutes.chat, router],
);
const getDefaultPayChannel = useCallback(() => {
return getDefaultPayChannelForCountryCode(countryCode);
@@ -97,6 +114,7 @@ export function useAppNavigator(): AppNavigator {
const target = ROUTE_BUILDERS.subscription(type, {
payChannel,
returnTo: returnTo ?? undefined,
characterSlug: character.slug,
analytics,
});
@@ -113,7 +131,7 @@ export function useAppNavigator(): AppNavigator {
}
router.push(nextUrl);
},
[getDefaultPayChannel, loginStatus, router],
[character.slug, getDefaultPayChannel, loginStatus, router],
);
const startMessageUnlock = useCallback(
@@ -185,17 +203,33 @@ export function useAppNavigator(): AppNavigator {
[getDefaultPayChannel, openSubscription],
);
const exitSubscription = useCallback((returnTo: SubscriptionReturnTo): void => {
void (async () => {
router.replace(await consumeSubscriptionExitUrl(returnTo));
})();
}, [router]);
const exitSubscription = useCallback(
(
returnTo: SubscriptionReturnTo,
characterSlug: string = character.slug,
): void => {
void (async () => {
router.replace(
await consumeSubscriptionExitUrl(returnTo, characterSlug),
);
})();
},
[character.slug, router],
);
const exitSubscriptionAfterSuccess = useCallback((returnTo: SubscriptionReturnTo): void => {
void (async () => {
router.replace(await resolveSubscriptionSuccessExitUrl(returnTo));
})();
}, [router]);
const exitSubscriptionAfterSuccess = useCallback(
(
returnTo: SubscriptionReturnTo,
characterSlug: string = character.slug,
): void => {
void (async () => {
router.replace(
await resolveSubscriptionSuccessExitUrl(returnTo, characterSlug),
);
})();
},
[character.slug, router],
);
return useMemo(() => ({
push,