306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
/**
|
||
* 品牌全局状态 Store (Zustand)
|
||
*
|
||
* 与 SWR 互补:
|
||
* - SWR 管理服务端缓存(品牌列表数据获取、缓存、重验证)
|
||
* - 此 Store 管理客户端侧状态:
|
||
* 1. 当前选中品牌(跨页面共享)
|
||
* 2. 乐观更新(操作先更新本地,API 失败再回滚)
|
||
* 3. 品牌列表的本地增删(配合 SWR mutate)
|
||
*/
|
||
|
||
import { create } from "zustand";
|
||
import { fetchWithAuth } from "@/lib/api/client";
|
||
import { brandsApi } from "@/lib/api/brands";
|
||
import type {
|
||
BrandListItem,
|
||
BrandListResponse,
|
||
} from "@/types/brand";
|
||
import type { CreateBrandPayload, UpdateBrandPayload } from "@/lib/api/brands";
|
||
import { useNotificationStore } from "./notification-store";
|
||
|
||
// ── 类型定义 ────────────────────────────────────────────────────────────────────
|
||
|
||
export interface BrandState {
|
||
/** 当前选中品牌 ID */
|
||
selectedBrandId: string | null;
|
||
/** 当前选中品牌名称(方便 UI 直接显示) */
|
||
selectedBrandName: string | null;
|
||
/** 品牌列表本地副本(用于乐观更新时的临时状态) */
|
||
localBrands: BrandListItem[];
|
||
/** 正在进行乐观更新的操作类型 */
|
||
optimisticAction: "creating" | "updating" | "deleting" | null;
|
||
}
|
||
|
||
export interface BrandActions {
|
||
/** 选中品牌 */
|
||
selectBrand: (brandId: string, brandName?: string) => void;
|
||
/** 清除选中品牌 */
|
||
clearSelection: () => void;
|
||
/** 从 SWR 数据同步品牌列表到本地副本 */
|
||
syncFromSWR: (brands: BrandListItem[]) => void;
|
||
|
||
// ── 乐观更新操作 ──────────────────────────────────────────────────────────
|
||
/**
|
||
* 乐观创建品牌:
|
||
* 1. 先在本地列表添加临时条目
|
||
* 2. 异步调用 API
|
||
* 3. 成功 → 用真实数据替换临时条目(配合 SWR mutate)
|
||
* 4. 失败 → 回滚本地列表,显示错误通知
|
||
*/
|
||
optimisticCreate: (
|
||
token: string,
|
||
payload: CreateBrandPayload,
|
||
/** SWR mutate 回调,用于刷新服务端缓存 */
|
||
swrMutate?: () => void,
|
||
) => Promise<BrandListItem | null>;
|
||
|
||
/**
|
||
* 乐观更新品牌:
|
||
* 1. 先在本地列表更新条目
|
||
* 2. 异步调用 API
|
||
* 3. 成功 → 配合 SWR mutate
|
||
* 4. 失败 → 回滚本地列表
|
||
*/
|
||
optimisticUpdate: (
|
||
token: string,
|
||
brandId: string,
|
||
payload: UpdateBrandPayload,
|
||
swrMutate?: () => void,
|
||
) => Promise<BrandListItem | null>;
|
||
|
||
/**
|
||
* 乐观删除品牌:
|
||
* 1. 先从本地列表移除条目
|
||
* 2. 异步调用 API
|
||
* 3. 成功 → 配合 SWR mutate
|
||
* 4. 失败 → 恢复本地列表条目
|
||
*/
|
||
optimisticDelete: (
|
||
token: string,
|
||
brandId: string,
|
||
swrMutate?: () => void,
|
||
) => Promise<boolean>;
|
||
}
|
||
|
||
// ── 辅助 ────────────────────────────────────────────────────────────────────────
|
||
|
||
/** 生成临时 ID(乐观创建用) */
|
||
function generateTempId(): string {
|
||
return `temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
// ── 默认值 ──────────────────────────────────────────────────────────────────────
|
||
|
||
const INITIAL_STATE: BrandState = {
|
||
selectedBrandId: null,
|
||
selectedBrandName: null,
|
||
localBrands: [],
|
||
optimisticAction: null,
|
||
};
|
||
|
||
// ── Store ───────────────────────────────────────────────────────────────────────
|
||
|
||
export const useBrandStore = create<BrandState & BrandActions>()((set, get) => ({
|
||
...INITIAL_STATE,
|
||
|
||
selectBrand: (brandId, brandName) => {
|
||
set({
|
||
selectedBrandId: brandId,
|
||
selectedBrandName: brandName ?? null,
|
||
});
|
||
},
|
||
|
||
clearSelection: () => {
|
||
set({ selectedBrandId: null, selectedBrandName: null });
|
||
},
|
||
|
||
syncFromSWR: (brands) => {
|
||
set({ localBrands: brands });
|
||
},
|
||
|
||
// ── 乐观创建 ────────────────────────────────────────────────────────────
|
||
optimisticCreate: async (token, payload, swrMutate) => {
|
||
const { localBrands } = get();
|
||
const notificationStore = useNotificationStore.getState();
|
||
|
||
// 1. 创建临时条目
|
||
const tempBrand: BrandListItem = {
|
||
id: generateTempId(),
|
||
name: payload.name,
|
||
aliases: payload.aliases ?? [],
|
||
platforms: payload.platforms ?? [],
|
||
frequency: (payload.frequency ?? "weekly") as BrandListItem["frequency"],
|
||
status: "pending",
|
||
score: null,
|
||
last_queried_at: null,
|
||
next_query_at: null,
|
||
created_at: new Date().toISOString(),
|
||
};
|
||
|
||
set({
|
||
localBrands: [...localBrands, tempBrand],
|
||
optimisticAction: "creating",
|
||
});
|
||
|
||
// 2. 异步调用 API
|
||
try {
|
||
const created = await brandsApi.create(token, payload as CreateBrandPayload) as BrandListItem;
|
||
|
||
// 3. 成功 → 用真实数据替换临时条目
|
||
set((state) => ({
|
||
localBrands: state.localBrands.map((b) =>
|
||
b.id === tempBrand.id ? created : b
|
||
),
|
||
optimisticAction: null,
|
||
}));
|
||
|
||
notificationStore.addNotification({
|
||
type: "success",
|
||
message: `品牌「${created.name}」创建成功`,
|
||
});
|
||
|
||
// 触发 SWR 重新获取,保证服务端缓存同步
|
||
swrMutate?.();
|
||
|
||
return created;
|
||
} catch (err) {
|
||
// 4. 失败 → 回滚
|
||
set((state) => ({
|
||
localBrands: state.localBrands.filter((b) => b.id !== tempBrand.id),
|
||
optimisticAction: null,
|
||
}));
|
||
|
||
const message = err instanceof Error ? err.message : "创建品牌失败";
|
||
notificationStore.addNotification({ type: "error", message });
|
||
|
||
return null;
|
||
}
|
||
},
|
||
|
||
// ── 乐观更新 ────────────────────────────────────────────────────────────
|
||
optimisticUpdate: async (token, brandId, payload, swrMutate) => {
|
||
const { localBrands } = get();
|
||
const notificationStore = useNotificationStore.getState();
|
||
|
||
// 1. 保存原始数据用于回滚
|
||
const originalBrand = localBrands.find((b) => b.id === brandId);
|
||
if (!originalBrand) {
|
||
// 本地没有此品牌,无法乐观更新,直接走 API
|
||
try {
|
||
const updated = await brandsApi.update(token, brandId, payload as UpdateBrandPayload) as BrandListItem;
|
||
swrMutate?.();
|
||
return updated;
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : "更新品牌失败";
|
||
notificationStore.addNotification({ type: "error", message });
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 2. 乐观更新本地列表
|
||
const optimisticBrand = {
|
||
...originalBrand,
|
||
...payload,
|
||
id: brandId, // 确保 ID 不被覆盖
|
||
} as BrandListItem;
|
||
|
||
set({
|
||
localBrands: localBrands.map((b) =>
|
||
b.id === brandId ? optimisticBrand : b
|
||
),
|
||
optimisticAction: "updating",
|
||
});
|
||
|
||
// 3. 异步调用 API
|
||
try {
|
||
const updated = await brandsApi.update(token, brandId, payload as UpdateBrandPayload) as BrandListItem;
|
||
|
||
set({
|
||
localBrands: get().localBrands.map((b) =>
|
||
b.id === brandId ? updated : b
|
||
),
|
||
optimisticAction: null,
|
||
});
|
||
|
||
notificationStore.addNotification({
|
||
type: "success",
|
||
message: `品牌「${updated.name}」更新成功`,
|
||
});
|
||
|
||
swrMutate?.();
|
||
|
||
return updated;
|
||
} catch (err) {
|
||
// 4. 失败 → 回滚到原始数据
|
||
set((state) => ({
|
||
localBrands: state.localBrands.map((b) =>
|
||
b.id === brandId ? originalBrand : b
|
||
),
|
||
optimisticAction: null,
|
||
}));
|
||
|
||
const message = err instanceof Error ? err.message : "更新品牌失败";
|
||
notificationStore.addNotification({ type: "error", message });
|
||
|
||
return null;
|
||
}
|
||
},
|
||
|
||
// ── 乐观删除 ────────────────────────────────────────────────────────────
|
||
optimisticDelete: async (token, brandId, swrMutate) => {
|
||
const { localBrands, selectedBrandId } = get();
|
||
const notificationStore = useNotificationStore.getState();
|
||
|
||
// 1. 保存原始数据用于回滚
|
||
const originalBrand = localBrands.find((b) => b.id === brandId);
|
||
if (!originalBrand) {
|
||
// 本地没有此品牌,直接走 API
|
||
try {
|
||
await brandsApi.delete(token, brandId);
|
||
swrMutate?.();
|
||
return true;
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : "删除品牌失败";
|
||
notificationStore.addNotification({ type: "error", message });
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 2. 乐观删除:从本地列表移除
|
||
set({
|
||
localBrands: localBrands.filter((b) => b.id !== brandId),
|
||
optimisticAction: "deleting",
|
||
// 如果删除的是当前选中品牌,清除选择
|
||
selectedBrandId: selectedBrandId === brandId ? null : selectedBrandId,
|
||
selectedBrandName: selectedBrandId === brandId ? null : get().selectedBrandName,
|
||
});
|
||
|
||
// 3. 异步调用 API
|
||
try {
|
||
await brandsApi.delete(token, brandId);
|
||
|
||
set({ optimisticAction: null });
|
||
|
||
notificationStore.addNotification({
|
||
type: "success",
|
||
message: `品牌「${originalBrand.name}」已删除`,
|
||
});
|
||
|
||
swrMutate?.();
|
||
|
||
return true;
|
||
} catch (err) {
|
||
// 4. 失败 → 恢复原始条目
|
||
set((state) => ({
|
||
localBrands: [...state.localBrands, originalBrand],
|
||
optimisticAction: null,
|
||
}));
|
||
|
||
const message = err instanceof Error ? err.message : "删除品牌失败";
|
||
notificationStore.addNotification({ type: "error", message });
|
||
|
||
return false;
|
||
}
|
||
},
|
||
})); |