feat: 优化web
This commit is contained in:
@@ -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)
|
||||
);
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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);
|
||||
@@ -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) })),
|
||||
}));
|
||||
Reference in New Issue
Block a user