feat(assets): add chat typing indicator lottie animation
This commit is contained in:
+6
-1
@@ -5,6 +5,11 @@
|
|||||||
"./src/data/models/chat",
|
"./src/data/models/chat",
|
||||||
"./src/data/models/metrics",
|
"./src/data/models/metrics",
|
||||||
"./src/data/models/user",
|
"./src/data/models/user",
|
||||||
"./src/data/api"
|
"./src/data/api",
|
||||||
|
"./src/data/storage",
|
||||||
|
"./src/data/storage/auth",
|
||||||
|
"./src/data/storage/user",
|
||||||
|
"./src/data/storage/chat",
|
||||||
|
"./src/data/storage/app"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -8,10 +8,11 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"lint:fix": "eslint --fix",
|
"lint:fix": "eslint --fix",
|
||||||
"generate-barrels": "barrelsby --delete"
|
"generate-barrels": "barrelsby --delete -c barrelsby.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||||
|
"dexie": "^4.4.3",
|
||||||
"next": "16.2.7",
|
"next": "16.2.7",
|
||||||
"ofetch": "^1.5.1",
|
"ofetch": "^1.5.1",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
|
|||||||
Generated
+8
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@fingerprintjs/fingerprintjs':
|
'@fingerprintjs/fingerprintjs':
|
||||||
specifier: ^5.2.0
|
specifier: ^5.2.0
|
||||||
version: 5.2.0
|
version: 5.2.0
|
||||||
|
dexie:
|
||||||
|
specifier: ^4.4.3
|
||||||
|
version: 4.4.3
|
||||||
next:
|
next:
|
||||||
specifier: 16.2.7
|
specifier: 16.2.7
|
||||||
version: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 16.2.7(@babel/core@7.29.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -979,6 +982,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
dexie@4.4.3:
|
||||||
|
resolution: {integrity: sha512-N+3IGQ3HPlyO2YAkntGAwitm42BpBGV86MttzUMiRzWLa4NGh0pltVRcUVF4ybL/OnXjCrr9k7SDPIKkFYP2Lg==}
|
||||||
|
|
||||||
doctrine@2.1.0:
|
doctrine@2.1.0:
|
||||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3008,6 +3014,8 @@ snapshots:
|
|||||||
|
|
||||||
detect-libc@2.1.2: {}
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
|
dexie@4.4.3: {}
|
||||||
|
|
||||||
doctrine@2.1.0:
|
doctrine@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
esutils: 2.0.3
|
esutils: 2.0.3
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AppStorage 完整实现(静态类风格 / localStorage)
|
||||||
|
*
|
||||||
|
* 对齐 Dart 端 `AppStorage`(lib/data/services/storage/app_storage.dart):
|
||||||
|
* - PWA 对话框每日展示 / PWA 事件每日上报 / app-info 每日上报
|
||||||
|
* - 内部用 `yyyy-MM-dd` 字符串比对实现"每日一次"语义
|
||||||
|
*
|
||||||
|
* 注意:Dart 端是 `static class` 没有接口,本 TS 版本同样保持静态方法风格。
|
||||||
|
* 通过 `LocalStorage` 单例 + 懒加载的私有 getter 访问底层存储。
|
||||||
|
*
|
||||||
|
* 语义:
|
||||||
|
* - `canXxx(today)` 返回 `true` 当且仅当"自上次记录以来日期变了"
|
||||||
|
* (即 stored !== today)。首次调用(key 不存在)也返回 `true`。
|
||||||
|
* - `recordXxx(today)` 写入 todayString,后续 `canXxx` 在同一天返回 `false`。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { LocalStorage } from "../local_storage";
|
||||||
|
import { Result, type Result as ResultT } from "../result";
|
||||||
|
import { StorageKeys } from "../storage_keys";
|
||||||
|
|
||||||
|
export class AppStorage {
|
||||||
|
private static _ls: LocalStorage | null = null;
|
||||||
|
|
||||||
|
/** 懒加载 LocalStorage 单例,避免模块加载期触发 SSR 副作用。 */
|
||||||
|
private static get ls(): LocalStorage {
|
||||||
|
if (!AppStorage._ls) AppStorage._ls = LocalStorage.getInstance();
|
||||||
|
return AppStorage._ls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
static _resetForTests(): void {
|
||||||
|
AppStorage._ls = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PWA install dialog ----
|
||||||
|
|
||||||
|
static async canShowPwaDialog(todayString: string): Promise<ResultT<boolean>> {
|
||||||
|
return AppStorage.canReportKey(StorageKeys.lastPwaDialogShown, todayString);
|
||||||
|
}
|
||||||
|
|
||||||
|
static recordPwaDialogShown(todayString: string): Promise<ResultT<void>> {
|
||||||
|
return AppStorage.ls.setString(StorageKeys.lastPwaDialogShown, todayString);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PWA event reporting ----
|
||||||
|
|
||||||
|
static canReportPwaEvent(todayString: string): Promise<ResultT<boolean>> {
|
||||||
|
return AppStorage.canReportKey(StorageKeys.lastPwaEventReported, todayString);
|
||||||
|
}
|
||||||
|
|
||||||
|
static recordPwaEventReported(todayString: string): Promise<ResultT<void>> {
|
||||||
|
return AppStorage.ls.setString(StorageKeys.lastPwaEventReported, todayString);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- app info reporting ----
|
||||||
|
|
||||||
|
static canReportAppInfo(todayString: string): Promise<ResultT<boolean>> {
|
||||||
|
return AppStorage.canReportKey(StorageKeys.lastAppInfoReported, todayString);
|
||||||
|
}
|
||||||
|
|
||||||
|
static recordAppInfoReported(todayString: string): Promise<ResultT<void>> {
|
||||||
|
return AppStorage.ls.setString(StorageKeys.lastAppInfoReported, todayString);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- internal helpers ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用"每日一次"判断:
|
||||||
|
* - 取已记录的日期字符串
|
||||||
|
* - 缺失 → 允许(true)
|
||||||
|
* - 等于 today → 拒绝(false)
|
||||||
|
* - 不等于 today → 允许(true)
|
||||||
|
*/
|
||||||
|
private static async canReportKey(
|
||||||
|
key: string,
|
||||||
|
todayString: string,
|
||||||
|
): Promise<ResultT<boolean>> {
|
||||||
|
const r = await AppStorage.ls.getString(key);
|
||||||
|
if (r.kind === "failure") return r;
|
||||||
|
return Result.success(r.value === null || r.value !== todayString);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* @file Automatically generated by barrelsby.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./app_storage";
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AuthStorage 完整实现(示范 1:KV / localStorage)
|
||||||
|
*
|
||||||
|
* SECURITY: 当前与 Dart 端行为一致,refresh_token 以明文存于 localStorage。
|
||||||
|
* 后续轮次将升级到 HttpOnly Cookie 方案,参见 `clearAuthData` 处的 TODO 标记。
|
||||||
|
*
|
||||||
|
* 设计说明:
|
||||||
|
* - 组合而非继承 `LocalStorage`(避免多重继承层级)
|
||||||
|
* - 单例挂在 class 静态字段,规避 HMR 复用污染
|
||||||
|
* - 构造函数接受可选 `LocalStorage` 注入,便于测试时使用 jsdom 的 storage
|
||||||
|
* - `hasXxx` = `token != null && token.length > 0`,对齐 Dart 端 `isNotEmpty` 语义
|
||||||
|
* - `clearAuthData` 顺序清理:loginToken → guestToken → refreshToken,任一失败立即返回
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { LocalStorage } from "../local_storage";
|
||||||
|
import { Result, type Result as ResultT } from "../result";
|
||||||
|
import { StorageKeys } from "../storage_keys";
|
||||||
|
import type { IAuthStorage } from "./iauth_storage";
|
||||||
|
|
||||||
|
export class AuthStorage implements IAuthStorage {
|
||||||
|
private readonly ls: LocalStorage;
|
||||||
|
private static _instance: AuthStorage | null = null;
|
||||||
|
|
||||||
|
constructor(localStorage?: LocalStorage) {
|
||||||
|
this.ls = localStorage ?? LocalStorage.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): AuthStorage {
|
||||||
|
if (!AuthStorage._instance) AuthStorage._instance = new AuthStorage();
|
||||||
|
return AuthStorage._instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
static _resetForTests(): void {
|
||||||
|
AuthStorage._instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- login token ----
|
||||||
|
|
||||||
|
getLoginToken(): Promise<ResultT<string | null>> {
|
||||||
|
return this.ls.getString(StorageKeys.loginToken);
|
||||||
|
}
|
||||||
|
setLoginToken(token: string): Promise<ResultT<void>> {
|
||||||
|
return this.ls.setString(StorageKeys.loginToken, token);
|
||||||
|
}
|
||||||
|
async hasLoginToken(): Promise<ResultT<boolean>> {
|
||||||
|
const r = await this.getLoginToken();
|
||||||
|
if (r.kind === "failure") return Result.failure(r.error);
|
||||||
|
return Result.success(r.value !== null && r.value.length > 0);
|
||||||
|
}
|
||||||
|
clearLoginToken(): Promise<ResultT<void>> {
|
||||||
|
return this.ls.remove(StorageKeys.loginToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- guest token ----
|
||||||
|
|
||||||
|
getGuestToken(): Promise<ResultT<string | null>> {
|
||||||
|
return this.ls.getString(StorageKeys.guestToken);
|
||||||
|
}
|
||||||
|
setGuestToken(token: string): Promise<ResultT<void>> {
|
||||||
|
return this.ls.setString(StorageKeys.guestToken, token);
|
||||||
|
}
|
||||||
|
async hasGuestToken(): Promise<ResultT<boolean>> {
|
||||||
|
const r = await this.getGuestToken();
|
||||||
|
if (r.kind === "failure") return Result.failure(r.error);
|
||||||
|
return Result.success(r.value !== null && r.value.length > 0);
|
||||||
|
}
|
||||||
|
clearGuestToken(): Promise<ResultT<void>> {
|
||||||
|
return this.ls.remove(StorageKeys.guestToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- device id ----
|
||||||
|
|
||||||
|
getDeviceId(): Promise<ResultT<string | null>> {
|
||||||
|
return this.ls.getString(StorageKeys.deviceId);
|
||||||
|
}
|
||||||
|
setDeviceId(deviceId: string): Promise<ResultT<void>> {
|
||||||
|
return this.ls.setString(StorageKeys.deviceId, deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- refresh token ----
|
||||||
|
// TODO(security): 升级到 HttpOnly Cookie 方案
|
||||||
|
// SECURITY: 明文存储,仅供本轮与 Dart 行为对齐
|
||||||
|
|
||||||
|
getRefreshToken(): Promise<ResultT<string | null>> {
|
||||||
|
return this.ls.getString(StorageKeys.refreshToken);
|
||||||
|
}
|
||||||
|
setRefreshToken(token: string): Promise<ResultT<void>> {
|
||||||
|
return this.ls.setString(StorageKeys.refreshToken, token);
|
||||||
|
}
|
||||||
|
clearRefreshToken(): Promise<ResultT<void>> {
|
||||||
|
return this.ls.remove(StorageKeys.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- facebook id ----
|
||||||
|
|
||||||
|
getFacebookId(): Promise<ResultT<string | null>> {
|
||||||
|
return this.ls.getString(StorageKeys.facebookId);
|
||||||
|
}
|
||||||
|
setFacebookId(id: string): Promise<ResultT<void>> {
|
||||||
|
return this.ls.setString(StorageKeys.facebookId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- bulk clear ----
|
||||||
|
|
||||||
|
async clearAuthData(): Promise<ResultT<void>> {
|
||||||
|
const r1 = await this.clearLoginToken();
|
||||||
|
if (r1.kind === "failure") return r1;
|
||||||
|
const r2 = await this.clearGuestToken();
|
||||||
|
if (r2.kind === "failure") return r2;
|
||||||
|
return this.clearRefreshToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IAuthStorage 接口
|
||||||
|
*
|
||||||
|
* 对齐 Dart 端 `IAuthStorage`(lib/data/services/storage/iauth_storage.dart):
|
||||||
|
* - loginToken / guestToken / deviceId / refreshToken / facebookId 五个字段的 CRUD
|
||||||
|
* - `clearAuthData()` 批量清空登录态
|
||||||
|
* - 所有方法返回 `Promise<Result<T>>`,与 Dart `Future<Result<T>>` 对齐
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Result } from "../result";
|
||||||
|
|
||||||
|
export interface IAuthStorage {
|
||||||
|
getLoginToken(): Promise<Result<string | null>>;
|
||||||
|
setLoginToken(token: string): Promise<Result<void>>;
|
||||||
|
hasLoginToken(): Promise<Result<boolean>>;
|
||||||
|
clearLoginToken(): Promise<Result<void>>;
|
||||||
|
|
||||||
|
getGuestToken(): Promise<Result<string | null>>;
|
||||||
|
setGuestToken(token: string): Promise<Result<void>>;
|
||||||
|
hasGuestToken(): Promise<Result<boolean>>;
|
||||||
|
clearGuestToken(): Promise<Result<void>>;
|
||||||
|
|
||||||
|
getDeviceId(): Promise<Result<string | null>>;
|
||||||
|
setDeviceId(deviceId: string): Promise<Result<void>>;
|
||||||
|
|
||||||
|
getRefreshToken(): Promise<Result<string | null>>;
|
||||||
|
setRefreshToken(token: string): Promise<Result<void>>;
|
||||||
|
clearRefreshToken(): Promise<Result<void>>;
|
||||||
|
|
||||||
|
getFacebookId(): Promise<Result<string | null>>;
|
||||||
|
setFacebookId(id: string): Promise<Result<void>>;
|
||||||
|
|
||||||
|
clearAuthData(): Promise<Result<void>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Automatically generated by barrelsby.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./auth_storage";
|
||||||
|
export * from "./iauth_storage";
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChatStorage 完整实现(KV / localStorage)
|
||||||
|
*
|
||||||
|
* 对齐 Dart 端 `ChatStorage`(lib/data/services/storage/chat/chat_storage.dart):
|
||||||
|
* - 游客每日聊天配额(GuestChatQuota:{ remaining, date })
|
||||||
|
* - 游客总配额(int)
|
||||||
|
*
|
||||||
|
* 注意:本类与 `LocalChatStorage`(IndexedDB / Dexie)是不同类:
|
||||||
|
* - ChatStorage = 游客配额等元数据(KV)
|
||||||
|
* - LocalChatStorage = 聊天记录本身(IndexedDB)
|
||||||
|
*
|
||||||
|
* 设计沿用 AuthStorage 模式:组合 `LocalStorage` + 单例 + Zod 校验。
|
||||||
|
* 跨天重置判断(`needsReset`)保留在 `GuestChatQuota` 类上,存储层只做读写。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
GuestChatQuotaSchema,
|
||||||
|
type GuestChatQuotaData,
|
||||||
|
} from "@/data/models";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { LocalStorage } from "../local_storage";
|
||||||
|
import { type Result as ResultT } from "../result";
|
||||||
|
import { StorageKeys } from "../storage_keys";
|
||||||
|
import type { IChatStorage } from "./ichat_storage";
|
||||||
|
|
||||||
|
export class ChatStorage implements IChatStorage {
|
||||||
|
private readonly ls: LocalStorage;
|
||||||
|
private static _instance: ChatStorage | null = null;
|
||||||
|
|
||||||
|
constructor(localStorage?: LocalStorage) {
|
||||||
|
this.ls = localStorage ?? LocalStorage.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): ChatStorage {
|
||||||
|
if (!ChatStorage._instance) ChatStorage._instance = new ChatStorage();
|
||||||
|
return ChatStorage._instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
static _resetForTests(): void {
|
||||||
|
ChatStorage._instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- guest daily chat quota ----
|
||||||
|
|
||||||
|
getGuestDailyChatQuota(): Promise<ResultT<GuestChatQuotaData | null>> {
|
||||||
|
return this.ls.getJson(StorageKeys.guestChatQuota, GuestChatQuotaSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
setGuestDailyChatQuota(remaining: number, date: string): Promise<ResultT<void>> {
|
||||||
|
const value: GuestChatQuotaData = { remaining, date };
|
||||||
|
return this.ls.setJson(StorageKeys.guestChatQuota, value, GuestChatQuotaSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearGuestDailyChatQuota(): Promise<ResultT<void>> {
|
||||||
|
return this.ls.remove(StorageKeys.guestChatQuota);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- guest total quota ----
|
||||||
|
|
||||||
|
getGuestTotalQuota(): Promise<ResultT<number | null>> {
|
||||||
|
return this.ls.getJson(StorageKeys.guestTotalQuotaRemaining, z.number());
|
||||||
|
}
|
||||||
|
|
||||||
|
setGuestTotalQuota(remaining: number): Promise<ResultT<void>> {
|
||||||
|
return this.ls.setJson(StorageKeys.guestTotalQuotaRemaining, remaining, z.number());
|
||||||
|
}
|
||||||
|
|
||||||
|
clearGuestTotalQuota(): Promise<ResultT<void>> {
|
||||||
|
return this.ls.remove(StorageKeys.guestTotalQuotaRemaining);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IChatStorage 接口
|
||||||
|
*
|
||||||
|
* 对齐 Dart 端 `IChatStorage`(lib/data/services/storage/chat/ichat_storage.dart):
|
||||||
|
* - 游客每日聊天配额 + 游客总配额 两个独立字段的 CRUD
|
||||||
|
*
|
||||||
|
* 注:`GuestChatQuota` 模型见 `src/data/models/chat/guest_chat_quota.ts`,
|
||||||
|
* 数据形态 `{ remaining: number, date: string }`。
|
||||||
|
* 跨天重置逻辑(`needsReset`)保留在 `GuestChatQuota` 类上,存储层只做读写。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Result } from "../result";
|
||||||
|
import type { GuestChatQuotaData } from "@/data/models";
|
||||||
|
|
||||||
|
export interface IChatStorage {
|
||||||
|
getGuestDailyChatQuota(): Promise<Result<GuestChatQuotaData | null>>;
|
||||||
|
setGuestDailyChatQuota(
|
||||||
|
remaining: number,
|
||||||
|
date: string,
|
||||||
|
): Promise<Result<void>>;
|
||||||
|
clearGuestDailyChatQuota(): Promise<Result<void>>;
|
||||||
|
|
||||||
|
getGuestTotalQuota(): Promise<Result<number | null>>;
|
||||||
|
setGuestTotalQuota(remaining: number): Promise<Result<void>>;
|
||||||
|
clearGuestTotalQuota(): Promise<Result<void>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* @file Automatically generated by barrelsby.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./chat_storage";
|
||||||
|
export * from "./ichat_storage";
|
||||||
|
export * from "./local_chat_db";
|
||||||
|
export * from "./local_chat_storage";
|
||||||
|
export * from "./local_message";
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dexie 数据库定义
|
||||||
|
*
|
||||||
|
* 唯一表 `messages`,只有 `++dbId` 自增主键,无次级索引(按本轮需求"最简表")。
|
||||||
|
* 未来要按 session 查询可升级到:
|
||||||
|
* this.version(2).stores({ messages: "++dbId, sessionId, createdAt" })
|
||||||
|
* .upgrade(async (tx) => { ... });
|
||||||
|
*
|
||||||
|
* 构造时 `dbName` 可注入,便于测试时每个用例用独立 DB 互不污染。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Dexie, { type Table } from "dexie";
|
||||||
|
|
||||||
|
export interface LocalMessageRow {
|
||||||
|
/** Dexie 自增主键(数据库内部使用,不暴露给 LocalMessage 类)。 */
|
||||||
|
dbId?: number;
|
||||||
|
/** 消息 id(来自 ChatMessage.id)。 */
|
||||||
|
id: string;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LocalChatDB extends Dexie {
|
||||||
|
messages!: Table<LocalMessageRow, number>;
|
||||||
|
|
||||||
|
constructor(dbName: string = "cozsweet-chat") {
|
||||||
|
super(dbName);
|
||||||
|
this.version(1).stores({
|
||||||
|
messages: "++dbId",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LocalChatStorage 完整实现(示范 2:IndexedDB via Dexie)
|
||||||
|
*
|
||||||
|
* 对齐 Dart 端 `LocalChatStorage`(lib/data/services/storage/chat/local_chat_storage.dart):
|
||||||
|
* - init / saveMessage / saveMessages / getAllMessages / clearAll / deleteMessage / close
|
||||||
|
* - 额外提供 `getMessageCount`(异步)替代 Dart 的同步 `messageCount` getter
|
||||||
|
* - 额外提供 `getAllMessagesBySession(sessionId)`(filter 模拟,等下一轮加索引后再用 where)
|
||||||
|
*
|
||||||
|
* 单例挂在 class 静态字段。
|
||||||
|
* Dexie 实例可注入,便于测试用 fake-indexeddb 跑真 Dexie。
|
||||||
|
*
|
||||||
|
* 注意:当前表无次级索引,所有过滤(sessionId)都是 `toArray().then(filter)`,O(n)。
|
||||||
|
* 数据量大时考虑升级 schema 加索引。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Result, type Result as ResultT } from "../result";
|
||||||
|
import { LocalChatDB } from "./local_chat_db";
|
||||||
|
import { LocalMessage } from "./local_message";
|
||||||
|
|
||||||
|
export class LocalChatStorage {
|
||||||
|
private readonly db: LocalChatDB;
|
||||||
|
private static _instance: LocalChatStorage | null = null;
|
||||||
|
|
||||||
|
constructor(db?: LocalChatDB) {
|
||||||
|
this.db = db ?? new LocalChatDB();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): LocalChatStorage {
|
||||||
|
if (!LocalChatStorage._instance) {
|
||||||
|
LocalChatStorage._instance = new LocalChatStorage();
|
||||||
|
}
|
||||||
|
return LocalChatStorage._instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
static _resetForTests(): void {
|
||||||
|
LocalChatStorage._instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 触发 Dexie 打开数据库。
|
||||||
|
* 幂等:Dexie.open() 多次调用无副作用。
|
||||||
|
* 在 bootstrap 中 await 此方法可保证后续操作不会因连接未开而延迟。
|
||||||
|
*/
|
||||||
|
async init(): Promise<ResultT<void>> {
|
||||||
|
try {
|
||||||
|
await this.db.open();
|
||||||
|
return Result.success(undefined);
|
||||||
|
} catch (e) {
|
||||||
|
return Result.failure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- create / append ----
|
||||||
|
|
||||||
|
async saveMessage(message: LocalMessage): Promise<ResultT<void>> {
|
||||||
|
try {
|
||||||
|
await this.db.messages.add(message.toRow());
|
||||||
|
return Result.success(undefined);
|
||||||
|
} catch (e) {
|
||||||
|
return Result.failure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveMessages(messages: readonly LocalMessage[]): Promise<ResultT<void>> {
|
||||||
|
try {
|
||||||
|
await this.db.transaction(
|
||||||
|
"rw",
|
||||||
|
this.db.messages,
|
||||||
|
async () => {
|
||||||
|
for (const m of messages) {
|
||||||
|
await this.db.messages.add(m.toRow());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return Result.success(undefined);
|
||||||
|
} catch (e) {
|
||||||
|
return Result.failure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- read ----
|
||||||
|
|
||||||
|
async getAllMessages(): Promise<ResultT<LocalMessage[]>> {
|
||||||
|
try {
|
||||||
|
const rows = await this.db.messages.toArray();
|
||||||
|
// 按 dbId 升序(与 Dart `box.values.toList()` 插入序语义一致)
|
||||||
|
rows.sort((a, b) => (a.dbId ?? 0) - (b.dbId ?? 0));
|
||||||
|
return Result.success(rows.map((r) => LocalMessage.fromRow(r)));
|
||||||
|
} catch (e) {
|
||||||
|
return Result.failure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessageCount(): Promise<ResultT<number>> {
|
||||||
|
try {
|
||||||
|
return Result.success(await this.db.messages.count());
|
||||||
|
} catch (e) {
|
||||||
|
return Result.failure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllMessagesBySession(
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<ResultT<LocalMessage[]>> {
|
||||||
|
try {
|
||||||
|
const rows = await this.db.messages
|
||||||
|
.filter((r) => r.sessionId === sessionId)
|
||||||
|
.toArray();
|
||||||
|
rows.sort((a, b) => (a.dbId ?? 0) - (b.dbId ?? 0));
|
||||||
|
return Result.success(rows.map((r) => LocalMessage.fromRow(r)));
|
||||||
|
} catch (e) {
|
||||||
|
return Result.failure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- delete ----
|
||||||
|
|
||||||
|
async clearAll(): Promise<ResultT<void>> {
|
||||||
|
try {
|
||||||
|
await this.db.messages.clear();
|
||||||
|
return Result.success(undefined);
|
||||||
|
} catch (e) {
|
||||||
|
return Result.failure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按数组下标删除(保持 Dart 端语义:`box.values.toList()[index].delete()`)。
|
||||||
|
* O(n) 取下标后单条 delete,与 Dart 行为一致。
|
||||||
|
*/
|
||||||
|
async deleteMessage(index: number): Promise<ResultT<void>> {
|
||||||
|
try {
|
||||||
|
if (!Number.isInteger(index) || index < 0) {
|
||||||
|
return Result.failure(
|
||||||
|
new RangeError(`deleteMessage: index ${index} out of range`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rows = await this.db.messages.toArray();
|
||||||
|
if (index >= rows.length) {
|
||||||
|
return Result.failure(
|
||||||
|
new RangeError(`deleteMessage: index ${index} out of range`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const target = rows[index]!;
|
||||||
|
await this.db.messages.delete(target.dbId!);
|
||||||
|
return Result.success(undefined);
|
||||||
|
} catch (e) {
|
||||||
|
return Result.failure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<ResultT<void>> {
|
||||||
|
try {
|
||||||
|
this.db.close();
|
||||||
|
return Result.success(undefined);
|
||||||
|
} catch (e) {
|
||||||
|
return Result.failure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LocalMessage 模型(存储层副本)
|
||||||
|
*
|
||||||
|
* 与 `src/data/models/chat/chat_message.ts` 的 `ChatMessage` 解耦:
|
||||||
|
* 存储层维护自己的数据形态(独立 id/role/content/createdAt + 内部 sessionId 分组),
|
||||||
|
* 不耦合 wire 模型,方便后续 schema 独立演进。
|
||||||
|
*
|
||||||
|
* 对齐项目内其他 Zod 模型的范式:
|
||||||
|
* - `*Schema` 是验证与默认值的单一来源
|
||||||
|
* - `*Input` 是构造输入(字段可缺),`*Data` 是解析后形态
|
||||||
|
* - 不可变(private constructor + Object.freeze)
|
||||||
|
* - 桥接方法 `toRow` / `fromRow` 处理 Dexie 行 ↔ 类实例的转换
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { LocalMessageRow } from "./local_chat_db";
|
||||||
|
|
||||||
|
export const LocalMessageSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
role: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
createdAt: z.string().default(""),
|
||||||
|
sessionId: z.string().default(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LocalMessageInput = z.input<typeof LocalMessageSchema>;
|
||||||
|
export type LocalMessageData = z.output<typeof LocalMessageSchema>;
|
||||||
|
|
||||||
|
export class LocalMessage {
|
||||||
|
declare readonly id: string;
|
||||||
|
declare readonly role: string;
|
||||||
|
declare readonly content: string;
|
||||||
|
declare readonly createdAt: string;
|
||||||
|
declare readonly sessionId: string;
|
||||||
|
|
||||||
|
private constructor(input: LocalMessageInput) {
|
||||||
|
const data = LocalMessageSchema.parse(input);
|
||||||
|
Object.assign(this, data);
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
static from(input: LocalMessageInput): LocalMessage {
|
||||||
|
return new LocalMessage(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJson(json: unknown): LocalMessage {
|
||||||
|
return LocalMessage.from(json as LocalMessageInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJson(): LocalMessageData {
|
||||||
|
return LocalMessageSchema.parse(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 转成 Dexie 行(不含 dbId,让 Dexie 自增)。 */
|
||||||
|
toRow(): Omit<LocalMessageRow, "dbId"> {
|
||||||
|
return {
|
||||||
|
id: this.id,
|
||||||
|
role: this.role,
|
||||||
|
content: this.content,
|
||||||
|
createdAt: this.createdAt,
|
||||||
|
sessionId: this.sessionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 Dexie 行还原(忽略 dbId 内部主键)。 */
|
||||||
|
static fromRow(row: LocalMessageRow): LocalMessage {
|
||||||
|
return LocalMessage.from({
|
||||||
|
id: row.id,
|
||||||
|
role: row.role,
|
||||||
|
content: row.content,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
sessionId: row.sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* @file Automatically generated by barrelsby.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./local_storage";
|
||||||
|
export * from "./result";
|
||||||
|
export * from "./storage_keys";
|
||||||
|
export * from "./app/app_storage";
|
||||||
|
export * from "./auth/auth_storage";
|
||||||
|
export * from "./auth/iauth_storage";
|
||||||
|
export * from "./chat/chat_storage";
|
||||||
|
export * from "./chat/ichat_storage";
|
||||||
|
export * from "./chat/local_chat_db";
|
||||||
|
export * from "./chat/local_chat_storage";
|
||||||
|
export * from "./chat/local_message";
|
||||||
|
export * from "./user/iuser_storage";
|
||||||
|
export * from "./user/user_storage";
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* localStorage 通用包装
|
||||||
|
*
|
||||||
|
* 设计要点:
|
||||||
|
* 1. 全部方法返回 `Promise<Result<T>>`,与 `LocalChatStorage`(Dexie/异步)保持签名一致,
|
||||||
|
* 调用方可以统一 `await` 而不必关心底层是同步还是异步。
|
||||||
|
* 2. 三重 SSR 保护:
|
||||||
|
* - 文件顶 `"use client"` 指令
|
||||||
|
* - 构造时探测 `typeof window`,服务端拿不到 `window.localStorage` 时存 `null`
|
||||||
|
* - 每次调用入口再做可用性检查,避免运行时炸
|
||||||
|
* 3. 测试友好:构造函数接受可选 `Storage` 注入,单例可通过 `_resetForTests` 重置。
|
||||||
|
* 4. `getString` 把空串视作 `null`(对齐 Dart `SpAsyncUtil.getString` 行为)。
|
||||||
|
* 5. `getJson` / `setJson` 在边界做 Zod 校验,读写都走 schema。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type ZodType } from "zod";
|
||||||
|
import { Result, type Result as ResultT } from "./result";
|
||||||
|
|
||||||
|
const SSR_ERROR_MSG =
|
||||||
|
"localStorage is not available in this environment (SSR or non-browser)";
|
||||||
|
|
||||||
|
export class LocalStorage {
|
||||||
|
private readonly storage: Storage | null;
|
||||||
|
private static _instance: LocalStorage | null = null;
|
||||||
|
|
||||||
|
constructor(storage?: Storage | null) {
|
||||||
|
this.storage =
|
||||||
|
storage !== undefined
|
||||||
|
? storage
|
||||||
|
: typeof window !== "undefined"
|
||||||
|
? window.localStorage
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全局单例(仅在浏览器环境有效)。 */
|
||||||
|
static getInstance(): LocalStorage {
|
||||||
|
if (!LocalStorage._instance) {
|
||||||
|
LocalStorage._instance = new LocalStorage();
|
||||||
|
}
|
||||||
|
return LocalStorage._instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal 测试用:重置单例以便每个测试用例拿到独立实例。 */
|
||||||
|
static _resetForTests(): void {
|
||||||
|
LocalStorage._instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAvailable(): boolean {
|
||||||
|
return this.storage !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getString(key: string): Promise<ResultT<string | null>> {
|
||||||
|
if (!this.isAvailable()) return Result.failure(new Error(SSR_ERROR_MSG));
|
||||||
|
try {
|
||||||
|
const v = this.storage!.getItem(key);
|
||||||
|
if (v === null || v === "") return Result.success(null);
|
||||||
|
return Result.success(v);
|
||||||
|
} catch (e) {
|
||||||
|
return Result.failure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setString(key: string, value: string): Promise<ResultT<void>> {
|
||||||
|
if (!this.isAvailable()) return Result.failure(new Error(SSR_ERROR_MSG));
|
||||||
|
try {
|
||||||
|
this.storage!.setItem(key, value);
|
||||||
|
return Result.success(undefined);
|
||||||
|
} catch (e) {
|
||||||
|
return Result.failure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJson<T>(key: string, schema: ZodType<T>): Promise<ResultT<T | null>> {
|
||||||
|
const r = await this.getString(key);
|
||||||
|
if (r.kind === "failure") return Result.failure(r.error);
|
||||||
|
if (r.value === null) {
|
||||||
|
const value: T | null = null;
|
||||||
|
return Result.success(value);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = schema.parse(JSON.parse(r.value));
|
||||||
|
return Result.success(parsed);
|
||||||
|
} catch (e) {
|
||||||
|
return Result.failure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setJson<T>(
|
||||||
|
key: string,
|
||||||
|
value: T,
|
||||||
|
schema?: ZodType<T>,
|
||||||
|
): Promise<ResultT<void>> {
|
||||||
|
if (!this.isAvailable()) return Result.failure(new Error(SSR_ERROR_MSG));
|
||||||
|
try {
|
||||||
|
const validated = schema ? schema.parse(value) : value;
|
||||||
|
this.storage!.setItem(key, JSON.stringify(validated));
|
||||||
|
return Result.success(undefined);
|
||||||
|
} catch (e) {
|
||||||
|
return Result.failure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(key: string): Promise<ResultT<void>> {
|
||||||
|
if (!this.isAvailable()) return Result.failure(new Error(SSR_ERROR_MSG));
|
||||||
|
try {
|
||||||
|
this.storage!.removeItem(key);
|
||||||
|
return Result.success(undefined);
|
||||||
|
} catch (e) {
|
||||||
|
return Result.failure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async has(key: string): Promise<ResultT<boolean>> {
|
||||||
|
if (!this.isAvailable()) return Result.failure(new Error(SSR_ERROR_MSG));
|
||||||
|
try {
|
||||||
|
return Result.success(this.storage!.getItem(key) !== null);
|
||||||
|
} catch (e) {
|
||||||
|
return Result.failure(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 通用 Result<T> 类型
|
||||||
|
*
|
||||||
|
* 对齐 Dart 项目中的 `sealed class Result<T>` / `Success<T>` / `Failure<T>` 模式。
|
||||||
|
* TypeScript 没有 sealed class,用判别联合(discriminated union)实现等价效果:
|
||||||
|
* 调用方在 `switch (result.kind)` 上做穷尽匹配,编译器在 strict 模式下能保证完整性。
|
||||||
|
*
|
||||||
|
* `error` 字段保持 `unknown`(不强制 `Error`)以匹配 Dart 端可抛任意对象的能力。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Result<T> =
|
||||||
|
| { readonly kind: "success"; readonly value: T }
|
||||||
|
| { readonly kind: "failure"; readonly error: unknown };
|
||||||
|
|
||||||
|
export const Result = {
|
||||||
|
success<T>(value: T): Result<T> {
|
||||||
|
return { kind: "success", value };
|
||||||
|
},
|
||||||
|
|
||||||
|
failure<T = never>(error: unknown): Result<T> {
|
||||||
|
return { kind: "failure", error };
|
||||||
|
},
|
||||||
|
|
||||||
|
isSuccess<T>(r: Result<T>): r is { kind: "success"; value: T } {
|
||||||
|
return r.kind === "success";
|
||||||
|
},
|
||||||
|
|
||||||
|
isFailure<T>(r: Result<T>): r is { kind: "failure"; error: unknown } {
|
||||||
|
return r.kind === "failure";
|
||||||
|
},
|
||||||
|
|
||||||
|
map<T, U>(r: Result<T>, fn: (v: T) => U): Result<U> {
|
||||||
|
return r.kind === "success" ? Result.success(fn(r.value)) : r;
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 存储键常量
|
||||||
|
*
|
||||||
|
* 键名沿用 Dart `lib/data/services/storage/storage_keys.dart` 的下划线风格,
|
||||||
|
* 便于跨端调试时直接对位。`as const` 让每个值都是字符串字面量类型,
|
||||||
|
* 未来若需要可用 `z.literal(StorageKeys.loginToken)` 做编译期键名校验。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const StorageKeys = {
|
||||||
|
// auth
|
||||||
|
loginToken: "login_token",
|
||||||
|
guestToken: "guest_token",
|
||||||
|
deviceId: "device_id",
|
||||||
|
refreshToken: "refresh_token",
|
||||||
|
facebookId: "facebook_id",
|
||||||
|
|
||||||
|
// user
|
||||||
|
user: "user",
|
||||||
|
userId: "user_id",
|
||||||
|
userAvatar: "user_avatar",
|
||||||
|
|
||||||
|
// chat
|
||||||
|
chatHistory: "chat_history",
|
||||||
|
guestChatQuota: "guest_chat_quota",
|
||||||
|
guestTotalQuotaRemaining: "guest_total_quota_remaining",
|
||||||
|
|
||||||
|
// pwa / app info
|
||||||
|
lastPwaDialogShown: "last_pwa_dialog_shown",
|
||||||
|
lastPwaEventReported: "last_pwa_event_reported",
|
||||||
|
lastAppInfoReported: "last_app_info_reported",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type StorageKey = (typeof StorageKeys)[keyof typeof StorageKeys];
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @file Automatically generated by barrelsby.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./iuser_storage";
|
||||||
|
export * from "./user_storage";
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IUserStorage 接口
|
||||||
|
*
|
||||||
|
* 对齐 Dart 端 `IUserStorage`(lib/data/services/storage/iuser_storage.dart):
|
||||||
|
* - User 对象 / userId / avatarUrl 三个独立字段的 CRUD
|
||||||
|
* - `clearUserData()` 批量清空
|
||||||
|
*
|
||||||
|
* 注:完整 User 模型见 `src/data/models/user/user.ts`,本接口允许存 JSON 字符串,
|
||||||
|
* 具体序列化由实现层通过 Zod schema 校验后转换为 `User` 类实例。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Result } from "../result";
|
||||||
|
import type { UserData } from "@/data/models";
|
||||||
|
|
||||||
|
export interface IUserStorage {
|
||||||
|
getUser(): Promise<Result<UserData | null>>;
|
||||||
|
setUser(user: UserData): Promise<Result<void>>;
|
||||||
|
clearUser(): Promise<Result<void>>;
|
||||||
|
|
||||||
|
getUserId(): Promise<Result<string | null>>;
|
||||||
|
setUserId(id: string): Promise<Result<void>>;
|
||||||
|
clearUserId(): Promise<Result<void>>;
|
||||||
|
|
||||||
|
getAvatarUrl(): Promise<Result<string | null>>;
|
||||||
|
setAvatarUrl(url: string): Promise<Result<void>>;
|
||||||
|
clearAvatarUrl(): Promise<Result<void>>;
|
||||||
|
|
||||||
|
clearUserData(): Promise<Result<void>>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserStorage 完整实现(KV / localStorage)
|
||||||
|
*
|
||||||
|
* 对齐 Dart 端 `UserStorage`(lib/data/services/storage/user_storage.dart):
|
||||||
|
* - 三个独立字段:User 对象 / userId / avatarUrl
|
||||||
|
* - `clearUserData()` 批量清空
|
||||||
|
*
|
||||||
|
* 设计沿用 AuthStorage 模式:组合 `LocalStorage`(非继承)+ 单例 + Zod 校验。
|
||||||
|
*
|
||||||
|
* 边界:
|
||||||
|
* - `getUser` 走 `UserSchema` 解析:缺字段时 Zod `.default()` 自动兜底
|
||||||
|
* - `setUser` 走 `UserSchema` 校验写入:保证存储的总是完整 UserData
|
||||||
|
* - 单例挂在 class 静态字段,HMR 复用安全
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { UserSchema, type UserData } from "@/data/models";
|
||||||
|
import { LocalStorage } from "../local_storage";
|
||||||
|
import { type Result as ResultT } from "../result";
|
||||||
|
import { StorageKeys } from "../storage_keys";
|
||||||
|
import type { IUserStorage } from "./iuser_storage";
|
||||||
|
|
||||||
|
export class UserStorage implements IUserStorage {
|
||||||
|
private readonly ls: LocalStorage;
|
||||||
|
private static _instance: UserStorage | null = null;
|
||||||
|
|
||||||
|
constructor(localStorage?: LocalStorage) {
|
||||||
|
this.ls = localStorage ?? LocalStorage.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): UserStorage {
|
||||||
|
if (!UserStorage._instance) UserStorage._instance = new UserStorage();
|
||||||
|
return UserStorage._instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
static _resetForTests(): void {
|
||||||
|
UserStorage._instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- user object ----
|
||||||
|
|
||||||
|
getUser(): Promise<ResultT<UserData | null>> {
|
||||||
|
return this.ls.getJson(StorageKeys.user, UserSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(user: UserData): Promise<ResultT<void>> {
|
||||||
|
return this.ls.setJson(StorageKeys.user, user, UserSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearUser(): Promise<ResultT<void>> {
|
||||||
|
return this.ls.remove(StorageKeys.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- userId ----
|
||||||
|
|
||||||
|
getUserId(): Promise<ResultT<string | null>> {
|
||||||
|
return this.ls.getString(StorageKeys.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserId(id: string): Promise<ResultT<void>> {
|
||||||
|
return this.ls.setString(StorageKeys.userId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearUserId(): Promise<ResultT<void>> {
|
||||||
|
return this.ls.remove(StorageKeys.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- avatarUrl ----
|
||||||
|
|
||||||
|
getAvatarUrl(): Promise<ResultT<string | null>> {
|
||||||
|
return this.ls.getString(StorageKeys.userAvatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvatarUrl(url: string): Promise<ResultT<void>> {
|
||||||
|
return this.ls.setString(StorageKeys.userAvatar, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAvatarUrl(): Promise<ResultT<void>> {
|
||||||
|
return this.ls.remove(StorageKeys.userAvatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- bulk clear ----
|
||||||
|
|
||||||
|
async clearUserData(): Promise<ResultT<void>> {
|
||||||
|
const r1 = await this.clearUser();
|
||||||
|
if (r1.kind === "failure") return r1;
|
||||||
|
const r2 = await this.clearUserId();
|
||||||
|
if (r2.kind === "failure") return r2;
|
||||||
|
return this.clearAvatarUrl();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"baseUrl": "src/",
|
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
|
|||||||
Reference in New Issue
Block a user