diff --git a/src/app/sidebar/components/guest-panel.tsx b/src/app/sidebar/components/guest-panel.tsx
new file mode 100644
index 00000000..917447c0
--- /dev/null
+++ b/src/app/sidebar/components/guest-panel.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import Link from "next/link";
+
+import { ROUTES } from "@/router/routes";
+
+import styles from "./sidebar-screen.module.css";
+
+/**
+ * 未登录态欢迎面板
+ *
+ * 显示欢迎语与 Sign in / Sign up 入口。
+ */
+export function GuestPanel() {
+ return (
+
+
Welcome to CozSweet
+
+ Sign in to sync your chats and unlock VIP.
+
+
+
+ Sign up
+
+
+ Sign in
+
+
+
+ );
+}
diff --git a/src/app/sidebar/components/index.ts b/src/app/sidebar/components/index.ts
index 16945812..49b73419 100644
--- a/src/app/sidebar/components/index.ts
+++ b/src/app/sidebar/components/index.ts
@@ -2,4 +2,7 @@
* @file Automatically generated by barrelsby.
*/
+export * from "./guest-panel";
export * from "./sidebar-screen";
+export * from "./user-info-card";
+export * from "./vip-cta";
diff --git a/src/app/sidebar/components/sidebar-screen.module.css b/src/app/sidebar/components/sidebar-screen.module.css
index 5a6b2540..008326cf 100644
--- a/src/app/sidebar/components/sidebar-screen.module.css
+++ b/src/app/sidebar/components/sidebar-screen.module.css
@@ -47,3 +47,229 @@
align-items: flex-start;
gap: var(--spacing-lg, 16px);
}
+
+/* ============================================================
+ * 用户信息区
+ * ============================================================ */
+.profileArea {
+ padding: 0 var(--spacing-md, 12px);
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: var(--spacing-lg, 16px);
+ width: 100%;
+}
+
+.userInfoCard {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md, 12px);
+ padding: var(--spacing-lg, 16px);
+ background: var(--color-settings-card-background, #ffffff);
+ border-radius: var(--radius-lg, 12px);
+ color: #000;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.userInfoAvatar {
+ border-radius: var(--radius-full, 999px);
+ object-fit: cover;
+ flex-shrink: 0;
+}
+
+.userInfoAvatarFallback {
+ width: 64px;
+ height: 64px;
+ border-radius: var(--radius-full, 999px);
+ background: var(--color-settings-row-background, #f2f2f2);
+ color: #000;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: var(--font-size-xl, 20px);
+ font-weight: 600;
+ flex-shrink: 0;
+}
+
+.userInfoText {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs, 4px);
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+.userInfoNameRow {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm, 8px);
+ min-width: 0;
+}
+
+.userInfoName {
+ font-size: var(--font-size-lg, 16px);
+ font-weight: 600;
+ color: #000;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+}
+
+.userInfoEmail {
+ font-size: var(--font-size-sm, 14px);
+ color: var(--color-text-secondary, #666);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.userInfoCoinRow {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-xs, 4px);
+ font-size: var(--font-size-sm, 14px);
+ color: var(--color-text-secondary, #666);
+}
+
+.vipBadge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px var(--spacing-sm, 8px);
+ border-radius: var(--radius-full, 999px);
+ background: var(--color-accent, #7c5cff);
+ color: var(--color-text-primary, #ffffff);
+ font-size: var(--font-size-xs, 12px);
+ font-weight: 600;
+ letter-spacing: 0.5px;
+ flex-shrink: 0;
+}
+
+.vipCta {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-md, 12px) var(--spacing-lg, 16px);
+ border-radius: var(--radius-full, 999px);
+ background: linear-gradient(
+ 90deg,
+ var(--color-button-gradient-start, #7c5cff),
+ var(--color-button-gradient-end, #b48bff)
+ );
+ color: var(--color-text-primary, #ffffff);
+ font-size: var(--font-size-lg, 16px);
+ font-weight: 600;
+ border: 0;
+ cursor: pointer;
+ width: 100%;
+ text-decoration: none;
+}
+
+.vipCta:hover {
+ opacity: 0.9;
+}
+
+.vipCta:focus-visible {
+ outline: 2px solid var(--color-accent, #7c5cff);
+ outline-offset: 2px;
+}
+
+/* ============================================================
+ * 未登录态 GuestPanel
+ * ============================================================ */
+.guestPanel {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-md, 12px);
+ padding: var(--spacing-lg, 16px);
+ background: var(--color-settings-card-background, #ffffff);
+ border-radius: var(--radius-lg, 12px);
+ color: #000;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.guestTitle {
+ font-size: var(--font-size-xl, 20px);
+ font-weight: 600;
+ color: #000;
+ margin: 0;
+}
+
+.guestSubtitle {
+ font-size: var(--font-size-sm, 14px);
+ color: var(--color-text-secondary, #666);
+ margin: 0;
+}
+
+.guestActions {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm, 8px);
+ margin-top: var(--spacing-sm, 8px);
+}
+
+.guestPrimaryLink {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-md, 12px) var(--spacing-lg, 16px);
+ border-radius: var(--radius-full, 999px);
+ background: linear-gradient(
+ 90deg,
+ var(--color-button-gradient-start, #7c5cff),
+ var(--color-button-gradient-end, #b48bff)
+ );
+ color: var(--color-text-primary, #ffffff);
+ font-size: var(--font-size-lg, 16px);
+ font-weight: 600;
+ text-decoration: none;
+ border: 0;
+}
+
+.guestPrimaryLink:hover {
+ opacity: 0.9;
+}
+
+.guestSecondaryLink {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-md, 12px) var(--spacing-lg, 16px);
+ border-radius: var(--radius-full, 999px);
+ background: transparent;
+ color: #000;
+ font-size: var(--font-size-lg, 16px);
+ font-weight: 600;
+ text-decoration: none;
+ border: 1px solid var(--color-auth-border, #d0d0d0);
+}
+
+.guestSecondaryLink:hover {
+ background: var(--color-settings-row-background, #f2f2f2);
+}
+
+/* ============================================================
+ * 骨架占位
+ * ============================================================ */
+.skeleton {
+ display: block;
+ background: var(--color-settings-row-background, #e5e5e5);
+ border-radius: var(--radius-md, 8px);
+}
+
+.skeletonMd {
+ width: 60%;
+ height: 18px;
+}
+
+.skeletonSm {
+ width: 80%;
+ height: 14px;
+}
+
+.skeletonXs {
+ width: 40%;
+ height: 14px;
+}
diff --git a/src/app/sidebar/components/sidebar-screen.tsx b/src/app/sidebar/components/sidebar-screen.tsx
index e689c446..af66deec 100644
--- a/src/app/sidebar/components/sidebar-screen.tsx
+++ b/src/app/sidebar/components/sidebar-screen.tsx
@@ -7,21 +7,33 @@ import { useRouter } from "next/navigation";
import { MobileShell } from "@/app/_components/core/mobile-shell";
import { SettingsSection } from "@/app/_components/core/settings-section";
import { useUserDispatch, useUserState } from "@/stores/user/user-context";
-import { useAuthDispatch } from "@/stores/auth/auth-context";
+import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { ROUTES } from "@/router/routes";
import type { UserView } from "@/models/user/user";
+import { GuestPanel } from "./guest-panel";
+import { UserInfoCard } from "./user-info-card";
+import { VipCta } from "./vip-cta";
+
import styles from "./sidebar-screen.module.css";
/**
* 侧边栏主屏幕
*
+ * 根据 `loginStatus + currentUser.isVip` 派生三种状态:
+ * - guest:未登录,展示欢迎面板
+ * - member:已登录未购买 VIP,展示用户信息 + Get VIP CTA
+ * - vip:已登录且购买 VIP,展示用户信息(含 VIP 徽章) + Manage VIP CTA
+ *
* 原始 Dart: lib/ui/sidebar/sidebar_screen.dart + profile_view.dart
*/
+type SidebarState = "guest" | "member" | "vip";
+
export function SidebarScreen() {
const user = useUserState();
const userDispatch = useUserDispatch();
+ const auth = useAuthState();
const authDispatch = useAuthDispatch();
const router = useRouter();
@@ -45,6 +57,14 @@ export function SidebarScreen() {
}
}, [user.currentUser, authDispatch, router]);
+ // 状态派生:未登录 / 已登录未 VIP / 已登录 VIP
+ const sidebarState: SidebarState =
+ auth.loginStatus === "notLoggedIn"
+ ? "guest"
+ : user.currentUser?.isVip === true
+ ? "vip"
+ : "member";
+
return (
@@ -71,25 +91,53 @@ export function SidebarScreen() {
- {/* 设置区域:与 Flutter SettingsSection 对齐 */}
-
-
router.push(ROUTES.subscription),
- },
- {
- id: "logout",
- title: "Log out",
- destructive: true,
- onClick: () => userDispatch({ type: "UserLogout" }),
- },
- ]}
- />
+ {/* 用户信息区:三种状态 */}
+
+ {sidebarState === "guest" ? (
+
+ ) : user.currentUser == null ? (
+ // 已登录但 currentUser 仍在初始化 → 骨架占位
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ {/* 设置区域:仅已登录态保留 Log out;与 Flutter SettingsSection 对齐 */}
+ {sidebarState !== "guest" ? (
+
+ router.push(ROUTES.subscription),
+ },
+ {
+ id: "logout",
+ title: "Log out",
+ destructive: true,
+ onClick: () => userDispatch({ type: "UserLogout" }),
+ },
+ ]}
+ />
+
+ ) : null}
);
diff --git a/src/app/sidebar/components/user-info-card.tsx b/src/app/sidebar/components/user-info-card.tsx
new file mode 100644
index 00000000..dbafb219
--- /dev/null
+++ b/src/app/sidebar/components/user-info-card.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import Image from "next/image";
+
+import type { UserView } from "@/models/user/user";
+
+import styles from "./sidebar-screen.module.css";
+
+export interface UserInfoCardProps {
+ user: UserView;
+ coinBalance: number;
+ isVip: boolean;
+}
+
+/**
+ * 已登录用户信息卡片
+ *
+ * 头像 + 用户名(+ VIP 徽章)+ 邮箱 + 金币余额。
+ */
+export function UserInfoCard({ user, coinBalance, isVip }: UserInfoCardProps) {
+ const initial = (user.username?.[0] ?? "?").toUpperCase();
+ const hasAvatar = user.avatarUrl && user.avatarUrl.length > 0;
+
+ return (
+
+ {hasAvatar ? (
+
+ ) : (
+
+ {initial}
+
+ )}
+
+
+
+ {user.username}
+
+ {isVip ? (
+
+ VIP
+
+ ) : null}
+
+ {user.email ? (
+
+ {user.email}
+
+ ) : null}
+
+
+ {coinBalance} coins
+
+
+
+ );
+}
diff --git a/src/app/sidebar/components/vip-cta.tsx b/src/app/sidebar/components/vip-cta.tsx
new file mode 100644
index 00000000..d4c2bc60
--- /dev/null
+++ b/src/app/sidebar/components/vip-cta.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+
+import { ROUTES } from "@/router/routes";
+
+import styles from "./sidebar-screen.module.css";
+
+export type VipCtaVariant = "get" | "manage";
+
+export interface VipCtaProps {
+ variant: VipCtaVariant;
+ onClick?: () => void;
+}
+
+/**
+ * VIP CTA 按钮
+ *
+ * - `get`:未购买 VIP 时显示 "Get VIP"
+ * - `manage`:已购买 VIP 时显示 "Manage VIP"
+ *
+ * 默认点击跳转 `/subscription`(订阅页);调用方可传入 `onClick` 覆盖。
+ */
+export function VipCta({ variant, onClick }: VipCtaProps) {
+ const router = useRouter();
+ const label = variant === "get" ? "Get VIP" : "Manage VIP";
+ const handleClick = () => {
+ if (onClick) {
+ onClick();
+ } else {
+ router.push(ROUTES.subscription);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/data/storage/chat/chat_storage.ts b/src/data/storage/chat/chat_storage.ts
index 561c1027..eb7a242a 100644
--- a/src/data/storage/chat/chat_storage.ts
+++ b/src/data/storage/chat/chat_storage.ts
@@ -22,7 +22,7 @@ import { GuestChatQuota as GuestChatQuotaDto } from "@/data/dto/chat/guest_chat_
import { z } from "zod";
import { type Result as ResultT, Result } from "@/utils/result";
-import { formatDate } from "@/utils/date";
+import { todayString } from "@/utils/date";
import { SpAsyncUtil } from "@/utils/storage";
import { StorageKeys } from "../storage_keys";
import type { IChatStorage } from "./ichat_storage";
@@ -51,7 +51,7 @@ export class ChatStorage implements IChatStorage {
if (existing.success && existing.data != null) {
return existing;
}
- const today = formatDate(new Date());
+ const today = todayString(new Date());
const initResult = await this.setGuestDailyChatQuota(
GuestChatQuotaDto.threshold,
today,
diff --git a/src/models/user/user.ts b/src/models/user/user.ts
index 8c2c02ed..87585fb5 100644
--- a/src/models/user/user.ts
+++ b/src/models/user/user.ts
@@ -15,6 +15,7 @@ export const UserViewSchema = z.object({
relationshipStage: z.string(),
currentMood: z.string(),
isGuest: z.boolean(),
+ isVip: z.boolean(),
});
export type UserView = z.infer;
diff --git a/src/stores/user/user-machine.ts b/src/stores/user/user-machine.ts
index e403eb78..cc72a661 100644
--- a/src/stores/user/user-machine.ts
+++ b/src/stores/user/user-machine.ts
@@ -49,6 +49,7 @@ function toView(u: {
relationshipStage?: string;
currentMood?: string;
isGuest?: boolean;
+ isVip?: boolean;
}): UserView {
return {
id: u.id,
@@ -60,6 +61,7 @@ function toView(u: {
relationshipStage: u.relationshipStage ?? "",
currentMood: u.currentMood ?? "",
isGuest: u.isGuest ?? false,
+ isVip: u.isVip ?? false,
};
}