feat: 优化web

This commit is contained in:
2026-04-23 18:58:13 +08:00
commit 544a2f3428
160 changed files with 27327 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
'use client';
import { create } from 'zustand';
import { loginWithPassword, logoutRequest, type TokenPair } from '@/lib/api/auth';
import { registerTokenBridge } from '@/lib/api/client';
import { broadcastLogout } from '@/lib/sync/logout-broadcast';
const STORAGE_KEY = 'smart_auth_tokens';
function loadFromSession(): Pick<AuthState, 'accessToken' | 'refreshToken'> {
if (typeof window === 'undefined') {
return { accessToken: null, refreshToken: null };
}
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) {
return { accessToken: null, refreshToken: null };
}
const p = JSON.parse(raw) as { accessToken?: string; refreshToken?: string };
return { accessToken: p.accessToken ?? null, refreshToken: p.refreshToken ?? null };
} catch {
return { accessToken: null, refreshToken: null };
}
}
type AuthState = {
accessToken: string | null;
refreshToken: string | null;
setTokens: (access: string | null, refresh: string | null) => void;
login: (userName: string, password: string, tenantId?: string) => Promise<void>;
logout: () => Promise<void>;
};
export const useAuthStore = create<AuthState>((set) => {
const initial = loadFromSession();
const persist = (accessToken: string | null, refreshToken: string | null) => {
set({ accessToken, refreshToken });
if (typeof window !== 'undefined') {
if (accessToken || refreshToken) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ accessToken, refreshToken }));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
}
};
return {
accessToken: initial.accessToken,
refreshToken: initial.refreshToken,
setTokens: (access, refresh) => {
persist(access, refresh);
},
login: async (userName, password, tenantId) => {
const pair: TokenPair = await loginWithPassword({ userName, password, tenantId });
persist(pair.accessToken, pair.refreshToken);
},
logout: async () => {
try {
await logoutRequest();
} finally {
persist(null, null);
broadcastLogout();
}
},
};
});
registerTokenBridge(
() => ({
accessToken: useAuthStore.getState().accessToken,
refreshToken: useAuthStore.getState().refreshToken,
}),
(access, refresh) => useAuthStore.getState().setTokens(access, refresh)
);
+17
View File
@@ -0,0 +1,17 @@
'use client';
import { create } from 'zustand';
type AuthUiState = {
loginModalOpen: boolean;
loginHint: string | null;
openLoginModal: (hint?: string | null) => void;
closeLoginModal: () => void;
};
export const useAuthUiStore = create<AuthUiState>((set) => ({
loginModalOpen: false,
loginHint: null,
openLoginModal: (hint) => set({ loginModalOpen: true, loginHint: hint ?? null }),
closeLoginModal: () => set({ loginModalOpen: false, loginHint: null }),
}));
+84
View File
@@ -0,0 +1,84 @@
'use client';
import { create } from 'zustand';
export type SidebarMode = 'classic' | 'icon';
const MODE_KEY = 'smart_sidebar_mode';
const RAIL_COLLAPSED_KEY = 'smart_classic_nav_rail_collapsed';
function loadRailCollapsed(): boolean {
if (typeof window === 'undefined') {
return false;
}
try {
return localStorage.getItem(RAIL_COLLAPSED_KEY) === '1';
} catch {
return false;
}
}
function persistRailCollapsed(collapsed: boolean) {
if (typeof window === 'undefined') {
return;
}
try {
localStorage.setItem(RAIL_COLLAPSED_KEY, collapsed ? '1' : '0');
} catch {
/* ignore */
}
}
function loadMode(): SidebarMode {
if (typeof window === 'undefined') {
return 'classic';
}
try {
const v = localStorage.getItem(MODE_KEY);
return v === 'icon' ? 'icon' : 'classic';
} catch {
return 'classic';
}
}
type LayoutStoreState = {
sidebarMode: SidebarMode;
/** 经典布局:窄轨仅一级 + 悬停浮层(true);false 为完整侧栏树 */
classicNavRailCollapsed: boolean;
perms: string[];
setSidebarMode: (mode: SidebarMode) => void;
setClassicNavRailCollapsed: (collapsed: boolean) => void;
toggleClassicNavRail: () => void;
setPerms: (perms: string[]) => void;
};
export const useLayoutStore = create<LayoutStoreState>((set) => ({
sidebarMode: typeof window !== 'undefined' ? loadMode() : 'classic',
classicNavRailCollapsed: typeof window !== 'undefined' ? loadRailCollapsed() : false,
perms: [],
setSidebarMode: (mode) => {
set({ sidebarMode: mode });
if (typeof window !== 'undefined') {
try {
localStorage.setItem(MODE_KEY, mode);
} catch {
/* ignore */
}
}
},
setClassicNavRailCollapsed: (collapsed) => {
persistRailCollapsed(collapsed);
set({ classicNavRailCollapsed: collapsed });
},
toggleClassicNavRail: () =>
set((s) => {
const next = !s.classicNavRailCollapsed;
persistRailCollapsed(next);
return { classicNavRailCollapsed: next };
}),
setPerms: (perms) => set({ perms }),
}));
+119
View File
@@ -0,0 +1,119 @@
'use client';
import { create } from 'zustand';
export type AppTab = {
id: string;
title: string;
path: string;
pinned?: boolean;
};
type TabStoreState = {
tabs: AppTab[];
activeId: string;
open: (tab: Omit<AppTab, 'id'> & { id?: string }) => void;
/** 同 path 则仅激活,否则新开 */
openOrActivate: (tab: { path: string; title: string }) => void;
/** 浏览器地址变化时同步当前页签 */
syncFromPath: (path: string) => void;
/** 切换租户:保留固定「概览」并重置页签 */
resetForTenantSwitch: () => void;
close: (id: string) => string | null;
activate: (id: string) => void;
};
let seq = 0;
const overview: AppTab = {
id: 'overview',
title: '概览',
path: '/dashboard',
pinned: true,
};
function titleFromPath(path: string): string {
const parts = path.split('/').filter(Boolean);
const last = parts[parts.length - 1];
return last ? decodeURIComponent(last) : path;
}
export const useTabStore = create<TabStoreState>((set, get) => ({
tabs: [overview],
activeId: 'overview',
open: (tab) => {
const id = tab.id ?? `t-${++seq}`;
set((s) => ({
tabs: [...s.tabs, { ...tab, id }],
activeId: id,
}));
},
openOrActivate: ({ path, title }) => {
const normalized = path.startsWith('/') ? path : `/${path}`;
set((s) => {
const hit = s.tabs.find((t) => t.path === normalized);
if (hit) {
return { activeId: hit.id };
}
const id = `t-${++seq}`;
return {
tabs: [...s.tabs, { id, path: normalized, title: title || titleFromPath(normalized) }],
activeId: id,
};
});
},
syncFromPath: (path) => {
const normalized = path.startsWith('/') ? path : `/${path}`;
if (!normalized.startsWith('/dashboard')) {
return;
}
set((s) => {
const hit = s.tabs.find((t) => t.path === normalized);
if (hit) {
return { activeId: hit.id };
}
const id = `t-${++seq}`;
return {
tabs: [
...s.tabs,
{
id,
path: normalized,
title: titleFromPath(normalized),
},
],
activeId: id,
};
});
},
resetForTenantSwitch: () => {
set({
tabs: [overview],
activeId: 'overview',
});
},
close: (id) => {
const { tabs, activeId } = get();
const t = tabs.find((x) => x.id === id);
if (!t || t.pinned) {
return null;
}
const nextTabs = tabs.filter((x) => x.id !== id);
let nextActive = activeId;
if (activeId === id) {
const idx = tabs.findIndex((x) => x.id === id);
const neighbor = tabs[idx - 1] ?? tabs[idx + 1];
nextActive = neighbor?.id ?? 'overview';
}
set({ tabs: nextTabs, activeId: nextActive });
const activeTab = get().tabs.find((x) => x.id === nextActive);
return activeTab?.path ?? '/dashboard';
},
activate: (id) => set({ activeId: id }),
}));
+59
View File
@@ -0,0 +1,59 @@
'use client';
import { create } from 'zustand';
import { registerTenantHeaderBridge } from '@/lib/api/client';
const KEY = 'smart_tenant_id';
type TenantState = {
/** 显式选择的租户;null 表示不额外传 X-Tenant-ID,由 Bearer 默认租户生效 */
tenantId: string | null;
setTenantId: (id: string | null) => void;
/** 从当前用户资料同步(登录后首次) */
hydrateFromUserTenant: (tenantId: string | undefined | null) => void;
};
function load(): string | null {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = sessionStorage.getItem(KEY);
if (raw === '' || raw === 'null') {
return null;
}
return raw;
} catch {
return null;
}
}
export const useTenantStore = create<TenantState>((set, get) => ({
tenantId: typeof window !== 'undefined' ? load() : null,
setTenantId: (id) => {
set({ tenantId: id });
if (typeof window !== 'undefined') {
if (id) {
sessionStorage.setItem(KEY, id);
} else {
sessionStorage.removeItem(KEY);
}
}
},
hydrateFromUserTenant: (tid) => {
if (!tid) {
return;
}
const cur = get().tenantId;
if (cur == null || cur === '') {
set({ tenantId: tid });
if (typeof window !== 'undefined') {
sessionStorage.setItem(KEY, tid);
}
}
},
}));
registerTenantHeaderBridge(() => useTenantStore.getState().tenantId);
+31
View File
@@ -0,0 +1,31 @@
'use client';
import { create } from 'zustand';
export type ToastItem = { id: string; message: string; variant: 'error' | 'info' };
type ToastState = {
toasts: ToastItem[];
show: (message: string, variant?: ToastItem['variant']) => void;
dismiss: (id: string) => void;
};
export const useToastStore = create<ToastState>((set, get) => ({
toasts: [],
show: (message, variant = 'info') => {
const id =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: String(Date.now());
set((s) => ({ toasts: [...s.toasts, { id, message, variant }] }));
const duration = variant === 'error' ? 5200 : 3600;
if (typeof window !== 'undefined') {
window.setTimeout(() => {
get().dismiss(id);
}, duration);
}
},
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
}));