@liujitcn/kratos-admin-core 0.1.4 → 0.1.6

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 CHANGED
@@ -60,7 +60,7 @@ packages/core
60
60
 
61
61
  | 路径 | 作用 |
62
62
  | -------------------------------------- | ------------------------------------------------------------------------------ |
63
- | `src/index.ts` | npm 主入口,导出启动函数和模块 interface。 |
63
+ | `src/index.ts` | npm 主入口,导出启动函数、模块 interface 和通用语音识别能力。 |
64
64
  | `src/auth.ts` | 业务模块使用的按钮权限入口。 |
65
65
  | `src/format.ts` | 业务模块使用的格式化入口。 |
66
66
  | `src/navigation.ts` | 业务模块使用的路由与跳转入口。 |
@@ -114,6 +114,7 @@ packages/core
114
114
  | `src/hooks/useHandleData.ts` | 删除、状态变更等确认交互。 |
115
115
  | `src/hooks/useOnline.ts` | 网络在线状态监听。 |
116
116
  | `src/hooks/useSelection.ts` | 表格选择状态。 |
117
+ | `src/hooks/useSpeechRecognition.ts` | AI 助手与 BI 问数共用的浏览器语音识别,通过 npm 主入口导出。 |
117
118
  | `src/hooks/useTable.ts` | 表格请求、分页、搜索和加载状态。 |
118
119
  | `src/hooks/useTheme.ts` | 主题初始化和切换。 |
119
120
  | `src/hooks/useTime.ts` | 问候时间和时间展示。 |
@@ -193,6 +194,6 @@ Dict 和 DictLabel 直接响应字典 Store 的文案变化。字典刷新成功
193
194
 
194
195
  纯表格 demo 的“所在城市”通过 `search.enum` 提供浙江、广东、四川的省市级联选项,选择城市后可筛选对应项目,清空或重置后恢复全部项目。
195
196
 
196
- ProTable 的 `cellType: "actions"` 操作列按当前行的 `hidden` 过滤按钮,优先展示使用 `common.action.edit`、`common.action.delete` 文案的编辑、删除操作;可见按钮超过三项时,其余操作收入“更多”菜单。未配置或无权限的编辑、删除不会补充显示,菜单保留图标、禁用状态和点击参数;自定义 render 或插槽不参与折叠。
197
+ ProTable 的 `cellType: "actions"` 操作列按当前行的 `hidden` 过滤按钮,优先展示使用 `common.action.edit`、`common.action.delete` 文案的编辑、删除操作。可见操作不超过三项时全部展示;四项及以上时,行内固定展示两个操作和“更多”,其余进入菜单。编辑、删除不足两个时按配置顺序补足,不补充未配置或无权限的操作。例如四项操作展示为行内两项、菜单两项。菜单保留图标、禁用状态和点击参数;自定义 render 或插槽不参与折叠。
197
198
 
198
199
  操作列默认固定在右侧。配置式操作列(`cellType: "actions"`)根据当前页实际展示按钮和表头的最大宽度自适应,计入翻译文案、图标和间距,随语言、字体和行权限变化重新测量,最小宽度为 `80px`;折叠菜单内的按钮不参与测量。自定义 render 或插槽操作列仍默认 `160px`。页面可通过 `width` 或 `minWidth` 覆盖自动宽度,通过 `fixed: "left"` 调整固定位置,或通过 `fixed: false` 取消固定。
@@ -22,11 +22,13 @@
22
22
  "Calendar",
23
23
  "Camera",
24
24
  "ChatDotRound",
25
+ "Check",
25
26
  "CircleCheck",
26
27
  "CircleCheckFilled",
27
28
  "CircleClose",
28
29
  "CirclePlus",
29
30
  "Close",
31
+ "Coin",
30
32
  "CollectionTag",
31
33
  "CopyDocument",
32
34
  "DataAnalysis",
@@ -41,23 +43,30 @@
41
43
  "Fold",
42
44
  "Folder",
43
45
  "Goods",
46
+ "Grid",
44
47
  "InfoFilled",
45
48
  "List",
49
+ "Loading",
46
50
  "Lock",
47
51
  "Male",
48
52
  "Message",
53
+ "Microphone",
49
54
  "Moon",
55
+ "MoreFilled",
50
56
  "Operation",
51
57
  "Phone",
52
58
  "Plus",
53
59
  "Position",
54
60
  "Promotion",
55
61
  "QuestionFilled",
62
+ "Rank",
56
63
  "Refresh",
57
64
  "RefreshLeft",
58
65
  "RefreshRight",
59
66
  "Search",
60
67
  "Setting",
68
+ "Star",
69
+ "StarFilled",
61
70
  "Sunny",
62
71
  "Switch",
63
72
  "SwitchButton",
@@ -0,0 +1,37 @@
1
+ /** 语音识别结果的最小结构,便于在不同浏览器实现间复用。 */
2
+ export type SpeechResultList = ArrayLike<{
3
+ 0?: {
4
+ transcript: string;
5
+ };
6
+ isFinal?: boolean;
7
+ length: number;
8
+ }>;
9
+ /** 语音识别错误的最小结构,兼容浏览器事件和不支持错误。 */
10
+ export type SpeechRecognitionError = {
11
+ code?: number;
12
+ error?: string;
13
+ message?: string;
14
+ };
15
+ type SpeechRecognitionOptions = {
16
+ /** 识别错误回调。 */
17
+ onError?: (error: SpeechRecognitionError) => void;
18
+ /** 识别开始回调。 */
19
+ onStart?: () => void;
20
+ /** 识别结束回调。 */
21
+ onEnd?: (text: string) => void;
22
+ /** 识别结果回调。 */
23
+ onResult?: (text: string) => void;
24
+ };
25
+ /** 增量累积语音识别结果,避免连续模式下 interim 结果重复计入已确认文本。 */
26
+ export declare function accumulateSpeechResultText(results: SpeechResultList, resultIndex: number, previousFinalText: string): {
27
+ finalText: string;
28
+ text: string;
29
+ };
30
+ /** useSpeechRecognition 创建浏览器语音识别实例,并统一结果、状态和卸载处理。 */
31
+ export declare function useSpeechRecognition({ onError, onStart, onEnd, onResult }?: SpeechRecognitionOptions): {
32
+ loading: globalThis.Ref<boolean, boolean>;
33
+ start: () => void;
34
+ stop: () => void;
35
+ value: globalThis.Ref<string, string>;
36
+ };
37
+ export {};
@@ -10,3 +10,5 @@ export { setAdminDocumentTitle } from "./documentTitle";
10
10
  export { kratosAdminModule } from "./modules/kratosAdmin";
11
11
  export { bootstrapAdminApp } from "./bootstrap";
12
12
  export { refreshAdminRuntimeConfig } from "./stores/modules/config";
13
+ export { useSpeechRecognition } from "./hooks/useSpeechRecognition";
14
+ export type { SpeechRecognitionError } from "./hooks/useSpeechRecognition";
@@ -15,7 +15,7 @@ export declare function hasValidAccessToken(): boolean;
15
15
  export declare function getRequestAccessToken(): Promise<string>;
16
16
  /** 尝试通过刷新令牌恢复访问令牌,供路由守卫进入页面前调用。 */
17
17
  export declare function ensureAccessToken(): Promise<boolean>;
18
- /** 统一处理认证失效 */
18
+ /** 统一处理认证失效:会话已不可用时直接清理本地凭据并回到登录页,避免用户停留在全 401 的空壳页面。 */
19
19
  export declare function handleAuthExpired(): void;
20
20
  /** 以请求体、响应体顺序提供带类型约束的 HTTP 请求函数。 */
21
21
  declare function request<Request = unknown, Response = unknown>(config: AxiosRequestConfig<Request>): Promise<Response>;
@@ -138,7 +138,7 @@ function createMarkdownHeadingId({ text, index }: Parameters<MdHeadingId>[0]) {
138
138
  --md-theme-table-td-border-color: var(--admin-page-card-border-soft);
139
139
  --md-theme-code-inline-color: var(--admin-page-accent-soft-text);
140
140
  --md-theme-code-inline-bg-color: var(--admin-page-card-bg-muted);
141
- --md-theme-code-inline-radius: 4px;
141
+ --md-theme-code-inline-radius: var(--admin-page-radius);
142
142
  --md-theme-code-block-color: var(--admin-page-text-primary);
143
143
  --md-theme-code-block-bg-color: var(--admin-page-card-bg-muted);
144
144
  --md-theme-code-before-bg-color: var(--admin-page-card-bg-muted);
@@ -132,7 +132,7 @@ onMounted(async () => {
132
132
  .password-strength__bar {
133
133
  height: 8px;
134
134
  background: #ebeef5;
135
- border-radius: 999px;
135
+ border-radius: var(--admin-page-radius);
136
136
  transform: scaleX(0.92);
137
137
  transform-origin: left center;
138
138
  transition:
@@ -228,7 +228,7 @@ const renderMoneyCell = (item: ColumnProps, scope: RenderScope<any>) => {
228
228
  };
229
229
 
230
230
  /**
231
- * 渲染操作按钮列,优先展示编辑、删除,超过三项时将其他操作折叠。
231
+ * 渲染操作按钮列,三项以内全部展示,超过三项保留两个并折叠其余操作。
232
232
  */
233
233
  const renderActionsCell = (item: ColumnProps, scope: RenderScope<any>) => {
234
234
  if (!item.actions?.length) return "--";
@@ -237,7 +237,9 @@ const renderActionsCell = (item: ColumnProps, scope: RenderScope<any>) => {
237
237
  const primaryLabels = [t("common.action.edit"), t("common.action.delete")];
238
238
  const primaryActions = primaryLabels.flatMap(label => visibleActions.filter(action => action.label === label));
239
239
  const otherActions = visibleActions.filter(action => !primaryLabels.includes(action.label));
240
- const inlineActions = visibleActions.length > 3 ? primaryActions : [...primaryActions, ...otherActions];
240
+ const orderedActions = [...primaryActions, ...otherActions];
241
+ const inlineActions = visibleActions.length > 3 ? orderedActions.slice(0, 2) : orderedActions;
242
+ const moreActions = visibleActions.length > 3 ? orderedActions.slice(2) : [];
241
243
  const buttons = inlineActions.map((action: TableActionProps) => {
242
244
  const params = resolveColumnParams(action.params, scope);
243
245
  return h(
@@ -254,7 +256,7 @@ const renderActionsCell = (item: ColumnProps, scope: RenderScope<any>) => {
254
256
  { default: () => action.label }
255
257
  );
256
258
  });
257
- if (visibleActions.length > 3 && otherActions.length)
259
+ if (moreActions.length)
258
260
  buttons.push(
259
261
  h(
260
262
  ElDropdown,
@@ -264,7 +266,7 @@ const renderActionsCell = (item: ColumnProps, scope: RenderScope<any>) => {
264
266
  h(ElButton, { type: "primary", link: true, size: "small", icon: ArrowDown }, () => t("common.action.more")),
265
267
  dropdown: () =>
266
268
  h(ElDropdownMenu, null, () =>
267
- otherActions.map(action => {
269
+ moreActions.map(action => {
268
270
  const disabled = getBooleanValue(action.disabled, scope);
269
271
  const params = resolveColumnParams(action.params, scope);
270
272
  return h(
@@ -36,7 +36,7 @@
36
36
  list-style: none;
37
37
  background: var(--el-color-primary-light-9);
38
38
  border: 1px solid var(--el-color-primary-light-5);
39
- border-radius: 32px;
39
+ border-radius: var(--admin-page-radius);
40
40
  &:hover {
41
41
  color: #ffffff;
42
42
  background: var(--el-color-primary);
@@ -185,14 +185,14 @@ onBeforeUnmount(() => {
185
185
  </script>
186
186
 
187
187
  <style scoped>
188
- .yaml-editor { display: flex; flex-direction: column; width: 100%; overflow: hidden; background: var(--el-fill-color-blank); border: 1px solid var(--el-border-color); border-radius: 4px; }
188
+ .yaml-editor { display: flex; flex-direction: column; width: 100%; overflow: hidden; background: var(--el-fill-color-blank); border: 1px solid var(--el-border-color); border-radius: var(--admin-page-radius); }
189
189
  .yaml-editor.is-bounded { max-height: v-bind(maxHeight); }
190
190
  .yaml-editor:focus-within { border-color: var(--el-color-primary); }
191
191
  .yaml-editor.is-invalid { border-color: var(--el-color-danger); }
192
192
  .yaml-editor__toolbar { display: flex; align-items: center; justify-content: space-between; min-height: 32px; padding: 2px 8px; background: var(--el-fill-color-light); border-bottom: 1px solid var(--el-border-color-lighter); }
193
193
  .yaml-editor__lang { display: inline-flex; gap: 5px; align-items: center; color: var(--el-text-color-secondary); font-size: 12px; font-weight: 600; letter-spacing: 0.5px; }
194
194
  .yaml-editor__actions { display: inline-flex; gap: 2px; }
195
- .yaml-editor__action { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; color: var(--el-text-color-secondary); cursor: pointer; background: transparent; border: none; border-radius: 4px; transition: color 0.15s ease, background-color 0.15s ease; }
195
+ .yaml-editor__action { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; color: var(--el-text-color-secondary); cursor: pointer; background: transparent; border: none; border-radius: var(--admin-page-radius); transition: color 0.15s ease, background-color 0.15s ease; }
196
196
  .yaml-editor__action:hover, .yaml-editor__action:focus-visible { color: var(--el-color-primary); background: var(--el-fill-color); outline: none; }
197
197
  .yaml-editor__action.is-active { color: var(--el-color-primary); }
198
198
  .yaml-editor__host { flex: 1 1 auto; min-height: 0; }
@@ -10,7 +10,7 @@ export const LOGIN_URL: string = "/login";
10
10
  export const DEFAULT_PRIMARY: string = "#009688";
11
11
 
12
12
  // 路由白名单地址(本地存在的路由 staticRouter.ts 中)
13
- export const ROUTER_WHITE_LIST: string[] = ["/500"];
13
+ export const ROUTER_WHITE_LIST: string[] = ["/403", "/500"];
14
14
 
15
15
  // 高德地图 key
16
16
  export const AMAP_MAP_KEY: string = "";
@@ -0,0 +1,177 @@
1
+ import { getCurrentInstance, onBeforeUnmount, ref } from "vue";
2
+
3
+ /** 语音识别结果的最小结构,便于在不同浏览器实现间复用。 */
4
+ export type SpeechResultList = ArrayLike<{
5
+ 0?: {
6
+ transcript: string;
7
+ };
8
+ isFinal?: boolean;
9
+ length: number;
10
+ }>;
11
+
12
+ /** 语音识别错误的最小结构,兼容浏览器事件和不支持错误。 */
13
+ export type SpeechRecognitionError = {
14
+ code?: number;
15
+ error?: string;
16
+ message?: string;
17
+ };
18
+
19
+ type SpeechRecognitionResultEvent = {
20
+ results: SpeechResultList;
21
+ resultIndex: number;
22
+ };
23
+
24
+ type SpeechRecognitionInstance = {
25
+ continuous: boolean;
26
+ interimResults: boolean;
27
+ lang: string;
28
+ onstart: (() => void) | null;
29
+ onend: (() => void) | null;
30
+ onerror: ((error: SpeechRecognitionError) => void) | null;
31
+ onresult: ((event: SpeechRecognitionResultEvent) => void) | null;
32
+ start: () => void;
33
+ stop: () => void;
34
+ };
35
+
36
+ type SpeechRecognitionConstructor = new () => SpeechRecognitionInstance;
37
+
38
+ type SpeechRecognitionWindow = Window & {
39
+ SpeechRecognition?: SpeechRecognitionConstructor;
40
+ webkitSpeechRecognition?: SpeechRecognitionConstructor;
41
+ };
42
+
43
+ type SpeechRecognitionOptions = {
44
+ /** 识别错误回调。 */
45
+ onError?: (error: SpeechRecognitionError) => void;
46
+ /** 识别开始回调。 */
47
+ onStart?: () => void;
48
+ /** 识别结束回调。 */
49
+ onEnd?: (text: string) => void;
50
+ /** 识别结果回调。 */
51
+ onResult?: (text: string) => void;
52
+ };
53
+
54
+ /** 增量累积语音识别结果,避免连续模式下 interim 结果重复计入已确认文本。 */
55
+ export function accumulateSpeechResultText(
56
+ results: SpeechResultList,
57
+ resultIndex: number,
58
+ previousFinalText: string
59
+ ): { finalText: string; text: string } {
60
+ let finalText = previousFinalText;
61
+ let interimText = "";
62
+ for (let index = resultIndex; index < results.length; index++) {
63
+ const result = results[index];
64
+ const transcript = result?.[0]?.transcript ?? "";
65
+ if (result?.isFinal) {
66
+ finalText += transcript;
67
+ } else {
68
+ interimText += transcript;
69
+ }
70
+ }
71
+ // Chrome 连续模式下 interim 结果会累计包含已确认的 final 文本,去除重复前缀。
72
+ if (interimText && finalText && interimText.startsWith(finalText)) {
73
+ interimText = interimText.slice(finalText.length);
74
+ }
75
+ return { finalText, text: finalText + interimText };
76
+ }
77
+
78
+ /** useSpeechRecognition 创建浏览器语音识别实例,并统一结果、状态和卸载处理。 */
79
+ export function useSpeechRecognition({ onError, onStart, onEnd, onResult }: SpeechRecognitionOptions = {}) {
80
+ const loading = ref(false);
81
+ const value = ref("");
82
+ let recognition: SpeechRecognitionInstance | null = null;
83
+ let recognitionSession = 0;
84
+ let finalText = "";
85
+
86
+ /** 获取当前浏览器支持的语音识别构造函数。 */
87
+ function resolveRecognitionConstructor() {
88
+ if (typeof window === "undefined") return undefined;
89
+ const speechWindow = window as SpeechRecognitionWindow;
90
+ return speechWindow.SpeechRecognition ?? speechWindow.webkitSpeechRecognition;
91
+ }
92
+
93
+ /** 开始一次语音识别。 */
94
+ function start() {
95
+ if (loading.value) return;
96
+ const RecognitionConstructor = resolveRecognitionConstructor();
97
+ if (!RecognitionConstructor) {
98
+ onError?.({ code: -1, message: "Speech recognition is not supported by this browser" });
99
+ return;
100
+ }
101
+
102
+ const nextRecognition = new RecognitionConstructor();
103
+ const currentSession = ++recognitionSession;
104
+ recognition = nextRecognition;
105
+ finalText = "";
106
+ const isCurrentRecognition = () => recognition === nextRecognition && recognitionSession === currentSession;
107
+ nextRecognition.continuous = true;
108
+ nextRecognition.interimResults = true;
109
+ nextRecognition.lang = "zh-CN";
110
+ nextRecognition.onstart = () => {
111
+ if (!isCurrentRecognition()) return;
112
+ loading.value = true;
113
+ value.value = "";
114
+ onStart?.();
115
+ };
116
+ nextRecognition.onresult = event => {
117
+ if (!isCurrentRecognition()) return;
118
+ const accumulated = accumulateSpeechResultText(event.results, event.resultIndex, finalText);
119
+ finalText = accumulated.finalText;
120
+ value.value = accumulated.text;
121
+ onResult?.(value.value);
122
+ };
123
+ nextRecognition.onerror = error => {
124
+ if (!isCurrentRecognition()) return;
125
+ loading.value = false;
126
+ recognition = null;
127
+ onError?.(error);
128
+ };
129
+ nextRecognition.onend = () => {
130
+ if (!isCurrentRecognition()) return;
131
+ loading.value = false;
132
+ recognition = null;
133
+ onEnd?.(value.value);
134
+ };
135
+
136
+ loading.value = true;
137
+ try {
138
+ nextRecognition.start();
139
+ } catch (error) {
140
+ if (!isCurrentRecognition()) return;
141
+ loading.value = false;
142
+ recognition = null;
143
+ onError?.({
144
+ code: -2,
145
+ message: error instanceof Error ? error.message : String(error)
146
+ });
147
+ }
148
+ }
149
+
150
+ /** 停止当前语音识别。 */
151
+ function stop() {
152
+ const currentRecognition = recognition;
153
+ if (!currentRecognition) return;
154
+ recognition = null;
155
+ recognitionSession++;
156
+ loading.value = false;
157
+ onEnd?.(value.value);
158
+ currentRecognition.stop();
159
+ }
160
+
161
+ if (getCurrentInstance()) {
162
+ onBeforeUnmount(() => {
163
+ const currentRecognition = recognition;
164
+ recognition = null;
165
+ recognitionSession++;
166
+ loading.value = false;
167
+ currentRecognition?.stop();
168
+ });
169
+ }
170
+
171
+ return {
172
+ loading,
173
+ start,
174
+ stop,
175
+ value
176
+ };
177
+ }
@@ -11,3 +11,5 @@ export { setAdminDocumentTitle } from "./documentTitle";
11
11
  export { kratosAdminModule } from "./modules/kratosAdmin";
12
12
  export { bootstrapAdminApp } from "./bootstrap";
13
13
  export { refreshAdminRuntimeConfig } from "./stores/modules/config";
14
+ export { useSpeechRecognition } from "./hooks/useSpeechRecognition";
15
+ export type { SpeechRecognitionError } from "./hooks/useSpeechRecognition";
@@ -42,5 +42,15 @@ const headerTools = getAdminHeaderTools();
42
42
  :deep(.el-dropdown) {
43
43
  margin-left: 18px;
44
44
  }
45
+
46
+ @media screen and (width <= 600px) {
47
+ .header-icon > * {
48
+ margin-left: 8px;
49
+ }
50
+
51
+ :deep(.el-dropdown) {
52
+ margin-left: 8px;
53
+ }
54
+ }
45
55
  }
46
56
  </style>
@@ -93,14 +93,23 @@ router.beforeEach(async to => {
93
93
  // 5.如果没有菜单列表,就重新请求菜单列表并添加动态路由
94
94
  if (!authStore.authMenuListGet.length) {
95
95
  await userStore.getUserInfo();
96
- await initDynamicRouter();
96
+ try {
97
+ await initDynamicRouter();
98
+ } catch {
99
+ // 无菜单权限账号保留登录态并引导到 403 页,避免踢回登录页后因会话有效被弹回。
100
+ return { path: "/403", replace: true };
101
+ }
97
102
  if (isUnmatchedRoute(router, to.path)) return getFirstAccessibleRoutePath();
98
103
  return { ...to, replace: true };
99
104
  }
100
105
 
101
106
  // 5.1 菜单已恢复但路由实例尚未重新挂载时,补跑一次动态路由注册,避免刷新或登录首跳直接命中 404。
102
107
  if (isUnmatchedRoute(router, to.path)) {
103
- await initDynamicRouter();
108
+ try {
109
+ await initDynamicRouter();
110
+ } catch {
111
+ return { path: "/403", replace: true };
112
+ }
104
113
  if (isUnmatchedRoute(router, to.path)) return getFirstAccessibleRoutePath();
105
114
  return { ...to, replace: true };
106
115
  }
@@ -129,8 +129,7 @@ export const initDynamicRouter = async () => {
129
129
  type: "warning",
130
130
  duration: 3000
131
131
  });
132
- userStore.clearAuthData();
133
- router.replace(LOGIN_URL);
132
+ // 保留登录态由调用方引导到 403 页,避免清除会话后登录页回跳形成重定向循环。
134
133
  return Promise.reject("No permission");
135
134
  }
136
135
 
@@ -98,7 +98,7 @@
98
98
  }
99
99
  ::-webkit-scrollbar-thumb {
100
100
  background-color: var(--el-border-color-darker);
101
- border-radius: 20px;
101
+ border-radius: var(--admin-page-radius);
102
102
  }
103
103
 
104
104
  /* nprogress */
@@ -1,6 +1,5 @@
1
1
  /* 自定义 element 暗黑模式 */
2
2
  html.dark {
3
- --admin-page-radius: 6px;
4
3
  --admin-page-card-bg: var(--el-bg-color-overlay);
5
4
  --admin-page-card-bg-soft: var(--el-fill-color-light);
6
5
  --admin-page-card-bg-muted: var(--el-fill-color);
@@ -1,7 +1,59 @@
1
1
  /* 统一 Element Plus 矩形控件与后台页面的圆角。 */
2
2
  html:root {
3
3
  --el-border-radius-base: var(--admin-page-radius);
4
- --el-border-radius-small: 2px;
4
+ --el-border-radius-small: var(--admin-page-radius);
5
+ --el-border-radius-round: var(--admin-page-radius);
6
+ --el-popover-border-radius: var(--admin-page-radius);
7
+ }
8
+
9
+ /* 组件局部变量和小尺寸派生值不会继承基础圆角,需要同步覆盖。 */
10
+ :root .el-card {
11
+ --el-card-border-radius: var(--admin-page-radius);
12
+ }
13
+ :root .el-tag {
14
+ --el-tag-border-radius: var(--admin-page-radius);
15
+ --el-tag-border-radius-rounded: var(--admin-page-radius);
16
+ }
17
+ :root .el-popover {
18
+ --el-popover-border-radius: var(--admin-page-radius);
19
+ }
20
+ :root .el-badge {
21
+ --el-badge-radius: var(--admin-page-radius);
22
+ }
23
+ :root .el-pagination {
24
+ --el-pagination-border-radius: var(--admin-page-radius);
25
+ }
26
+ :root .el-button.el-button--small:not(.is-circle),
27
+ :root .el-checkbox.is-bordered.el-checkbox--small,
28
+ :root .el-color-picker__trigger,
29
+ :root .el-image-viewer__actions,
30
+ :root .el-progress-bar__outer,
31
+ :root .el-progress-bar__inner,
32
+ :root .el-scrollbar__bar,
33
+ :root .el-segmented.el-segmented--small,
34
+ :root .el-segmented .el-segmented__item,
35
+ :root .el-segmented .el-segmented__item-selected,
36
+ :root .el-steps--simple,
37
+ :root .el-switch .el-switch__core,
38
+ :root .el-table-filter,
39
+ :root .el-tabs__new-tab,
40
+ :root .el-tabs__item:focus-visible,
41
+ :root .el-upload--picture-card,
42
+ :root .el-upload-dragger,
43
+ :root .el-upload-list .el-upload-list__item {
44
+ border-radius: var(--admin-page-radius);
45
+ }
46
+ :root .el-input::-webkit-scrollbar-thumb {
47
+ border-radius: var(--admin-page-radius);
48
+ }
49
+ :root .el-tabs--card > .el-tabs__header .el-tabs__nav {
50
+ border-radius: var(--admin-page-radius) var(--admin-page-radius) 0 0;
51
+ }
52
+ :root .el-tabs--left.el-tabs--card .el-tabs__nav {
53
+ border-radius: var(--admin-page-radius) 0 0 var(--admin-page-radius);
54
+ }
55
+ :root .el-tabs--right.el-tabs--card .el-tabs__nav {
56
+ border-radius: 0 var(--admin-page-radius) var(--admin-page-radius) 0;
5
57
  }
6
58
 
7
59
  /* el-alert */
@@ -237,6 +237,12 @@ function hasRefreshCookieHint() {
237
237
  return Number.isFinite(expiresAt) && expiresAt * 1000 > Date.now();
238
238
  }
239
239
 
240
+ /** 清除刷新令牌过期提示 Cookie,供会话吊销与刷新失败路径复用,避免守卫误判仍可静默刷新。 */
241
+ function clearRefreshExpiryCookie() {
242
+ if (typeof document === "undefined") return;
243
+ document.cookie = `${REFRESH_EXPIRY_COOKIE}=; Max-Age=0; path=/`;
244
+ }
245
+
240
246
  /** 判断当前访问令牌是否仍在有效期内。 */
241
247
  export function hasValidAccessToken() {
242
248
  const userStore = getUserStore();
@@ -291,30 +297,24 @@ export async function ensureAccessToken() {
291
297
  let isHandlingAuthExpired = false;
292
298
  let passwordChangePromptUntil = 0;
293
299
 
294
- /** 统一处理认证失效 */
300
+ /** 统一处理认证失效:会话已不可用时直接清理本地凭据并回到登录页,避免用户停留在全 401 的空壳页面。 */
295
301
  export function handleAuthExpired() {
296
302
  if (isHandlingAuthExpired) {
297
303
  return;
298
304
  }
299
305
 
300
306
  isHandlingAuthExpired = true;
301
- ElMessageBox.confirm(t("core.auth.session_expired"), t("common.title.notice"), {
302
- confirmButtonText: t("core.auth.login_again"),
303
- cancelButtonText: t("common.action.cancel"),
304
- type: "warning",
305
- closeOnClickModal: false,
306
- closeOnPressEscape: false
307
- })
308
- .then(() => {
309
- const userStore = getUserStore();
310
- const currentRoute = router.currentRoute.value;
311
- const redirect = currentRoute.path === LOGIN_URL ? undefined : currentRoute.fullPath;
312
- clearPasswordChangeRequired();
313
- userStore.clearAuthData();
314
- return router.replace({
315
- path: LOGIN_URL,
316
- query: redirect ? { redirect } : undefined
317
- });
307
+ const userStore = getUserStore();
308
+ const currentRoute = router.currentRoute.value;
309
+ const redirect = currentRoute.path === LOGIN_URL ? undefined : currentRoute.fullPath;
310
+ clearPasswordChangeRequired();
311
+ clearRefreshExpiryCookie();
312
+ userStore.clearAuthData();
313
+ ElMessage.warning(t("core.auth.session_expired"));
314
+ void router
315
+ .replace({
316
+ path: LOGIN_URL,
317
+ query: redirect ? { redirect } : undefined
318
318
  })
319
319
  .finally(() => {
320
320
  isHandlingAuthExpired = false;
@@ -447,6 +447,7 @@ async function handleTokenRefresh(promptOnFailure = true) {
447
447
  .catch(error => {
448
448
  if (isRefreshTokenUnauthorized(error)) {
449
449
  // 刷新令牌失效后立即终止后续自动刷新,避免旧 Cookie 被反复提交。
450
+ clearRefreshExpiryCookie();
450
451
  getUserStore().clearAuthData();
451
452
  }
452
453
  if (promptOnFailure) {
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" size="large">
2
+ <el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" size="large" :validate-on-rule-change="false">
3
3
  <el-form-item v-if="configStore.showTenantCode" prop="tenant_code">
4
4
  <el-input v-model="loginForm.tenant_code" :placeholder="t('core.login.tenant_code')">
5
5
  <template #prefix>
@@ -586,7 +586,13 @@ const finishLogin = async (mustChangePassword = false) => {
586
586
  }
587
587
 
588
588
  // 3.添加动态路由
589
- await initDynamicRouter();
589
+ try {
590
+ await initDynamicRouter();
591
+ } catch {
592
+ // 无菜单权限账号保留登录态并引导到 403 页,不再继续欢迎流程。
593
+ await router.replace("/403");
594
+ return;
595
+ }
590
596
 
591
597
  // 4.清空 tabs、keepAlive 数据
592
598
  tabsStore.setTabs([]);
@@ -1086,7 +1092,7 @@ watch(
1086
1092
  :global(.behavior-captcha-dialog .go-captcha .gc-drag-line) {
1087
1093
  height: 12px;
1088
1094
  margin-top: -6px;
1089
- border-radius: 999px;
1095
+ border-radius: var(--admin-page-radius);
1090
1096
  }
1091
1097
 
1092
1098
  :global(.behavior-captcha-dialog .go-captcha .gc-drag-block) {
@@ -16,11 +16,13 @@ declare global {
16
16
  const Calendar: typeof import('@element-plus/icons-vue').Calendar
17
17
  const Camera: typeof import('@element-plus/icons-vue').Camera
18
18
  const ChatDotRound: typeof import('@element-plus/icons-vue').ChatDotRound
19
+ const Check: typeof import('@element-plus/icons-vue').Check
19
20
  const CircleCheck: typeof import('@element-plus/icons-vue').CircleCheck
20
21
  const CircleCheckFilled: typeof import('@element-plus/icons-vue').CircleCheckFilled
21
22
  const CircleClose: typeof import('@element-plus/icons-vue').CircleClose
22
23
  const CirclePlus: typeof import('@element-plus/icons-vue').CirclePlus
23
24
  const Close: typeof import('@element-plus/icons-vue').Close
25
+ const Coin: typeof import('@element-plus/icons-vue').Coin
24
26
  const CollectionTag: typeof import('@element-plus/icons-vue').CollectionTag
25
27
  const CopyDocument: typeof import('@element-plus/icons-vue').CopyDocument
26
28
  const DataAnalysis: typeof import('@element-plus/icons-vue').DataAnalysis
@@ -46,23 +48,30 @@ declare global {
46
48
  const Fold: typeof import('@element-plus/icons-vue').Fold
47
49
  const Folder: typeof import('@element-plus/icons-vue').Folder
48
50
  const Goods: typeof import('@element-plus/icons-vue').Goods
51
+ const Grid: typeof import('@element-plus/icons-vue').Grid
49
52
  const InfoFilled: typeof import('@element-plus/icons-vue').InfoFilled
50
53
  const List: typeof import('@element-plus/icons-vue').List
54
+ const Loading: typeof import('@element-plus/icons-vue').Loading
51
55
  const Lock: typeof import('@element-plus/icons-vue').Lock
52
56
  const Male: typeof import('@element-plus/icons-vue').Male
53
57
  const Message: typeof import('@element-plus/icons-vue').Message
58
+ const Microphone: typeof import('@element-plus/icons-vue').Microphone
54
59
  const Moon: typeof import('@element-plus/icons-vue').Moon
60
+ const MoreFilled: typeof import('@element-plus/icons-vue').MoreFilled
55
61
  const Operation: typeof import('@element-plus/icons-vue').Operation
56
62
  const Phone: typeof import('@element-plus/icons-vue').Phone
57
63
  const Plus: typeof import('@element-plus/icons-vue').Plus
58
64
  const Position: typeof import('@element-plus/icons-vue').Position
59
65
  const Promotion: typeof import('@element-plus/icons-vue').Promotion
60
66
  const QuestionFilled: typeof import('@element-plus/icons-vue').QuestionFilled
67
+ const Rank: typeof import('@element-plus/icons-vue').Rank
61
68
  const Refresh: typeof import('@element-plus/icons-vue').Refresh
62
69
  const RefreshLeft: typeof import('@element-plus/icons-vue').RefreshLeft
63
70
  const RefreshRight: typeof import('@element-plus/icons-vue').RefreshRight
64
71
  const Search: typeof import('@element-plus/icons-vue').Search
65
72
  const Setting: typeof import('@element-plus/icons-vue').Setting
73
+ const Star: typeof import('@element-plus/icons-vue').Star
74
+ const StarFilled: typeof import('@element-plus/icons-vue').StarFilled
66
75
  const Sunny: typeof import('@element-plus/icons-vue').Sunny
67
76
  const Switch: typeof import('@element-plus/icons-vue').Switch
68
77
  const SwitchButton: typeof import('@element-plus/icons-vue').SwitchButton
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@liujitcn/kratos-admin-core",
3
3
  "private": false,
4
- "version": "0.1.4",
4
+ "version": "0.1.6",
5
5
  "type": "module",
6
6
  "description": "kratos-admin frontend runtime and base modules",
7
7
  "author": {
@@ -263,7 +263,7 @@
263
263
  "scripts": {
264
264
  "build": "pnpm build:package",
265
265
  "build:package": "node ../../scripts/build-package.mjs .",
266
- "test": "tsc -p tsconfig.test.json && node --test dist/test/tests/modules.test.js dist/test/tests/dict.test.js dist/test/tests/menu.test.js dist/test/tests/source-patterns.test.js dist/test/tests/codegen-hmr.test.js dist/test/tests/dialog-request.test.js dist/test/tests/request-error.test.js dist/test/tests/locale-reactivity.test.js",
266
+ "test": "tsc -p tsconfig.test.json && node --test dist/test/tests/modules.test.js dist/test/tests/dict.test.js dist/test/tests/menu.test.js dist/test/tests/source-patterns.test.js dist/test/tests/codegen-hmr.test.js dist/test/tests/dialog-request.test.js dist/test/tests/request-error.test.js dist/test/tests/locale-reactivity.test.js dist/test/tests/voice-input.test.js",
267
267
  "type:check": "vue-tsc --noEmit --skipLibCheck"
268
268
  }
269
269
  }
@@ -16,11 +16,13 @@ declare global {
16
16
  const Calendar: typeof import('@element-plus/icons-vue').Calendar
17
17
  const Camera: typeof import('@element-plus/icons-vue').Camera
18
18
  const ChatDotRound: typeof import('@element-plus/icons-vue').ChatDotRound
19
+ const Check: typeof import('@element-plus/icons-vue').Check
19
20
  const CircleCheck: typeof import('@element-plus/icons-vue').CircleCheck
20
21
  const CircleCheckFilled: typeof import('@element-plus/icons-vue').CircleCheckFilled
21
22
  const CircleClose: typeof import('@element-plus/icons-vue').CircleClose
22
23
  const CirclePlus: typeof import('@element-plus/icons-vue').CirclePlus
23
24
  const Close: typeof import('@element-plus/icons-vue').Close
25
+ const Coin: typeof import('@element-plus/icons-vue').Coin
24
26
  const CollectionTag: typeof import('@element-plus/icons-vue').CollectionTag
25
27
  const CopyDocument: typeof import('@element-plus/icons-vue').CopyDocument
26
28
  const DataAnalysis: typeof import('@element-plus/icons-vue').DataAnalysis
@@ -46,23 +48,30 @@ declare global {
46
48
  const Fold: typeof import('@element-plus/icons-vue').Fold
47
49
  const Folder: typeof import('@element-plus/icons-vue').Folder
48
50
  const Goods: typeof import('@element-plus/icons-vue').Goods
51
+ const Grid: typeof import('@element-plus/icons-vue').Grid
49
52
  const InfoFilled: typeof import('@element-plus/icons-vue').InfoFilled
50
53
  const List: typeof import('@element-plus/icons-vue').List
54
+ const Loading: typeof import('@element-plus/icons-vue').Loading
51
55
  const Lock: typeof import('@element-plus/icons-vue').Lock
52
56
  const Male: typeof import('@element-plus/icons-vue').Male
53
57
  const Message: typeof import('@element-plus/icons-vue').Message
58
+ const Microphone: typeof import('@element-plus/icons-vue').Microphone
54
59
  const Moon: typeof import('@element-plus/icons-vue').Moon
60
+ const MoreFilled: typeof import('@element-plus/icons-vue').MoreFilled
55
61
  const Operation: typeof import('@element-plus/icons-vue').Operation
56
62
  const Phone: typeof import('@element-plus/icons-vue').Phone
57
63
  const Plus: typeof import('@element-plus/icons-vue').Plus
58
64
  const Position: typeof import('@element-plus/icons-vue').Position
59
65
  const Promotion: typeof import('@element-plus/icons-vue').Promotion
60
66
  const QuestionFilled: typeof import('@element-plus/icons-vue').QuestionFilled
67
+ const Rank: typeof import('@element-plus/icons-vue').Rank
61
68
  const Refresh: typeof import('@element-plus/icons-vue').Refresh
62
69
  const RefreshLeft: typeof import('@element-plus/icons-vue').RefreshLeft
63
70
  const RefreshRight: typeof import('@element-plus/icons-vue').RefreshRight
64
71
  const Search: typeof import('@element-plus/icons-vue').Search
65
72
  const Setting: typeof import('@element-plus/icons-vue').Setting
73
+ const Star: typeof import('@element-plus/icons-vue').Star
74
+ const StarFilled: typeof import('@element-plus/icons-vue').StarFilled
66
75
  const Sunny: typeof import('@element-plus/icons-vue').Sunny
67
76
  const Switch: typeof import('@element-plus/icons-vue').Switch
68
77
  const SwitchButton: typeof import('@element-plus/icons-vue').SwitchButton