create-secra 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/README.zh-CN.md +6 -0
- package/antd-adapter-template/apps/core/index.html +13 -0
- package/antd-adapter-template/apps/core/package.json +18 -0
- package/antd-adapter-template/apps/core/public/favicon.ico +1 -0
- package/antd-adapter-template/apps/core/public/favicon.svg +1 -0
- package/antd-adapter-template/apps/core/public/logo.svg +1 -0
- package/antd-adapter-template/apps/core/src/api/auth.ts +49 -0
- package/antd-adapter-template/apps/core/src/assets/react.svg +1 -0
- package/antd-adapter-template/apps/core/src/components/AntdGlobalProvider.tsx +87 -0
- package/antd-adapter-template/apps/core/src/components/AntdRootLayout.tsx +10 -0
- package/antd-adapter-template/apps/core/src/components/layout.tsx +387 -0
- package/antd-adapter-template/apps/core/src/guards/auth-route-guard.ts +45 -0
- package/antd-adapter-template/apps/core/src/main.tsx +65 -0
- package/antd-adapter-template/apps/core/src/pages/auth/components/account-login-fields.tsx +60 -0
- package/antd-adapter-template/apps/core/src/pages/auth/components/phone-login-fields.tsx +60 -0
- package/antd-adapter-template/apps/core/src/pages/auth/login.tsx +169 -0
- package/antd-adapter-template/apps/core/src/pages/index.tsx +156 -0
- package/antd-adapter-template/apps/core/src/router.ts +42 -0
- package/antd-adapter-template/apps/core/src/shims/use-sync-external-store-shim.ts +3 -0
- package/antd-adapter-template/apps/core/src/theme/theme.css +48 -0
- package/antd-adapter-template/apps/core/src/types/crypto-js.d.ts +5 -0
- package/antd-adapter-template/apps/core/src/utils/index.ts +12 -0
- package/antd-adapter-template/apps/core/src/utils/md5.ts +6 -0
- package/antd-adapter-template/apps/core/tsconfig.app.json +11 -0
- package/antd-adapter-template/apps/core/tsconfig.json +13 -0
- package/antd-adapter-template/apps/core/tsconfig.node.json +7 -0
- package/antd-adapter-template/apps/core/vite.config.ts +118 -0
- package/antd-adapter-template/eslint.config.js +23 -0
- package/antd-adapter-template/package.json +63 -0
- package/antd-adapter-template/packages/sdk/.swcrc +18 -0
- package/antd-adapter-template/packages/sdk/package.json +52 -0
- package/antd-adapter-template/packages/sdk/src/build/index.ts +28 -0
- package/antd-adapter-template/packages/sdk/src/build/plugins/auto-import.ts +46 -0
- package/antd-adapter-template/packages/sdk/src/build/plugins/bundle-analyzer.ts +33 -0
- package/antd-adapter-template/packages/sdk/src/build/plugins/remove-console.ts +23 -0
- package/antd-adapter-template/packages/sdk/src/build/plugins/unocss.ts +202 -0
- package/antd-adapter-template/packages/sdk/src/build/plugins/unplugin-icon.ts +43 -0
- package/antd-adapter-template/packages/sdk/src/components/i18n-switch-dropdown.tsx +139 -0
- package/antd-adapter-template/packages/sdk/src/components/index.ts +2 -0
- package/antd-adapter-template/packages/sdk/src/components/theme-switch-dropdown.tsx +131 -0
- package/antd-adapter-template/packages/sdk/src/hooks/auth/core.ts +101 -0
- package/antd-adapter-template/packages/sdk/src/hooks/auth/index.ts +139 -0
- package/antd-adapter-template/packages/sdk/src/hooks/auth/with-auth.tsx +41 -0
- package/antd-adapter-template/packages/sdk/src/hooks/index.ts +1 -0
- package/antd-adapter-template/packages/sdk/src/i18n/index.ts +150 -0
- package/antd-adapter-template/packages/sdk/src/index.ts +11 -0
- package/antd-adapter-template/packages/sdk/src/request/index.ts +436 -0
- package/antd-adapter-template/packages/sdk/src/storage/README.md +30 -0
- package/antd-adapter-template/packages/sdk/src/storage/index.ts +57 -0
- package/antd-adapter-template/packages/sdk/src/styles/reset.css +111 -0
- package/antd-adapter-template/packages/sdk/src/theme/index.ts +466 -0
- package/antd-adapter-template/packages/sdk/tsconfig.json +16 -0
- package/antd-adapter-template/pnpm-workspace.yaml +3 -0
- package/antd-adapter-template/tsconfig.app.json +29 -0
- package/antd-adapter-template/tsconfig.json +7 -0
- package/antd-adapter-template/tsconfig.node.json +27 -0
- package/antd-adapter-template/turbo.json +17 -0
- package/bin/index.mjs +165 -33
- package/package.json +3 -2
- package/template/apps/core/src/main.tsx +11 -18
- package/template/apps/core/src/router.ts +5 -1
- package/template/package.json +1 -1
- package/template/packages/sdk/src/build/plugins/unocss.ts +3 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { TranslationOutlined } from "@ant-design/icons";
|
|
2
|
+
import { Button, Dropdown, Space, type MenuProps } from "antd";
|
|
3
|
+
import { useLocale } from "@vlian/framework/core";
|
|
4
|
+
import { LocaleSwitch } from "@vlian/framework/components";
|
|
5
|
+
import { useCallback, useEffect, useMemo } from "react";
|
|
6
|
+
import type { SdkLang } from "../i18n/index.js";
|
|
7
|
+
import { cache, type StorageStrategy } from "../storage/index.js";
|
|
8
|
+
|
|
9
|
+
const LANG_LABEL: Record<SdkLang, string> = {
|
|
10
|
+
"en-US": "English",
|
|
11
|
+
"zh-CN": "简体中文",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const isSdkLang = (lang: string): lang is SdkLang => {
|
|
15
|
+
return lang === "en-US" || lang === "zh-CN";
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const normalizeLang = (lang?: string): SdkLang => {
|
|
19
|
+
if (lang === "en-US") {
|
|
20
|
+
return "en-US";
|
|
21
|
+
}
|
|
22
|
+
return "zh-CN";
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export interface I18nSwitchDropdownProps {
|
|
26
|
+
storageType?: StorageStrategy;
|
|
27
|
+
storageKey?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const I18nSwitchDropdown = ({
|
|
31
|
+
storageType = "localstorage",
|
|
32
|
+
storageKey = "sdk-lang",
|
|
33
|
+
}: I18nSwitchDropdownProps) => {
|
|
34
|
+
const { setLocale } = useLocale();
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (storageType !== "indexeddb") {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let disposed = false;
|
|
42
|
+
|
|
43
|
+
const restoreLang = async () => {
|
|
44
|
+
const nextLang = await cache.indexeddb.get<SdkLang>(storageKey);
|
|
45
|
+
if (disposed || !nextLang || !isSdkLang(nextLang)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
setLocale(nextLang);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
void restoreLang();
|
|
52
|
+
return () => {
|
|
53
|
+
disposed = true;
|
|
54
|
+
};
|
|
55
|
+
}, [setLocale, storageKey, storageType]);
|
|
56
|
+
|
|
57
|
+
const persistence = useMemo(() => {
|
|
58
|
+
if (typeof window === "undefined") {
|
|
59
|
+
return { enabled: false, key: storageKey };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (storageType === "localstorage") {
|
|
63
|
+
return { key: storageKey, storage: window.localStorage };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (storageType === "sessionStorage") {
|
|
67
|
+
return { key: storageKey, storage: window.sessionStorage };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { enabled: false, key: storageKey };
|
|
71
|
+
}, [storageKey, storageType]);
|
|
72
|
+
|
|
73
|
+
const handleChange = useCallback(
|
|
74
|
+
(locale: SdkLang) => {
|
|
75
|
+
if (storageType === "indexeddb") {
|
|
76
|
+
void cache.indexeddb.set(storageKey, locale, -1);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
[storageKey, storageType],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const renderDropdown = useCallback(
|
|
83
|
+
({
|
|
84
|
+
locale,
|
|
85
|
+
locales,
|
|
86
|
+
setLocale: setLocaleValue,
|
|
87
|
+
isChanging,
|
|
88
|
+
labels,
|
|
89
|
+
}: {
|
|
90
|
+
locale: SdkLang;
|
|
91
|
+
locales: SdkLang[];
|
|
92
|
+
setLocale: (nextLocale: SdkLang) => void;
|
|
93
|
+
isChanging: boolean;
|
|
94
|
+
labels: Record<SdkLang, string>;
|
|
95
|
+
}) => {
|
|
96
|
+
const menu: MenuProps = {
|
|
97
|
+
selectedKeys: [normalizeLang(locale)],
|
|
98
|
+
onClick: ({ key }) => {
|
|
99
|
+
if (!isSdkLang(key)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
setLocaleValue(key);
|
|
103
|
+
},
|
|
104
|
+
items: locales.map((item) => ({
|
|
105
|
+
key: item,
|
|
106
|
+
label: (
|
|
107
|
+
<Space size={8}>
|
|
108
|
+
{labels[item]}
|
|
109
|
+
</Space>
|
|
110
|
+
),
|
|
111
|
+
})),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<Dropdown menu={menu} trigger={["click"]}>
|
|
116
|
+
<Button
|
|
117
|
+
type="text"
|
|
118
|
+
size="small"
|
|
119
|
+
shape={"circle"}
|
|
120
|
+
aria-label="切换语言"
|
|
121
|
+
loading={isChanging}
|
|
122
|
+
>
|
|
123
|
+
<TranslationOutlined />
|
|
124
|
+
</Button>
|
|
125
|
+
</Dropdown>
|
|
126
|
+
);
|
|
127
|
+
},
|
|
128
|
+
[],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<LocaleSwitch
|
|
133
|
+
labels={LANG_LABEL}
|
|
134
|
+
persistence={persistence}
|
|
135
|
+
onChange={handleChange}
|
|
136
|
+
render={renderDropdown}
|
|
137
|
+
/>
|
|
138
|
+
);
|
|
139
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SunOutlined,
|
|
3
|
+
DesktopOutlined,
|
|
4
|
+
MoonOutlined,
|
|
5
|
+
} from "@ant-design/icons";
|
|
6
|
+
import { Button, Dropdown, Space, type MenuProps } from "antd";
|
|
7
|
+
import { ThemeSwitch } from "@vlian/framework/components";
|
|
8
|
+
import { useCallback, useEffect, useMemo } from "react";
|
|
9
|
+
import { cache, type StorageStrategy } from "../storage/index.js";
|
|
10
|
+
import { useThemeSwitch, type ThemeMode } from "../theme/index.js";
|
|
11
|
+
|
|
12
|
+
const MODE_ICON: Record<ThemeMode, React.ReactNode> = {
|
|
13
|
+
light: <SunOutlined />,
|
|
14
|
+
dark: <MoonOutlined />,
|
|
15
|
+
system: <DesktopOutlined />,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export interface ThemeSwitchDropdownProps {
|
|
19
|
+
storageType?: StorageStrategy;
|
|
20
|
+
storageKey?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const ThemeSwitchDropdown = ({
|
|
24
|
+
storageType = "localstorage",
|
|
25
|
+
storageKey = "theme-mode",
|
|
26
|
+
}: ThemeSwitchDropdownProps) => {
|
|
27
|
+
const { setThemeMode } = useThemeSwitch();
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (storageType !== "indexeddb") {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let disposed = false;
|
|
35
|
+
|
|
36
|
+
const restoreThemeMode = async () => {
|
|
37
|
+
const nextMode = await cache.indexeddb.get<ThemeMode>(storageKey);
|
|
38
|
+
if (disposed || !nextMode) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (nextMode === "light" || nextMode === "dark" || nextMode === "system") {
|
|
42
|
+
setThemeMode(nextMode);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
void restoreThemeMode();
|
|
47
|
+
return () => {
|
|
48
|
+
disposed = true;
|
|
49
|
+
};
|
|
50
|
+
}, [setThemeMode, storageKey, storageType]);
|
|
51
|
+
|
|
52
|
+
const persistence = useMemo(() => {
|
|
53
|
+
if (typeof window === "undefined") {
|
|
54
|
+
return { enabled: false, key: storageKey };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (storageType === "localstorage") {
|
|
58
|
+
return { key: storageKey, storage: window.localStorage };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (storageType === "sessionStorage") {
|
|
62
|
+
return { key: storageKey, storage: window.sessionStorage };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { enabled: false, key: storageKey };
|
|
66
|
+
}, [storageKey, storageType]);
|
|
67
|
+
|
|
68
|
+
const handleChange = useCallback(
|
|
69
|
+
(nextMode: ThemeMode) => {
|
|
70
|
+
if (storageType === "indexeddb") {
|
|
71
|
+
void cache.indexeddb.set(storageKey, nextMode, -1);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
[storageKey, storageType],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const renderDropdown = useCallback(
|
|
78
|
+
({
|
|
79
|
+
mode,
|
|
80
|
+
modes,
|
|
81
|
+
labels,
|
|
82
|
+
setMode,
|
|
83
|
+
}: {
|
|
84
|
+
mode: ThemeMode;
|
|
85
|
+
modes: ThemeMode[];
|
|
86
|
+
labels: Record<ThemeMode, string>;
|
|
87
|
+
setMode: (nextMode: ThemeMode) => void;
|
|
88
|
+
}) => {
|
|
89
|
+
const menu: MenuProps = {
|
|
90
|
+
selectedKeys: [mode],
|
|
91
|
+
onClick: ({ key }) => {
|
|
92
|
+
if (key !== "light" && key !== "dark" && key !== "system") {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
setMode(key as ThemeMode);
|
|
96
|
+
},
|
|
97
|
+
items: modes.map((item) => ({
|
|
98
|
+
key: item,
|
|
99
|
+
label: (
|
|
100
|
+
<Space size={8}>
|
|
101
|
+
{MODE_ICON[item]}
|
|
102
|
+
{labels[item]}
|
|
103
|
+
</Space>
|
|
104
|
+
),
|
|
105
|
+
})),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<Dropdown menu={menu} trigger={["click"]}>
|
|
110
|
+
<Button type="text" size="small" shape={"circle"} aria-label="切换主题">
|
|
111
|
+
{MODE_ICON[mode]}
|
|
112
|
+
</Button>
|
|
113
|
+
</Dropdown>
|
|
114
|
+
);
|
|
115
|
+
},
|
|
116
|
+
[],
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<ThemeSwitch
|
|
121
|
+
labels={{
|
|
122
|
+
light: "浅色",
|
|
123
|
+
dark: "深色",
|
|
124
|
+
system: "跟随系统",
|
|
125
|
+
}}
|
|
126
|
+
persistence={persistence}
|
|
127
|
+
onChange={handleChange}
|
|
128
|
+
render={renderDropdown}
|
|
129
|
+
/>
|
|
130
|
+
);
|
|
131
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { StorageStrategy } from "../../storage/index.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 默认 access token 存储键。
|
|
5
|
+
* 业务方可通过 AuthTokenOptions.key 覆盖该键以复用同一套读写逻辑。
|
|
6
|
+
*/
|
|
7
|
+
export const AUTH_TOKEN_KEY = "user:token";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 统一的 access token 存储键(业务登录场景)。
|
|
11
|
+
*/
|
|
12
|
+
export const ACCESS_TOKEN_KEY = "auth-access-token";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 统一的 refresh token 存储键(业务登录场景)。
|
|
16
|
+
*/
|
|
17
|
+
export const REFRESH_TOKEN_KEY = "auth-refresh-token";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 默认 token 存储介质。
|
|
21
|
+
* 当前默认使用 localstorage,确保页面刷新后仍可读取。
|
|
22
|
+
*/
|
|
23
|
+
export const AUTH_TOKEN_STORAGE: StorageStrategy = "localstorage";
|
|
24
|
+
|
|
25
|
+
const SDK_STORAGE_PREFIX = "secra-admin-sdk";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Token 读写配置项。
|
|
29
|
+
* - key: 自定义 token 对应的存储 key
|
|
30
|
+
* - strategy: 存储策略(localstorage / sessionStorage / indexeddb)
|
|
31
|
+
*/
|
|
32
|
+
export interface AuthTokenOptions {
|
|
33
|
+
key?: string;
|
|
34
|
+
strategy?: StorageStrategy;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 归一化 token 配置,补齐默认值,避免调用方重复传参。
|
|
39
|
+
*/
|
|
40
|
+
export const resolveAuthTokenOptions = (
|
|
41
|
+
options?: AuthTokenOptions,
|
|
42
|
+
): Required<AuthTokenOptions> => ({
|
|
43
|
+
key: options?.key || AUTH_TOKEN_KEY,
|
|
44
|
+
strategy: options?.strategy || AUTH_TOKEN_STORAGE,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
type SyncStorageStrategy = Exclude<StorageStrategy, "indexeddb">;
|
|
48
|
+
type SerializedStoragePayload = {
|
|
49
|
+
data?: unknown;
|
|
50
|
+
exp?: number;
|
|
51
|
+
sign?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const getSyncStorageByStrategy = (strategy: SyncStorageStrategy): Storage | null => {
|
|
55
|
+
if (typeof window === "undefined") {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return strategy === "sessionStorage" ? window.sessionStorage : window.localStorage;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const resolveRawStoredValue = (storage: Storage, key: string): string | null => {
|
|
62
|
+
const prefixedKey = `${SDK_STORAGE_PREFIX}:${key}`;
|
|
63
|
+
return storage.getItem(prefixedKey) ?? storage.getItem(key);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const parseStoredToken = (rawValue: string): string | null => {
|
|
67
|
+
try {
|
|
68
|
+
const payload = JSON.parse(rawValue) as SerializedStoragePayload;
|
|
69
|
+
const exp = payload.exp ?? -1;
|
|
70
|
+
if (exp !== -1 && Date.now() > exp) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return typeof payload.data === "string" && payload.data ? payload.data : null;
|
|
74
|
+
} catch {
|
|
75
|
+
return rawValue || null;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 同步判断当前是否已登录。
|
|
81
|
+
* 仅支持 localstorage / sessionStorage;indexeddb 回退为 false。
|
|
82
|
+
*/
|
|
83
|
+
export const isAuthenticatedSync = (options?: AuthTokenOptions): boolean => {
|
|
84
|
+
const { key, strategy } = resolveAuthTokenOptions(options);
|
|
85
|
+
if (strategy === "indexeddb") {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const storage = getSyncStorageByStrategy(strategy);
|
|
90
|
+
if (!storage) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const rawValue = resolveRawStoredValue(storage, key);
|
|
95
|
+
if (!rawValue) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return Boolean(parseStoredToken(rawValue));
|
|
100
|
+
};
|
|
101
|
+
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { cache } from "../../storage/index.js";
|
|
3
|
+
import {
|
|
4
|
+
ACCESS_TOKEN_KEY,
|
|
5
|
+
AUTH_TOKEN_KEY,
|
|
6
|
+
AUTH_TOKEN_STORAGE,
|
|
7
|
+
REFRESH_TOKEN_KEY,
|
|
8
|
+
type AuthTokenOptions,
|
|
9
|
+
isAuthenticatedSync,
|
|
10
|
+
resolveAuthTokenOptions,
|
|
11
|
+
} from "./core.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 读取 token(非 hooks 模式)。
|
|
15
|
+
*
|
|
16
|
+
* @param options 可选配置:自定义 key 和存储策略。
|
|
17
|
+
* @returns Promise<string | null> 返回 token 字符串;不存在时返回 null。
|
|
18
|
+
*/
|
|
19
|
+
export const getToken = async (options?: AuthTokenOptions): Promise<string | null> => {
|
|
20
|
+
const { key, strategy } = resolveAuthTokenOptions(options);
|
|
21
|
+
return cache[strategy].get<string>(key);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 写入 token(非 hooks 模式)。
|
|
26
|
+
*
|
|
27
|
+
* @param token 要持久化的 token 字符串。
|
|
28
|
+
* @param options 可选配置:自定义 key 和存储策略。
|
|
29
|
+
* @returns Promise<void>
|
|
30
|
+
*/
|
|
31
|
+
export const setToken = async (token: string, options?: AuthTokenOptions): Promise<void> => {
|
|
32
|
+
const { key, strategy } = resolveAuthTokenOptions(options);
|
|
33
|
+
await cache[strategy].set<string>(key, token);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 删除 token(非 hooks 模式)。
|
|
38
|
+
*
|
|
39
|
+
* @param options 可选配置:自定义 key 和存储策略。
|
|
40
|
+
* @returns Promise<void>
|
|
41
|
+
*/
|
|
42
|
+
export const removeToken = async (options?: AuthTokenOptions): Promise<void> => {
|
|
43
|
+
const { key, strategy } = resolveAuthTokenOptions(options);
|
|
44
|
+
await cache[strategy].remove(key);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* useToken 返回结果。
|
|
49
|
+
* - token: 当前内存态 token
|
|
50
|
+
* - loading: 首次加载或手动刷新时的读取状态
|
|
51
|
+
* - getToken: 仅读取存储并同步到内存态
|
|
52
|
+
* - setToken: 写入存储并同步到内存态
|
|
53
|
+
* - removeToken: 删除存储并清空内存态
|
|
54
|
+
* - refresh: 强制刷新 token,通常用于登录态恢复场景
|
|
55
|
+
*/
|
|
56
|
+
export interface UseTokenResult {
|
|
57
|
+
token: string | null;
|
|
58
|
+
loading: boolean;
|
|
59
|
+
getToken: () => Promise<string | null>;
|
|
60
|
+
setToken: (value: string) => Promise<void>;
|
|
61
|
+
removeToken: () => Promise<void>;
|
|
62
|
+
refresh: () => Promise<string | null>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Token 管理 Hook(hooks 模式)。
|
|
67
|
+
*
|
|
68
|
+
* 提供 token 的读取、写入、删除与刷新能力,并维护组件内可响应的 token 状态。
|
|
69
|
+
* 在组件挂载后会自动触发一次 refresh(),用于从持久化存储恢复 token。
|
|
70
|
+
*
|
|
71
|
+
* @param options 可选配置:自定义 key 和存储策略。
|
|
72
|
+
* @returns UseTokenResult token 状态与一组可复用操作方法。
|
|
73
|
+
*/
|
|
74
|
+
export const useToken = (options?: AuthTokenOptions): UseTokenResult => {
|
|
75
|
+
const [token, setTokenState] = useState<string | null>(null);
|
|
76
|
+
const [loading, setLoading] = useState(true);
|
|
77
|
+
const resolvedOptions = useMemo(
|
|
78
|
+
() => resolveAuthTokenOptions(options),
|
|
79
|
+
[options?.key, options?.strategy],
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 从存储读取 token,并同步到 hook 内部状态。
|
|
84
|
+
*/
|
|
85
|
+
const readToken = useCallback(async () => {
|
|
86
|
+
const value = await cache[resolvedOptions.strategy].get<string>(resolvedOptions.key);
|
|
87
|
+
setTokenState(value);
|
|
88
|
+
return value;
|
|
89
|
+
}, [resolvedOptions.key, resolvedOptions.strategy]);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 主动刷新 token:
|
|
93
|
+
* 1. 设置 loading=true
|
|
94
|
+
* 2. 读取并同步 token
|
|
95
|
+
* 3. 结束后恢复 loading=false
|
|
96
|
+
*/
|
|
97
|
+
const refresh = useCallback(async () => {
|
|
98
|
+
setLoading(true);
|
|
99
|
+
try {
|
|
100
|
+
return await readToken();
|
|
101
|
+
} finally {
|
|
102
|
+
setLoading(false);
|
|
103
|
+
}
|
|
104
|
+
}, [readToken]);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 持久化写入 token,并立即更新内存态,避免界面等待下一次读取。
|
|
108
|
+
*/
|
|
109
|
+
const persistToken = useCallback(
|
|
110
|
+
async (value: string) => {
|
|
111
|
+
await cache[resolvedOptions.strategy].set<string>(resolvedOptions.key, value);
|
|
112
|
+
setTokenState(value);
|
|
113
|
+
},
|
|
114
|
+
[resolvedOptions.key, resolvedOptions.strategy],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 删除持久化 token,并同步清空内存态。
|
|
119
|
+
*/
|
|
120
|
+
const clearToken = useCallback(async () => {
|
|
121
|
+
await cache[resolvedOptions.strategy].remove(resolvedOptions.key);
|
|
122
|
+
setTokenState(null);
|
|
123
|
+
}, [resolvedOptions.key, resolvedOptions.strategy]);
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
void refresh();
|
|
127
|
+
}, [refresh]);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
token,
|
|
131
|
+
loading,
|
|
132
|
+
getToken: readToken,
|
|
133
|
+
setToken: persistToken,
|
|
134
|
+
removeToken: clearToken,
|
|
135
|
+
refresh,
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
export * from "./core.js";
|
|
139
|
+
export * from "./with-auth.js";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ComponentType, FC } from "react";
|
|
2
|
+
import { Navigate } from "react-router-dom";
|
|
3
|
+
import { logger } from "@vlian/framework/utils";
|
|
4
|
+
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, isAuthenticatedSync, type AuthTokenOptions } from "./core.js";
|
|
5
|
+
|
|
6
|
+
export interface WithAuthOptions {
|
|
7
|
+
tokenOptions?: AuthTokenOptions;
|
|
8
|
+
refreshTokenOptions?: AuthTokenOptions | false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DEFAULT_LOGIN_PATH = "/auth/login";
|
|
12
|
+
|
|
13
|
+
export const withAuth = <P extends object>(
|
|
14
|
+
WrappedComponent: ComponentType<P>,
|
|
15
|
+
options?: WithAuthOptions,
|
|
16
|
+
): FC<P> => {
|
|
17
|
+
const accessTokenOptions = options?.tokenOptions ?? { key: ACCESS_TOKEN_KEY };
|
|
18
|
+
const refreshTokenOptions = options?.refreshTokenOptions ?? { key: REFRESH_TOKEN_KEY };
|
|
19
|
+
const getNow =
|
|
20
|
+
typeof performance !== "undefined" && typeof performance.now === "function"
|
|
21
|
+
? () => performance.now()
|
|
22
|
+
: () => Date.now();
|
|
23
|
+
|
|
24
|
+
const AuthenticatedComponent: FC<P> = (props) => {
|
|
25
|
+
const start = getNow();
|
|
26
|
+
const hasAccessToken = isAuthenticatedSync(accessTokenOptions);
|
|
27
|
+
const hasRefreshToken =
|
|
28
|
+
refreshTokenOptions === false ? false : isAuthenticatedSync(refreshTokenOptions);
|
|
29
|
+
const end = getNow();
|
|
30
|
+
logger.debug("[withAuth] isAuthenticatedSync cost(ms)", Number((end - start).toFixed(2)));
|
|
31
|
+
const isLogin = hasAccessToken || hasRefreshToken;
|
|
32
|
+
if (isLogin) {
|
|
33
|
+
return <WrappedComponent {...props} />;
|
|
34
|
+
}
|
|
35
|
+
return <Navigate to={DEFAULT_LOGIN_PATH} />;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
AuthenticatedComponent.displayName = `withAuth(${WrappedComponent.displayName || WrappedComponent.name || "Component"})`;
|
|
39
|
+
|
|
40
|
+
return AuthenticatedComponent;
|
|
41
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./auth/index.js";
|