geo/frontend/lib/stores/brand-store.ts

306 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 品牌全局状态 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;
}
},
}));