@liujitcn/kratos-admin-core 0.0.54 → 0.0.56

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.
Files changed (38) hide show
  1. package/dist/package/declarations/src/api/base/v1/config.d.ts +3 -1
  2. package/dist/package/declarations/src/api/base/v1/file.d.ts +3 -3
  3. package/dist/package/declarations/src/components/Upload/File.vue.d.ts +3 -1
  4. package/dist/package/declarations/src/components/Upload/Files.vue.d.ts +3 -1
  5. package/dist/package/declarations/src/components/Upload/Img.vue.d.ts +4 -2
  6. package/dist/package/declarations/src/components/Upload/Imgs.vue.d.ts +3 -1
  7. package/dist/package/declarations/src/hooks/useImageUrl.d.ts +8 -0
  8. package/dist/package/declarations/src/hooks/useLogoUrl.d.ts +8 -0
  9. package/dist/package/declarations/src/rpc/base/v1/config.d.ts +11 -1
  10. package/dist/package/declarations/src/rpc/base/v1/file.d.ts +11 -0
  11. package/dist/package/declarations/src/stores/modules/config.d.ts +8 -0
  12. package/dist/package/declarations/src/utils/tenant.d.ts +6 -1
  13. package/dist/package/src/api/base/v1/config.ts +16 -1
  14. package/dist/package/src/api/base/v1/file.ts +5 -3
  15. package/dist/package/src/assets/images/logo.svg +13 -1
  16. package/dist/package/src/bootstrap.ts +14 -1
  17. package/dist/package/src/components/Upload/File.vue +5 -3
  18. package/dist/package/src/components/Upload/Files.vue +5 -3
  19. package/dist/package/src/components/Upload/Img.vue +5 -3
  20. package/dist/package/src/components/Upload/Imgs.vue +5 -3
  21. package/dist/package/src/hooks/useImageUrl.ts +25 -0
  22. package/dist/package/src/hooks/useLogoUrl.ts +11 -0
  23. package/dist/package/src/layouts/LayoutClassic/index.vue +9 -2
  24. package/dist/package/src/layouts/LayoutColumns/index.vue +9 -2
  25. package/dist/package/src/layouts/LayoutTransverse/index.vue +9 -2
  26. package/dist/package/src/layouts/LayoutVertical/index.vue +4 -1
  27. package/dist/package/src/locales/README.md +1 -1
  28. package/dist/package/src/rpc/base/v1/config.ts +13 -1
  29. package/dist/package/src/rpc/base/v1/file.ts +12 -0
  30. package/dist/package/src/stores/modules/config.ts +17 -2
  31. package/dist/package/src/stores/modules/user.ts +2 -0
  32. package/dist/package/src/tenant.ts +12 -4
  33. package/dist/package/src/utils/errorHandler.ts +2 -0
  34. package/dist/package/src/utils/request.ts +39 -4
  35. package/dist/package/src/utils/tenant.ts +34 -2
  36. package/dist/package/src/views/login/components/LoginForm.vue +5 -0
  37. package/dist/package/src/views/login/index.vue +24 -4
  38. package/package.json +2 -2
@@ -1,7 +1,9 @@
1
- import type { ConfigService, GetConfigRequest, GetConfigResponse } from "../../../rpc/base/v1/config";
1
+ import type { ConfigService, GetConfigRequest, GetConfigResponse, GetI18nCustomRequest, GetI18nCustomResponse } from "../../../rpc/base/v1/config";
2
2
  /** 系统配置公共服务 */
3
3
  export declare class ConfigServiceImpl implements ConfigService {
4
4
  /** 获取系统配置 */
5
5
  GetConfig(request: GetConfigRequest): Promise<GetConfigResponse>;
6
+ /** 获取当前租户的自定义国际化覆盖项。 */
7
+ GetI18nCustom(request: GetI18nCustomRequest): Promise<GetI18nCustomResponse>;
6
8
  }
7
9
  export declare const defConfigService: ConfigServiceImpl;
@@ -1,10 +1,10 @@
1
- import type { MultiUploadFileResponse, FileInfo } from "../../../rpc/base/v1/file";
1
+ import { BaseFileAccessMode, type MultiUploadFileResponse, type FileInfo } from "../../../rpc/base/v1/file";
2
2
  /** 文件服务 */
3
3
  export declare class FileServiceImpl {
4
4
  /** 多个文件上传 */
5
- MultiUploadFile(files: File[], fileType: string): Promise<MultiUploadFileResponse>;
5
+ MultiUploadFile(files: File[], fileType: string, accessMode?: BaseFileAccessMode): Promise<MultiUploadFileResponse>;
6
6
  /** 上传文件 */
7
- UploadFile(file: File, fileType: string): Promise<FileInfo>;
7
+ UploadFile(file: File, fileType: string, accessMode?: BaseFileAccessMode): Promise<FileInfo>;
8
8
  /** 读取文件内容并返回 Blob。 */
9
9
  GetFileBlob(file: string, fileName: string): Promise<Blob>;
10
10
  /** 下载文件 */
@@ -1,5 +1,5 @@
1
1
  import type { UploadUserFile } from "element-plus";
2
- import type { FileInfo } from "../../rpc/base/v1/file";
2
+ import { BaseFileAccessMode, type FileInfo } from "../../rpc/base/v1/file";
3
3
  /** 单文件上传组件属性。 */
4
4
  interface UploadFileProps {
5
5
  fileInfo?: UploadUserFile;
@@ -8,6 +8,7 @@ interface UploadFileProps {
8
8
  fileSize?: number;
9
9
  fileType?: string[];
10
10
  uploadType?: string;
11
+ accessMode?: BaseFileAccessMode;
11
12
  }
12
13
  declare const __VLS_export: import("vue").DefineComponent<UploadFileProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
13
14
  "update:fileInfo": (value: UploadUserFile | undefined) => any;
@@ -16,6 +17,7 @@ declare const __VLS_export: import("vue").DefineComponent<UploadFileProps, {}, {
16
17
  }>, {
17
18
  disabled: boolean;
18
19
  fileType: string[];
20
+ accessMode: BaseFileAccessMode;
19
21
  uploadType: string;
20
22
  fileInfo: UploadUserFile;
21
23
  fileSize: number;
@@ -1,5 +1,5 @@
1
1
  import type { UploadUserFile } from "element-plus";
2
- import type { FileInfo } from "../../rpc/base/v1/file";
2
+ import { BaseFileAccessMode, type FileInfo } from "../../rpc/base/v1/file";
3
3
  /** 多文件上传组件属性。 */
4
4
  interface UploadFilesProps {
5
5
  fileList: UploadUserFile[];
@@ -9,6 +9,7 @@ interface UploadFilesProps {
9
9
  fileSize?: number;
10
10
  fileType?: string[];
11
11
  uploadType?: string;
12
+ accessMode?: BaseFileAccessMode;
12
13
  }
13
14
  declare const __VLS_export: import("vue").DefineComponent<UploadFilesProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
14
15
  "update:fileList": (value: UploadUserFile[]) => any;
@@ -17,6 +18,7 @@ declare const __VLS_export: import("vue").DefineComponent<UploadFilesProps, {},
17
18
  }>, {
18
19
  disabled: boolean;
19
20
  fileType: string[];
21
+ accessMode: BaseFileAccessMode;
20
22
  uploadType: string;
21
23
  fileSize: number;
22
24
  fileList: UploadUserFile[];
@@ -1,9 +1,10 @@
1
- import type { FileInfo } from "../../rpc/base/v1/file";
1
+ import { BaseFileAccessMode, type FileInfo } from "../../rpc/base/v1/file";
2
2
  /** 单图上传组件属性。 */
3
3
  interface UploadFileProps {
4
4
  imageUrl: string;
5
5
  api?: (file: File) => Promise<FileInfo>;
6
6
  uploadType?: string;
7
+ accessMode?: BaseFileAccessMode;
7
8
  drag?: boolean;
8
9
  disabled?: boolean;
9
10
  fileSize?: number;
@@ -27,10 +28,11 @@ declare const __VLS_base: import("vue").DefineComponent<UploadFileProps, {}, {},
27
28
  width: string;
28
29
  height: string;
29
30
  drag: boolean;
31
+ imageUrl: string;
30
32
  fileType: File.ImageMimeType[];
33
+ accessMode: BaseFileAccessMode;
31
34
  uploadType: string;
32
35
  fileSize: number;
33
- imageUrl: string;
34
36
  borderRadius: string;
35
37
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
36
38
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
@@ -1,10 +1,11 @@
1
1
  import type { UploadUserFile } from "element-plus";
2
- import type { FileInfo } from "../../rpc/base/v1/file";
2
+ import { BaseFileAccessMode, type FileInfo } from "../../rpc/base/v1/file";
3
3
  /** 多图上传组件属性。 */
4
4
  interface UploadFileProps {
5
5
  fileList: UploadUserFile[];
6
6
  api?: (file: File) => Promise<FileInfo>;
7
7
  uploadType?: string;
8
+ accessMode?: BaseFileAccessMode;
8
9
  drag?: boolean;
9
10
  disabled?: boolean;
10
11
  limit?: number;
@@ -30,6 +31,7 @@ declare const __VLS_base: import("vue").DefineComponent<UploadFileProps, {}, {},
30
31
  height: string;
31
32
  drag: boolean;
32
33
  fileType: File.ImageMimeType[];
34
+ accessMode: BaseFileAccessMode;
33
35
  uploadType: string;
34
36
  fileSize: number;
35
37
  fileList: UploadUserFile[];
@@ -0,0 +1,8 @@
1
+ import { type Ref } from "vue";
2
+ /**
3
+ * 管理图片地址,并在配置地址为空或加载失败时回退到指定的本地资源。
4
+ */
5
+ export declare function useImageUrl(configuredImageUrl: Readonly<Ref<string>>, fallbackImageUrl: string): {
6
+ imageUrl: Ref<string, string>;
7
+ handleImageError: () => void;
8
+ };
@@ -0,0 +1,8 @@
1
+ import type { Ref } from "vue";
2
+ /**
3
+ * 管理站点 Logo 地址,并在配置地址不可用时回退到本地默认 Logo。
4
+ */
5
+ export declare function useLogoUrl(configuredLogoUrl: Readonly<Ref<string>>): {
6
+ logoUrl: Ref<string, string>;
7
+ handleLogoError: () => void;
8
+ };
@@ -29,8 +29,16 @@ export interface GetConfigResponse {
29
29
  configs: ConfigItem[];
30
30
  /** AI 助手是否可用 */
31
31
  ai_enabled: boolean;
32
+ }
33
+ /** 当前租户自定义国际化覆盖项查询条件。 */
34
+ export interface GetI18nCustomRequest {
35
+ /** 位置:枚举【BaseConfigSite】 */
36
+ site: BaseConfigSite;
37
+ }
38
+ /** 当前租户自定义国际化覆盖项查询结果。 */
39
+ export interface GetI18nCustomResponse {
32
40
  /** 全部语言的自定义国际化覆盖项 */
33
- i18n_customs: I18nCustomItem[];
41
+ items: I18nCustomItem[];
34
42
  }
35
43
  /** 全部语言的自定义国际化覆盖项。 */
36
44
  export interface I18nCustomItem {
@@ -45,4 +53,6 @@ export interface I18nCustomItem {
45
53
  export interface ConfigService {
46
54
  /** 获取系统配置 */
47
55
  GetConfig(request: GetConfigRequest): Promise<GetConfigResponse>;
56
+ /** 获取当前租户的自定义国际化覆盖项 */
57
+ GetI18nCustom(request: GetI18nCustomRequest): Promise<GetI18nCustomResponse>;
48
58
  }
@@ -1,4 +1,13 @@
1
1
  import type { BytesValue } from "../../google/protobuf/wrappers";
2
+ /** 文件访问方式。 */
3
+ export declare enum BaseFileAccessMode {
4
+ /** BASE_FILE_ACCESS_MODE_UNSPECIFIED - 未指定文件访问方式,服务端按授权访问处理。 */
5
+ BASE_FILE_ACCESS_MODE_UNSPECIFIED = 0,
6
+ /** BASE_FILE_ACCESS_MODE_PUBLIC - 允许匿名公开访问。 */
7
+ BASE_FILE_ACCESS_MODE_PUBLIC = 1,
8
+ /** BASE_FILE_ACCESS_MODE_AUTHORIZED - 必须携带有效访问令牌。 */
9
+ BASE_FILE_ACCESS_MODE_AUTHORIZED = 2
10
+ }
2
11
  /** 批量上传文件请求参数 */
3
12
  export interface MultiUploadFileRequest {
4
13
  /** 文件名 */
@@ -24,6 +33,8 @@ export interface UploadFileInfo {
24
33
  path: string;
25
34
  /** 二进制内容将通过 base64 编码传输 */
26
35
  content: Uint8Array;
36
+ /** 文件访问方式,未指定时默认需要访问令牌 */
37
+ access_mode: BaseFileAccessMode;
27
38
  }
28
39
  /** 下载文件请求参数 */
29
40
  export interface DownloadFileRequest {
@@ -23,6 +23,14 @@ export declare const useConfigStore: import("pinia").StoreDefinition<"admin-conf
23
23
  adminLogo: string;
24
24
  background: string;
25
25
  }>;
26
+ /**
27
+ * 登录成功后加载当前租户的自定义国际化覆盖项。
28
+ */
29
+ loadI18nCustom(): Promise<void>;
30
+ /**
31
+ * 清空租户自定义翻译并恢复本地默认文案。
32
+ */
33
+ resetI18nCustom(): void;
26
34
  }>;
27
35
  /** 刷新当前管理端语言和站点运行配置。 */
28
36
  export declare function refreshAdminRuntimeConfig(): Promise<void>;
@@ -1,6 +1,11 @@
1
+ import type { SelectOptionResponse_Option } from "../rpc/common/v1/common";
1
2
  /** 默认租户编码。 */
2
3
  export declare const DEFAULT_TENANT_CODE = "0000";
4
+ /** 读取共享租户选项,同一时间只发起一次请求。 */
5
+ export declare function loadSharedTenantOptions(): Promise<SelectOptionResponse_Option[]>;
6
+ /** 清除共享租户选项,租户数据变更后由下一次读取重新加载。 */
7
+ export declare function invalidateTenantOptions(): void;
3
8
  /** 读取租户列表筛选选项。 */
4
9
  export declare function requestTenantOptions(): Promise<{
5
- data: import("../rpc/common/v1/common").SelectOptionResponse_Option[];
10
+ data: SelectOptionResponse_Option[];
6
11
  }>;
@@ -1,5 +1,11 @@
1
1
  import service from "../../../utils/request";
2
- import type { ConfigService, GetConfigRequest, GetConfigResponse } from "../../../rpc/base/v1/config";
2
+ import type {
3
+ ConfigService,
4
+ GetConfigRequest,
5
+ GetConfigResponse,
6
+ GetI18nCustomRequest,
7
+ GetI18nCustomResponse
8
+ } from "../../../rpc/base/v1/config";
3
9
 
4
10
  const CONFIG_URL = "/v1/base/config";
5
11
 
@@ -14,6 +20,15 @@ export class ConfigServiceImpl implements ConfigService {
14
20
  headers: { Authorization: "no-auth" }
15
21
  });
16
22
  }
23
+
24
+ /** 获取当前租户的自定义国际化覆盖项。 */
25
+ GetI18nCustom(request: GetI18nCustomRequest): Promise<GetI18nCustomResponse> {
26
+ return service<GetI18nCustomRequest, GetI18nCustomResponse>({
27
+ url: `${CONFIG_URL}/i18n-custom`,
28
+ method: "get",
29
+ params: request
30
+ });
31
+ }
17
32
  }
18
33
 
19
34
  export const defConfigService = new ConfigServiceImpl();
@@ -1,17 +1,18 @@
1
1
  import service from "../../../utils/request";
2
- import type { DownloadFileRequest, MultiUploadFileResponse, FileInfo } from "../../../rpc/base/v1/file";
2
+ import { BaseFileAccessMode, type DownloadFileRequest, type MultiUploadFileResponse, type FileInfo } from "../../../rpc/base/v1/file";
3
3
 
4
4
  const FILE_URL = "/v1/base/file";
5
5
 
6
6
  /** 文件服务 */
7
7
  export class FileServiceImpl {
8
8
  /** 多个文件上传 */
9
- MultiUploadFile(files: File[], fileType: string): Promise<MultiUploadFileResponse> {
9
+ MultiUploadFile(files: File[], fileType: string, accessMode = BaseFileAccessMode.BASE_FILE_ACCESS_MODE_AUTHORIZED): Promise<MultiUploadFileResponse> {
10
10
  const formData = new FormData();
11
11
  files.map(file => {
12
12
  formData.append(file.name, file);
13
13
  });
14
14
  formData.append("fileType", fileType);
15
+ formData.append("accessMode", String(accessMode));
15
16
  return service<FormData, MultiUploadFileResponse>({
16
17
  url: `${FILE_URL}/multi`,
17
18
  method: "post",
@@ -22,10 +23,11 @@ export class FileServiceImpl {
22
23
  });
23
24
  }
24
25
  /** 上传文件 */
25
- UploadFile(file: File, fileType: string): Promise<FileInfo> {
26
+ UploadFile(file: File, fileType: string, accessMode = BaseFileAccessMode.BASE_FILE_ACCESS_MODE_AUTHORIZED): Promise<FileInfo> {
26
27
  const formData = new FormData();
27
28
  formData.append("file", file);
28
29
  formData.append("fileType", fileType);
30
+ formData.append("accessMode", String(accessMode));
29
31
  return service<FormData, FileInfo>({
30
32
  url: `${FILE_URL}`,
31
33
  method: "post",
@@ -1 +1,13 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 56" role="img" aria-label="Admin logo">
2
+ <rect x="3" y="8" width="54" height="43" rx="7" fill="#eef6ff" stroke="#244987" stroke-width="4"/>
3
+ <rect x="11" y="17" width="22" height="24" rx="4" fill="#fff"/>
4
+ <path d="M17 22a5 5 0 1 0 5 5" fill="none" stroke="#ffd15c" stroke-width="4"/>
5
+ <path d="M22 22a5 5 0 0 1 5 5" fill="none" stroke="#ffbd00" stroke-width="4"/>
6
+ <rect x="17" y="34" width="3" height="4" rx="1" fill="#8679e8"/>
7
+ <rect x="22" y="31" width="3" height="7" rx="1" fill="#6f5bd9"/>
8
+ <rect x="27" y="28" width="3" height="10" rx="1" fill="#5540cf"/>
9
+ <path d="M39 23h10M39 29h10M39 35h7" fill="none" stroke="#8a7dea" stroke-width="3" stroke-linecap="round"/>
10
+ <circle cx="52" cy="10" r="10" fill="#c9f8e4"/>
11
+ <circle cx="52" cy="10" r="7" fill="#56dba0"/>
12
+ <path d="m48.5 10 2.5 2.5 4.5-5" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
13
+ </svg>
@@ -30,6 +30,9 @@ import { useUserStore } from "./stores/modules/user";
30
30
  import { getRouteMetaTitle } from "./utils";
31
31
  import { defLanguageService } from "./api/base/v1/language";
32
32
  import { setAdminDocumentTitle } from "./documentTitle";
33
+ import defaultLogoUrl from "./assets/images/logo.svg";
34
+ import defaultLoginBackgroundUrl from "./assets/images/login_bg.svg";
35
+ import defaultLoginIllustrationUrl from "./assets/images/login_left.png";
33
36
 
34
37
  /**
35
38
  * 管理端启动参数。
@@ -49,6 +52,15 @@ export async function bootstrapAdminApp(options: AdminBootstrapOptions = {}) {
49
52
  registerAdminModules(modules);
50
53
  registerLocaleMessages(modules);
51
54
  initializeLocale();
55
+
56
+ // 登录页图片与启动接口并行加载,并在首次挂载前完成解码,避免页面先于图片绘制。
57
+ const loginImagePreloads = [defaultLogoUrl, defaultLoginBackgroundUrl, defaultLoginIllustrationUrl].map(
58
+ imageUrl => {
59
+ const image = new Image();
60
+ image.src = imageUrl;
61
+ return image.decode();
62
+ }
63
+ );
52
64
  try {
53
65
  applyLanguageConfig(await defLanguageService.OptionLanguage({}));
54
66
  } catch {
@@ -82,6 +94,7 @@ export async function bootstrapAdminApp(options: AdminBootstrapOptions = {}) {
82
94
  }
83
95
  updateDocumentTitle();
84
96
 
97
+ await Promise.allSettled(loginImagePreloads);
85
98
  app.mount(options.mount ?? "#app");
86
99
  return app;
87
100
  }
@@ -92,7 +105,7 @@ async function refreshLocalizedRuntimeData() {
92
105
  const authStore = useAuthStore(pinia);
93
106
  const dictStore = useDictStore(pinia);
94
107
  const configStore = useConfigStore(pinia);
95
- await Promise.allSettled([configStore.loadDisplayConfig()]);
108
+ await Promise.allSettled([configStore.loadDisplayConfig(), ...(userStore.token ? [configStore.loadI18nCustom()] : [])]);
96
109
  if (userStore.token) {
97
110
  await Promise.allSettled([authStore.getAuthMenuList(), dictStore.updateDictionaryCache()]);
98
111
  syncLocalizedRouteState();
@@ -39,7 +39,7 @@ import { Delete, Document, Download } from "@element-plus/icons-vue";
39
39
  import { ElNotification, formContextKey, formItemContextKey } from "element-plus";
40
40
  import type { UploadProps, UploadRequestOptions, UploadUserFile } from "element-plus";
41
41
  import { defFileService } from "../../api/base/v1/file";
42
- import type { FileInfo } from "../../rpc/base/v1/file";
42
+ import { BaseFileAccessMode, type FileInfo } from "../../rpc/base/v1/file";
43
43
  import { useLocaleStore } from "../../locales";
44
44
 
45
45
  const { t } = useLocaleStore();
@@ -52,6 +52,7 @@ interface UploadFileProps {
52
52
  fileSize?: number;
53
53
  fileType?: string[];
54
54
  uploadType?: string;
55
+ accessMode?: BaseFileAccessMode; // 文件访问方式,默认需要访问令牌。
55
56
  }
56
57
 
57
58
  const props = withDefaults(defineProps<UploadFileProps>(), {
@@ -59,7 +60,8 @@ const props = withDefaults(defineProps<UploadFileProps>(), {
59
60
  disabled: false,
60
61
  fileSize: 20,
61
62
  fileType: () => [],
62
- uploadType: "file"
63
+ uploadType: "file",
64
+ accessMode: BaseFileAccessMode.BASE_FILE_ACCESS_MODE_AUTHORIZED
63
65
  });
64
66
 
65
67
  const emit = defineEmits<{
@@ -113,7 +115,7 @@ const beforeUpload: UploadProps["beforeUpload"] = rawFile => {
113
115
  /** 执行自定义文件上传。 */
114
116
  const handleHttpUpload = async (options: UploadRequestOptions) => {
115
117
  try {
116
- const api = props.api ?? (file => defFileService.UploadFile(file, props.uploadType));
118
+ const api = props.api ?? (file => defFileService.UploadFile(file, props.uploadType, props.accessMode));
117
119
  const data = await api(options.file);
118
120
  options.onSuccess(data);
119
121
  } catch (error) {
@@ -45,7 +45,7 @@ import { Delete, Document, Download } from "@element-plus/icons-vue";
45
45
  import { ElNotification, formContextKey, formItemContextKey } from "element-plus";
46
46
  import type { UploadProps, UploadRequestOptions, UploadUserFile } from "element-plus";
47
47
  import { defFileService } from "../../api/base/v1/file";
48
- import type { FileInfo } from "../../rpc/base/v1/file";
48
+ import { BaseFileAccessMode, type FileInfo } from "../../rpc/base/v1/file";
49
49
  import { useLocaleStore } from "../../locales";
50
50
 
51
51
  const { t } = useLocaleStore();
@@ -59,6 +59,7 @@ interface UploadFilesProps {
59
59
  fileSize?: number;
60
60
  fileType?: string[];
61
61
  uploadType?: string;
62
+ accessMode?: BaseFileAccessMode; // 文件访问方式,默认需要访问令牌。
62
63
  }
63
64
 
64
65
  const props = withDefaults(defineProps<UploadFilesProps>(), {
@@ -67,7 +68,8 @@ const props = withDefaults(defineProps<UploadFilesProps>(), {
67
68
  limit: 5,
68
69
  fileSize: 20,
69
70
  fileType: () => [],
70
- uploadType: "file"
71
+ uploadType: "file",
72
+ accessMode: BaseFileAccessMode.BASE_FILE_ACCESS_MODE_AUTHORIZED
71
73
  });
72
74
 
73
75
  const emit = defineEmits<{
@@ -129,7 +131,7 @@ const beforeUpload: UploadProps["beforeUpload"] = rawFile => {
129
131
  /** 执行多文件自定义上传。 */
130
132
  const handleHttpUpload = async (options: UploadRequestOptions) => {
131
133
  try {
132
- const api = props.api ?? (file => defFileService.UploadFile(file, props.uploadType));
134
+ const api = props.api ?? (file => defFileService.UploadFile(file, props.uploadType, props.accessMode));
133
135
  const data = await api(options.file);
134
136
  options.onSuccess(data);
135
137
  } catch (error) {
@@ -54,7 +54,7 @@ import { formatSrc } from "../../utils/utils";
54
54
  import { defFileService } from "../../api/base/v1/file";
55
55
  import { ElNotification, formContextKey, formItemContextKey } from "element-plus";
56
56
  import type { UploadProps, UploadRequestOptions } from "element-plus";
57
- import type { FileInfo } from "../../rpc/base/v1/file";
57
+ import { BaseFileAccessMode, type FileInfo } from "../../rpc/base/v1/file";
58
58
  import { useLocaleStore } from "../../locales";
59
59
 
60
60
  const { t } = useLocaleStore();
@@ -63,7 +63,8 @@ const { t } = useLocaleStore();
63
63
  interface UploadFileProps {
64
64
  imageUrl: string; // 图片地址 ==> 必传
65
65
  api?: (file: File) => Promise<FileInfo>; // 上传图片的 api 方法,默认使用当前文件服务 ==> 非必传
66
- uploadType?: string; // 上传文件业务类型 ==> 非必传(默认为 image)
66
+ uploadType?: string;
67
+ accessMode?: BaseFileAccessMode; // 文件访问方式,默认需要访问令牌。
67
68
  drag?: boolean; // 是否支持拖拽上传 ==> 非必传(默认为 true)
68
69
  disabled?: boolean; // 是否禁用上传组件 ==> 非必传(默认为 false)
69
70
  fileSize?: number; // 图片大小限制 ==> 非必传(默认为 5M)
@@ -77,6 +78,7 @@ interface UploadFileProps {
77
78
  const props = withDefaults(defineProps<UploadFileProps>(), {
78
79
  imageUrl: "",
79
80
  uploadType: "image",
81
+ accessMode: BaseFileAccessMode.BASE_FILE_ACCESS_MODE_AUTHORIZED,
80
82
  drag: true,
81
83
  disabled: false,
82
84
  fileSize: 5,
@@ -112,7 +114,7 @@ const emit = defineEmits<{
112
114
  const handleHttpUpload = async (options: UploadRequestOptions) => {
113
115
  try {
114
116
  // 优先使用页面显式传入的业务上传类型,避免不同业务图片都落到同一个 image 分类。
115
- const api = props.api ?? (file => defFileService.UploadFile(file, props.uploadType));
117
+ const api = props.api ?? (file => defFileService.UploadFile(file, props.uploadType, props.accessMode));
116
118
  const data = await api(options.file);
117
119
  emit("update:imageUrl", data.url);
118
120
  // 调用 el-form 内部的校验方法(可自动校验)
@@ -50,7 +50,7 @@ import { defFileService } from "../../api/base/v1/file";
50
50
  import { formatSrc } from "../../utils/utils";
51
51
  import type { UploadProps, UploadFile, UploadUserFile, UploadRequestOptions } from "element-plus";
52
52
  import { ElNotification, formContextKey, formItemContextKey } from "element-plus";
53
- import type { FileInfo } from "../../rpc/base/v1/file";
53
+ import { BaseFileAccessMode, type FileInfo } from "../../rpc/base/v1/file";
54
54
  import { useLocaleStore } from "../../locales";
55
55
 
56
56
  const { t } = useLocaleStore();
@@ -59,7 +59,8 @@ const { t } = useLocaleStore();
59
59
  interface UploadFileProps {
60
60
  fileList: UploadUserFile[];
61
61
  api?: (file: File) => Promise<FileInfo>; // 上传图片的 api 方法,默认使用当前文件服务 ==> 非必传
62
- uploadType?: string; // 上传文件业务类型 ==> 非必传(默认为 image)
62
+ uploadType?: string;
63
+ accessMode?: BaseFileAccessMode; // 文件访问方式,默认需要访问令牌。
63
64
  drag?: boolean; // 是否支持拖拽上传 ==> 非必传(默认为 true)
64
65
  disabled?: boolean; // 是否禁用上传组件 ==> 非必传(默认为 false)
65
66
  limit?: number; // 最大图片上传数 ==> 非必传(默认为 5张)
@@ -73,6 +74,7 @@ interface UploadFileProps {
73
74
  const props = withDefaults(defineProps<UploadFileProps>(), {
74
75
  fileList: () => [],
75
76
  uploadType: "image",
77
+ accessMode: BaseFileAccessMode.BASE_FILE_ACCESS_MODE_AUTHORIZED,
76
78
  drag: true,
77
79
  disabled: false,
78
80
  limit: 5,
@@ -133,7 +135,7 @@ const beforeUpload: UploadProps["beforeUpload"] = rawFile => {
133
135
  const handleHttpUpload = async (options: UploadRequestOptions) => {
134
136
  try {
135
137
  // 优先使用页面显式传入的业务上传类型,避免不同业务图片都落到同一个 image 分类。
136
- const api = props.api ?? (file => defFileService.UploadFile(file, props.uploadType));
138
+ const api = props.api ?? (file => defFileService.UploadFile(file, props.uploadType, props.accessMode));
137
139
  const data = await api(options.file);
138
140
  options.onSuccess(data);
139
141
  } catch (error) {
@@ -0,0 +1,25 @@
1
+ import { ref, watch, type Ref } from "vue";
2
+
3
+ /**
4
+ * 管理图片地址,并在配置地址为空或加载失败时回退到指定的本地资源。
5
+ */
6
+ export function useImageUrl(configuredImageUrl: Readonly<Ref<string>>, fallbackImageUrl: string) {
7
+ const imageUrl = ref(fallbackImageUrl);
8
+
9
+ watch(
10
+ configuredImageUrl,
11
+ value => {
12
+ imageUrl.value = value || fallbackImageUrl;
13
+ },
14
+ { immediate: true }
15
+ );
16
+
17
+ /** 图片加载失败时回退到指定的本地资源。 */
18
+ const handleImageError = () => {
19
+ if (imageUrl.value !== fallbackImageUrl) {
20
+ imageUrl.value = fallbackImageUrl;
21
+ }
22
+ };
23
+
24
+ return { imageUrl, handleImageError };
25
+ }
@@ -0,0 +1,11 @@
1
+ import type { Ref } from "vue";
2
+ import defaultLogoUrl from "../assets/images/logo.svg";
3
+ import { useImageUrl } from "../hooks/useImageUrl";
4
+
5
+ /**
6
+ * 管理站点 Logo 地址,并在配置地址不可用时回退到本地默认 Logo。
7
+ */
8
+ export function useLogoUrl(configuredLogoUrl: Readonly<Ref<string>>) {
9
+ const { imageUrl, handleImageError } = useImageUrl(configuredLogoUrl, defaultLogoUrl);
10
+ return { logoUrl: imageUrl, handleLogoError: handleImageError };
11
+ }
@@ -4,7 +4,12 @@
4
4
  <el-header>
5
5
  <div class="header-lf mask-image">
6
6
  <div class="logo flx-center">
7
- <img class="logo-img" :src="logoUrl" :alt="t('core.layout.logo_alt')" />
7
+ <img
8
+ class="logo-img"
9
+ :src="logoUrl"
10
+ :alt="t('core.layout.logo_alt')"
11
+ @error="handleLogoError"
12
+ />
8
13
  <span class="logo-text">{{ title }}</span>
9
14
  </div>
10
15
  <ToolBarLeft />
@@ -47,6 +52,7 @@ import SubMenu from "../../layouts/components/Menu/SubMenu.vue";
47
52
  import ToolBarLeft from "../../layouts/components/Header/ToolBarLeft.vue";
48
53
  import ToolBarRight from "../../layouts/components/Header/ToolBarRight.vue";
49
54
  import { useLocaleStore } from "../../locales";
55
+ import { useLogoUrl } from "../../hooks/useLogoUrl";
50
56
 
51
57
  const route = useRoute();
52
58
  const authStore = useAuthStore();
@@ -58,7 +64,8 @@ const isCollapse = computed(() => globalStore.isCollapse);
58
64
  const menuList = computed(() => authStore.showMenuListGet);
59
65
  const activeMenu = computed(() => route.path as string);
60
66
  const title = computed(() => configStore.display.sysName || import.meta.env.VITE_GLOB_APP_TITLE);
61
- const logoUrl = computed(() => configStore.display.adminLogo);
67
+ const configuredLogoUrl = computed(() => configStore.display.adminLogo);
68
+ const { logoUrl, handleLogoError } = useLogoUrl(configuredLogoUrl);
62
69
  </script>
63
70
 
64
71
  <style scoped lang="scss">
@@ -3,7 +3,12 @@
3
3
  <el-container class="layout">
4
4
  <div class="aside-split">
5
5
  <div class="logo flx-center">
6
- <img class="logo-img" :src="logoUrl" :alt="t('core.layout.logo_alt')" />
6
+ <img
7
+ class="logo-img"
8
+ :src="logoUrl"
9
+ :alt="t('core.layout.logo_alt')"
10
+ @error="handleLogoError"
11
+ />
7
12
  </div>
8
13
  <el-scrollbar>
9
14
  <div class="split-list">
@@ -61,6 +66,7 @@ import ToolBarLeft from "../../layouts/components/Header/ToolBarLeft.vue";
61
66
  import ToolBarRight from "../../layouts/components/Header/ToolBarRight.vue";
62
67
  import SubMenu from "../../layouts/components/Menu/SubMenu.vue";
63
68
  import { useLocaleStore } from "../../locales";
69
+ import { useLogoUrl } from "../../hooks/useLogoUrl";
64
70
 
65
71
  const route = useRoute();
66
72
  const router = useRouter();
@@ -74,7 +80,8 @@ const menuList = computed(() => authStore.showMenuListGet.filter(item => Boolean
74
80
  const activeMenu = computed(() => route.path as string);
75
81
  const title = computed(() => configStore.display.sysName || import.meta.env.VITE_GLOB_APP_TITLE);
76
82
  const collapseTitle = computed(() => title.value.slice(0, 1).toUpperCase() || "S");
77
- const logoUrl = computed(() => configStore.display.adminLogo);
83
+ const configuredLogoUrl = computed(() => configStore.display.adminLogo);
84
+ const { logoUrl, handleLogoError } = useLogoUrl(configuredLogoUrl);
78
85
 
79
86
  const subMenuList = ref<RouteItem[]>([]);
80
87
  const splitActive = ref("");
@@ -3,7 +3,12 @@
3
3
  <el-container class="layout">
4
4
  <el-header>
5
5
  <div class="logo flx-center">
6
- <img class="logo-img" :src="logoUrl" :alt="t('core.layout.logo_alt')" />
6
+ <img
7
+ class="logo-img"
8
+ :src="logoUrl"
9
+ :alt="t('core.layout.logo_alt')"
10
+ @error="handleLogoError"
11
+ />
7
12
  <span class="logo-text">{{ title }}</span>
8
13
  </div>
9
14
  <el-menu mode="horizontal" :router="false" :default-active="activeMenu">
@@ -45,6 +50,7 @@ import Main from "../../layouts/components/Main/index.vue";
45
50
  import ToolBarRight from "../../layouts/components/Header/ToolBarRight.vue";
46
51
  import SubMenu from "../../layouts/components/Menu/SubMenu.vue";
47
52
  import { useLocaleStore } from "../../locales";
53
+ import { useLogoUrl } from "../../hooks/useLogoUrl";
48
54
 
49
55
  const route = useRoute();
50
56
  const router = useRouter();
@@ -54,7 +60,8 @@ const { t } = useLocaleStore();
54
60
  const menuList = computed(() => authStore.showMenuListGet);
55
61
  const activeMenu = computed(() => route.path as string);
56
62
  const title = computed(() => configStore.display.sysName || import.meta.env.VITE_GLOB_APP_TITLE);
57
- const logoUrl = computed(() => configStore.display.adminLogo);
63
+ const configuredLogoUrl = computed(() => configStore.display.adminLogo);
64
+ const { logoUrl, handleLogoError } = useLogoUrl(configuredLogoUrl);
58
65
 
59
66
  const handleClickMenu = (subItem: RouteItem) => {
60
67
  const target = getRouteTarget(subItem);
@@ -10,6 +10,7 @@
10
10
  class="logo-img"
11
11
  :src="logoUrl"
12
12
  :alt="t('core.layout.logo_alt')"
13
+ @error="handleLogoError"
13
14
  />
14
15
  <span
15
16
  v-show="!isCollapse"
@@ -53,6 +54,7 @@ import ToolBarLeft from "../../layouts/components/Header/ToolBarLeft.vue";
53
54
  import ToolBarRight from "../../layouts/components/Header/ToolBarRight.vue";
54
55
  import SubMenu from "../../layouts/components/Menu/SubMenu.vue";
55
56
  import { useLocaleStore } from "../../locales";
57
+ import { useLogoUrl } from "../../hooks/useLogoUrl";
56
58
 
57
59
  const route = useRoute();
58
60
  const authStore = useAuthStore();
@@ -64,7 +66,8 @@ const isCollapse = computed(() => globalStore.isCollapse);
64
66
  const menuList = computed(() => authStore.showMenuListGet);
65
67
  const activeMenu = computed(() => route.path as string);
66
68
  const title = computed(() => configStore.display.sysName || import.meta.env.VITE_GLOB_APP_TITLE);
67
- const logoUrl = computed(() => configStore.display.adminLogo);
69
+ const configuredLogoUrl = computed(() => configStore.display.adminLogo);
70
+ const { logoUrl, handleLogoError } = useLogoUrl(configuredLogoUrl);
68
71
  const logoWrapperRef = ref<HTMLElement>();
69
72
  const logoIconRef = ref<HTMLImageElement>();
70
73
  const logoTextRef = ref<HTMLElement>();
@@ -9,4 +9,4 @@
9
9
  | `en-US.json` | 英文公共语言包。 | 管理端切换为英文时由 Vue I18n 使用。 |
10
10
  | `ja-JP.json` | 日文公共语言包。 | 管理端切换为日文时由 Vue I18n 使用。 |
11
11
 
12
- 语言包是标准 JSON,不能在文件内直接写注释;新增语言或修改键后执行仓库根目录的 `make i18n-sync`,脚本会校验所有语言并生成注册产物。语言切换选项固定显示后端 `base_language.native_name`,接口不可用时使用代码内置的原生名称;`common.language.*` 仅用于普通文案和语言迁移初始数据。新增语言的完整文件清单见[国际化语言扩展指南](../../../../../../docs/国际化语言扩展指南.md)。
12
+ 语言包是标准 JSON,不能在文件内直接写注释;新增语言或修改键后执行仓库根目录的 `make i18n`,脚本会校验所有语言并生成注册产物。语言切换选项固定显示后端 `base_language.native_name`,接口不可用时使用代码内置的原生名称;`common.language.*` 仅用于普通文案和语言迁移初始数据。新增语言的完整文件清单见[国际化语言扩展指南](../../../../../../docs/国际化语言扩展指南.md)。
@@ -40,8 +40,18 @@ export interface GetConfigResponse {
40
40
  configs: ConfigItem[];
41
41
  /** AI 助手是否可用 */
42
42
  ai_enabled: boolean;
43
+ }
44
+
45
+ /** 当前租户自定义国际化覆盖项查询条件。 */
46
+ export interface GetI18nCustomRequest {
47
+ /** 位置:枚举【BaseConfigSite】 */
48
+ site: BaseConfigSite;
49
+ }
50
+
51
+ /** 当前租户自定义国际化覆盖项查询结果。 */
52
+ export interface GetI18nCustomResponse {
43
53
  /** 全部语言的自定义国际化覆盖项 */
44
- i18n_customs: I18nCustomItem[];
54
+ items: I18nCustomItem[];
45
55
  }
46
56
 
47
57
  /** 全部语言的自定义国际化覆盖项。 */
@@ -58,4 +68,6 @@ export interface I18nCustomItem {
58
68
  export interface ConfigService {
59
69
  /** 获取系统配置 */
60
70
  GetConfig(request: GetConfigRequest): Promise<GetConfigResponse>;
71
+ /** 获取当前租户的自定义国际化覆盖项 */
72
+ GetI18nCustom(request: GetI18nCustomRequest): Promise<GetI18nCustomResponse>;
61
73
  }
@@ -7,6 +7,16 @@
7
7
  /* eslint-disable */
8
8
  import type { BytesValue } from "../../google/protobuf/wrappers";
9
9
 
10
+ /** 文件访问方式。 */
11
+ export enum BaseFileAccessMode {
12
+ /** BASE_FILE_ACCESS_MODE_UNSPECIFIED - 未指定文件访问方式,服务端按授权访问处理。 */
13
+ BASE_FILE_ACCESS_MODE_UNSPECIFIED = 0,
14
+ /** BASE_FILE_ACCESS_MODE_PUBLIC - 允许匿名公开访问。 */
15
+ BASE_FILE_ACCESS_MODE_PUBLIC = 1,
16
+ /** BASE_FILE_ACCESS_MODE_AUTHORIZED - 必须携带有效访问令牌。 */
17
+ BASE_FILE_ACCESS_MODE_AUTHORIZED = 2,
18
+ }
19
+
10
20
  /** 批量上传文件请求参数 */
11
21
  export interface MultiUploadFileRequest {
12
22
  /** 文件名 */
@@ -35,6 +45,8 @@ export interface UploadFileInfo {
35
45
  path: string;
36
46
  /** 二进制内容将通过 base64 编码传输 */
37
47
  content: Uint8Array;
48
+ /** 文件访问方式,未指定时默认需要访问令牌 */
49
+ access_mode: BaseFileAccessMode;
38
50
  }
39
51
 
40
52
  /** 下载文件请求参数 */
@@ -118,18 +118,33 @@ export const useConfigStore = defineStore("admin-config", {
118
118
  });
119
119
  const configMap = buildConfigMap(configResponse.configs ?? []);
120
120
 
121
- applyCustomLocaleMessages(configResponse.i18n_customs ?? []);
122
121
  this.setDisplayConfig(normalizeSiteDisplayConfig(configMap));
123
122
  this.setLoginCaptchaConfig(normalizeLoginCaptchaConfig(configMap));
124
123
  this.showTenantCode = !["false", "0"].includes((configMap[SHOW_TENANT_CODE_KEY] ?? "true").toLowerCase());
125
124
  this.i18nDraftEnabled = configMap[I18N_DRAFT_ENABLED_KEY] === "true";
126
125
  this.aiEnabled = configResponse.ai_enabled === true;
127
126
  return this.display;
127
+ },
128
+ /**
129
+ * 登录成功后加载当前租户的自定义国际化覆盖项。
130
+ */
131
+ async loadI18nCustom() {
132
+ const response = await defConfigService.GetI18nCustom({
133
+ site: BaseConfigSite.BASE_CONFIG_SITE_ADMIN
134
+ });
135
+ applyCustomLocaleMessages(response.items ?? []);
136
+ },
137
+ /**
138
+ * 清空租户自定义翻译并恢复本地默认文案。
139
+ */
140
+ resetI18nCustom() {
141
+ applyCustomLocaleMessages([]);
128
142
  }
129
143
  }
130
144
  });
131
145
 
132
146
  /** 刷新当前管理端语言和站点运行配置。 */
133
147
  export async function refreshAdminRuntimeConfig(): Promise<void> {
134
- await useConfigStore().loadDisplayConfig();
148
+ const configStore = useConfigStore();
149
+ await Promise.all([configStore.loadDisplayConfig(), configStore.loadI18nCustom()]);
135
150
  }
@@ -10,6 +10,7 @@ import piniaPersistConfig from "../../stores/helper/persist";
10
10
  import { useDictStoreHook } from "../../stores/modules/dict";
11
11
  import { useLockScreenStore } from "../../stores/modules/lockScreen";
12
12
  import { useAuthStore } from "../../stores/modules/auth";
13
+ import { useConfigStore } from "../../stores/modules/config";
13
14
 
14
15
  const defaultUserInfo: UserInfoForm = {
15
16
  user_name: "",
@@ -100,6 +101,7 @@ export const useUserStore = defineStore("admin-user", {
100
101
  useDictStoreHook().clearDictionaryCache();
101
102
  useLockScreenStore().clearLock();
102
103
  useAuthStore().$reset();
104
+ useConfigStore().resetI18nCustom();
103
105
  this.authVersion += 1;
104
106
  this.authInvalidated = true;
105
107
  this.setToken("");
@@ -4,7 +4,11 @@ import type { ColumnProps, EnumProps, RenderScope, SearchType } from "./componen
4
4
  import { defBaseTenantService } from "./api/system/admin/v1/base_tenant";
5
5
  import type { SelectOptionResponse_Option } from "./rpc/common/v1/common";
6
6
  import { useUserStore } from "./stores/runtime";
7
- import { DEFAULT_TENANT_CODE, requestTenantOptions as requestTenantOptionData } from "./utils/tenant";
7
+ import {
8
+ DEFAULT_TENANT_CODE,
9
+ loadSharedTenantOptions,
10
+ requestTenantOptions as requestTenantOptionData
11
+ } from "./utils/tenant";
8
12
 
9
13
  export * from "./utils/tenant";
10
14
 
@@ -89,8 +93,7 @@ export function useTenantScope(): TenantScope {
89
93
  async function loadTenantOptions(includeCurrentTenant = false) {
90
94
  if (!includeCurrentTenant && !isDefaultTenant.value) return;
91
95
  if (tenantOptions.value.length) return;
92
- const response = await defBaseTenantService.OptionBaseTenant({ keyword: "" });
93
- tenantOptions.value = response.list ?? [];
96
+ tenantOptions.value = await loadSharedTenantOptions();
94
97
  }
95
98
 
96
99
  function toRequestTenantId(value: unknown) {
@@ -129,7 +132,12 @@ export function useTenantScope(): TenantScope {
129
132
  search:
130
133
  options.searchable === false
131
134
  ? undefined
132
- : { el: options.searchEl ?? "tenant-select", key: options.searchKey ?? prop, props: { filterable: true }, order: options.order },
135
+ : {
136
+ el: options.searchEl ?? "tenant-select",
137
+ key: options.searchKey ?? prop,
138
+ props: { filterable: true },
139
+ order: options.order ?? 1
140
+ },
133
141
  enum: options.enum ?? requestTenantOptionData,
134
142
  render: options.render ?? (scope => resolveTenantLabel(scope.row, prop))
135
143
  }
@@ -1,3 +1,4 @@
1
+ import axios from "axios";
1
2
  import { ElNotification } from "element-plus";
2
3
  import { t } from "../locales";
3
4
 
@@ -5,6 +6,7 @@ import { t } from "../locales";
5
6
  * @description 全局代码错误捕捉
6
7
  * */
7
8
  const errorHandler = (error: any) => {
9
+ if (axios.isCancel(error) || error === "canceled") return false;
8
10
  // 过滤 HTTP 请求错误
9
11
  if (error.status || error.status == 0) return false;
10
12
  let errorMap: { [key: string]: string } = {
@@ -152,6 +152,12 @@ function shouldSkipErrorMessage(config?: InternalAxiosRequestConfig) {
152
152
  return requestUrl === SESSION_URL && requestMethod === "delete";
153
153
  }
154
154
 
155
+ /** 判断当前是否处于退出或认证失效过渡状态。 */
156
+ function isAuthTransitioning() {
157
+ const userStore = getUserStore();
158
+ return userStore.isLoggingOut || userStore.authInvalidated;
159
+ }
160
+
155
161
  /** 判断响应是否要求用户先修改密码。 */
156
162
  function isPasswordChangeRequired(data?: ErrorResponseData) {
157
163
  return data?.metadata?.[PASSWORD_CHANGE_REQUIRED_METADATA_KEY] === "true";
@@ -262,7 +268,6 @@ export async function ensureAccessToken() {
262
268
  await handleTokenRefresh(false);
263
269
  return hasValidAccessToken();
264
270
  } catch {
265
- userStore.clearAuthData();
266
271
  return false;
267
272
  }
268
273
  }
@@ -304,7 +309,14 @@ export function handleAuthExpired() {
304
309
  // 请求拦截器
305
310
  service.interceptors.request.use(
306
311
  async (config: InternalAxiosRequestConfig) => {
312
+ const requestUrl = config.url ?? "";
313
+ const requestMethod = String(config.method ?? "").toLowerCase();
307
314
  const skipAuth = shouldSkipAuth(config);
315
+ const isLogoutRequest = requestUrl === SESSION_URL && requestMethod === "delete";
316
+ if (isAuthTransitioning() && !skipAuth && !isLogoutRequest) {
317
+ throw new axios.CanceledError();
318
+ }
319
+
308
320
  const accessToken = skipAuth ? "" : await getRequestAccessToken();
309
321
  Object.assign(config.headers, getLocaleRequestHeaders());
310
322
  config.headers[REFRESH_TOKEN_TRANSPORT_HEADER] = REFRESH_TOKEN_TRANSPORT_COOKIE;
@@ -341,14 +353,24 @@ service.interceptors.response.use(
341
353
  return Promise.reject(new Error(message || t("common.message.system_error")));
342
354
  },
343
355
  async (error: AxiosError) => {
356
+ if (axios.isCancel(error)) {
357
+ return Promise.reject(error);
358
+ }
359
+
344
360
  const status = error.response?.status;
345
361
  const data = error.response?.data as ErrorResponseData | undefined;
346
362
  const code = data?.code;
347
363
  const message = data?.message;
348
364
  const requestConfig = error.config as RetryableRequestConfig | undefined;
349
365
 
350
- // 业务请求仅在 401 时尝试刷新并重放,403 直接展示后端权限错误。
351
- if ((status === 401 || code === 401) && !shouldSkipAuthExpiredPrompt(requestConfig)) {
366
+ // 退出或认证失效后,页面中的在途请求返回 401 属于预期结果,不再重复刷新令牌、弹窗或提示错误。
367
+ if (isAuthTransitioning()) {
368
+ return Promise.reject(error);
369
+ }
370
+
371
+ // 业务请求仅在 401 时尝试刷新并重放,公共认证接口的 401 继续展示后端业务错误。
372
+ const isUnauthorized = status === 401 || code === 401;
373
+ if (isUnauthorized && !shouldSkipAuthExpiredPrompt(requestConfig)) {
352
374
  const userStore = getUserStore();
353
375
  if (requestConfig && !requestConfig._authRetried && !userStore.isLoggingOut && !userStore.authInvalidated && hasRefreshCookieHint()) {
354
376
  requestConfig._authRetried = true;
@@ -374,7 +396,7 @@ service.interceptors.response.use(
374
396
  } else if (!shouldSkipErrorMessage(requestConfig)) {
375
397
  showRequestError(error.message || t("common.message.system_error"));
376
398
  }
377
- return Promise.reject(error.message);
399
+ return Promise.reject(error);
378
400
  }
379
401
  );
380
402
 
@@ -389,12 +411,25 @@ export default request;
389
411
  let isRefreshing = false;
390
412
  let refreshPromise: Promise<void> | null = null;
391
413
 
414
+ /** 判断刷新令牌是否已被服务端判定为失效。 */
415
+ function isRefreshTokenUnauthorized(error: unknown) {
416
+ if (!axios.isAxiosError<ErrorResponseData>(error)) return false;
417
+
418
+ const status = error.response?.status;
419
+ const code = error.response?.data?.code;
420
+ return status === 401 || code === 401;
421
+ }
422
+
392
423
  /** 刷新 Token 处理 */
393
424
  async function handleTokenRefresh(promptOnFailure = true) {
394
425
  if (!isRefreshing) {
395
426
  isRefreshing = true;
396
427
  refreshPromise = refreshAccessToken()
397
428
  .catch(error => {
429
+ if (isRefreshTokenUnauthorized(error)) {
430
+ // 刷新令牌失效后立即终止后续自动刷新,避免旧 Cookie 被反复提交。
431
+ getUserStore().clearAuthData();
432
+ }
398
433
  if (promptOnFailure) {
399
434
  console.log("token 刷新失败", error);
400
435
  handleAuthExpired();
@@ -1,10 +1,42 @@
1
1
  import { defBaseTenantService } from "../api/system/admin/v1/base_tenant";
2
+ import type { SelectOptionResponse_Option } from "../rpc/common/v1/common";
2
3
 
3
4
  /** 默认租户编码。 */
4
5
  export const DEFAULT_TENANT_CODE = "0000";
5
6
 
7
+ const tenantOptions: SelectOptionResponse_Option[] = [];
8
+ let tenantOptionsLoaded = false;
9
+ let tenantOptionsRequest: Promise<SelectOptionResponse_Option[]> | undefined;
10
+ let tenantOptionsVersion = 0;
11
+
12
+ /** 读取共享租户选项,同一时间只发起一次请求。 */
13
+ export async function loadSharedTenantOptions() {
14
+ if (tenantOptionsLoaded) return tenantOptions;
15
+ if (!tenantOptionsRequest) {
16
+ const requestVersion = tenantOptionsVersion;
17
+ tenantOptionsRequest = defBaseTenantService.OptionBaseTenant({ keyword: "" })
18
+ .then(response => {
19
+ if (requestVersion !== tenantOptionsVersion) return tenantOptions;
20
+ tenantOptions.splice(0, tenantOptions.length, ...(response.list ?? []));
21
+ tenantOptionsLoaded = true;
22
+ return tenantOptions;
23
+ })
24
+ .finally(() => {
25
+ tenantOptionsRequest = undefined;
26
+ });
27
+ }
28
+ return tenantOptionsRequest;
29
+ }
30
+
31
+ /** 清除共享租户选项,租户数据变更后由下一次读取重新加载。 */
32
+ export function invalidateTenantOptions() {
33
+ tenantOptionsVersion += 1;
34
+ tenantOptions.splice(0, tenantOptions.length);
35
+ tenantOptionsLoaded = false;
36
+ tenantOptionsRequest = undefined;
37
+ }
38
+
6
39
  /** 读取租户列表筛选选项。 */
7
40
  export async function requestTenantOptions() {
8
- const response = await defBaseTenantService.OptionBaseTenant({ keyword: "" });
9
- return { data: response.list ?? [] };
41
+ return { data: await loadSharedTenantOptions() };
10
42
  }
@@ -629,6 +629,11 @@ const handleLoginResponse = async (result: LoginResponse) => {
629
629
  clearPasswordChangeRequired();
630
630
  }
631
631
  userStore.updateTokenAuth(result.access_token, result.token_type ?? "", result.expires_in);
632
+ try {
633
+ await configStore.loadI18nCustom();
634
+ } catch {
635
+ configStore.resetI18nCustom();
636
+ }
632
637
  await finishLogin(mustChangePassword);
633
638
  };
634
639
 
@@ -4,11 +4,23 @@
4
4
  <SwitchDark class="dark" />
5
5
  <LocaleSwitch v-if="languageOptions.length > 1" class="locale" />
6
6
  <div class="login-left">
7
- <img class="login-left-img" :src="backgroundUrl" :alt="t('core.login.background_alt')" />
7
+ <img
8
+ class="login-left-img"
9
+ :src="backgroundUrl"
10
+ :alt="t('core.login.background_alt')"
11
+ @error="handleBackgroundError"
12
+ />
8
13
  </div>
9
14
  <div ref="loginFormRef" class="login-form">
10
15
  <div ref="loginLogoRef" class="login-logo">
11
- <img v-show="showLogoIcon" ref="logoIconRef" class="login-icon" :src="logoUrl" alt="" />
16
+ <img
17
+ v-show="showLogoIcon"
18
+ ref="logoIconRef"
19
+ class="login-icon"
20
+ :src="logoUrl"
21
+ alt=""
22
+ @error="handleLogoError"
23
+ />
12
24
  <h2 ref="logoTextRef" class="logo-text" :style="{ fontSize: `${logoFontSize}px` }">{{ projectName }}</h2>
13
25
  </div>
14
26
  <LoginForm :dialog-anchor="loginFormRef" />
@@ -24,12 +36,20 @@ import SwitchDark from "../../components/SwitchDark/index.vue";
24
36
  import LocaleSwitch from "../../components/LocaleSwitch/index.vue";
25
37
  import { useConfigStore } from "../../stores/modules/config";
26
38
  import { useLocaleStore } from "../../locales";
39
+ import defaultBackgroundUrl from "../../assets/images/login_left.png";
40
+ import { useLogoUrl } from "../../hooks/useLogoUrl";
41
+ import { useImageUrl } from "../../hooks/useImageUrl";
27
42
 
28
43
  const configStore = useConfigStore();
29
44
  const { t, languageOptions } = useLocaleStore();
30
45
  const projectName = computed(() => configStore.display.sysName || import.meta.env.VITE_GLOB_APP_TITLE);
31
- const logoUrl = computed(() => configStore.display.adminLogo);
32
- const backgroundUrl = computed(() => configStore.display.background);
46
+ const configuredLogoUrl = computed(() => configStore.display.adminLogo);
47
+ const { logoUrl, handleLogoError } = useLogoUrl(configuredLogoUrl);
48
+ const configuredBackgroundUrl = computed(() => configStore.display.background);
49
+ const { imageUrl: backgroundUrl, handleImageError: handleBackgroundError } = useImageUrl(
50
+ configuredBackgroundUrl,
51
+ defaultBackgroundUrl
52
+ );
33
53
 
34
54
  const MAX_LOGO_FONT_SIZE = 42;
35
55
  const PREFERRED_MIN_LOGO_FONT_SIZE = 30;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@liujitcn/kratos-admin-core",
3
3
  "private": false,
4
- "version": "0.0.54",
4
+ "version": "0.0.56",
5
5
  "type": "module",
6
6
  "description": "kratos-admin frontend runtime and base modules",
7
7
  "author": {
@@ -236,7 +236,7 @@
236
236
  "scripts": {
237
237
  "build": "pnpm build:package",
238
238
  "build:package": "node ../../scripts/build-package.mjs .",
239
- "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",
239
+ "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",
240
240
  "type:check": "vue-tsc --noEmit --skipLibCheck"
241
241
  }
242
242
  }