@liujitcn/kratos-admin-core 0.0.65 → 0.0.67

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 (59) hide show
  1. package/dist/package/declarations/src/api/base/v1/login.d.ts +1 -3
  2. package/dist/package/declarations/src/api/base/v1/secret_crypto.d.ts +9 -0
  3. package/dist/package/declarations/src/components/CodeBlock/index.vue.d.ts +15 -0
  4. package/dist/package/declarations/src/components/Dialog/PasswordVerifyDialog.vue.d.ts +3 -7
  5. package/dist/package/declarations/src/components/ProForm/components/ProFormItem.vue.d.ts +6 -6
  6. package/dist/package/declarations/src/components/ProForm/components/SecretInput.vue.d.ts +23 -0
  7. package/dist/package/declarations/src/components/ProForm/interface/index.d.ts +14 -1
  8. package/dist/package/declarations/src/components/YamlEditor/index.vue.d.ts +7 -1
  9. package/dist/package/declarations/src/locales/generated.d.ts +388 -0
  10. package/dist/package/declarations/src/rpc/base/v1/login.d.ts +2 -39
  11. package/dist/package/declarations/src/rpc/base/v1/mfa.d.ts +3 -3
  12. package/dist/package/declarations/src/rpc/base/v1/oauth.d.ts +2 -2
  13. package/dist/package/declarations/src/rpc/base/v1/secret_crypto.d.ts +37 -0
  14. package/dist/package/declarations/src/rpc/common/v1/types.d.ts +4 -4
  15. package/dist/package/declarations/src/rpc/system/admin/v1/auth.d.ts +3 -3
  16. package/dist/package/declarations/src/security.d.ts +1 -1
  17. package/dist/package/declarations/src/utils/secretCrypto.d.ts +19 -0
  18. package/dist/package/declarations/src/views/demo/components/DemoDetailDrawer.vue.d.ts +15 -0
  19. package/dist/package/declarations/src/views/demo/components/DemoFormDialog.vue.d.ts +69 -0
  20. package/dist/package/declarations/src/views/demo/demo-data.d.ts +145 -0
  21. package/dist/package/declarations/src/views/demo/table/index.vue.d.ts +3 -0
  22. package/dist/package/declarations/src/views/demo/tree-grid/index.vue.d.ts +3 -0
  23. package/dist/package/declarations/src/views/demo/tree-table/index.vue.d.ts +3 -0
  24. package/dist/package/src/api/base/v1/login.ts +0 -12
  25. package/dist/package/src/api/base/v1/secret_crypto.ts +35 -0
  26. package/dist/package/src/components/CodeBlock/index.vue +156 -0
  27. package/dist/package/src/components/Dialog/PasswordVerifyDialog.vue +4 -8
  28. package/dist/package/src/components/ProForm/components/ProFormItem.vue +29 -2
  29. package/dist/package/src/components/ProForm/components/SecretInput.vue +117 -0
  30. package/dist/package/src/components/ProForm/interface/index.ts +16 -0
  31. package/dist/package/src/components/YamlEditor/index.vue +103 -7
  32. package/dist/package/src/locales/README.md +1 -1
  33. package/dist/package/src/locales/en-US.json +98 -1
  34. package/dist/package/src/locales/ja-JP.json +98 -1
  35. package/dist/package/src/locales/zh-CN.json +98 -1
  36. package/dist/package/src/locales/zh-TW.json +98 -1
  37. package/dist/package/src/modules/kratosAdmin.ts +11 -1
  38. package/dist/package/src/rpc/base/v1/login.ts +2 -42
  39. package/dist/package/src/rpc/base/v1/mfa.ts +3 -3
  40. package/dist/package/src/rpc/base/v1/oauth.ts +2 -2
  41. package/dist/package/src/rpc/base/v1/secret_crypto.ts +49 -0
  42. package/dist/package/src/rpc/common/v1/types.ts +4 -4
  43. package/dist/package/src/rpc/system/admin/v1/auth.ts +3 -3
  44. package/dist/package/src/security.ts +1 -1
  45. package/dist/package/src/utils/index.ts +2 -1
  46. package/dist/package/src/utils/request.ts +5 -4
  47. package/dist/package/src/utils/{passwordCrypto.ts → secretCrypto.ts} +112 -50
  48. package/dist/package/src/views/demo/components/DemoDetailDrawer.vue +108 -0
  49. package/dist/package/src/views/demo/components/DemoFormDialog.vue +470 -0
  50. package/dist/package/src/views/demo/demo-data.ts +279 -0
  51. package/dist/package/src/views/demo/table/index.vue +391 -0
  52. package/dist/package/src/views/demo/tree-grid/index.vue +272 -0
  53. package/dist/package/src/views/demo/tree-table/index.vue +346 -0
  54. package/dist/package/src/views/login/components/LoginForm.vue +2 -2
  55. package/dist/package/types/generated/components.d.ts +4 -1
  56. package/package.json +14 -2
  57. package/src/runtime-messages.ts +39 -0
  58. package/types/generated/components.d.ts +4 -1
  59. package/dist/package/declarations/src/utils/passwordCrypto.d.ts +0 -6
@@ -60,8 +60,8 @@ export interface BytesValues {
60
60
  value: Uint8Array[];
61
61
  }
62
62
 
63
- /** 密码密文。 */
64
- export interface PasswordCrypto {
63
+ /** 敏感字段密文,密码与各类密钥字段统一使用。 */
64
+ export interface SecretCrypto {
65
65
  /** 临时密钥ID */
66
66
  key_id: string;
67
67
  /** 随机值 */
@@ -72,6 +72,6 @@ export interface PasswordCrypto {
72
72
  encrypted_key: string;
73
73
  /** 初始化向量 */
74
74
  iv: string;
75
- /** 密码加密数据 */
76
- ciphertext: string;
75
+ /** 传输时为密文,服务端解密后回填明文 */
76
+ text: string;
77
77
  }
@@ -5,7 +5,7 @@
5
5
  // source: system/admin/v1/auth.proto
6
6
 
7
7
  /* eslint-disable */
8
- import type { PasswordCrypto, StringValues } from "../../../common/v1/types";
8
+ import type { SecretCrypto, StringValues } from "../../../common/v1/types";
9
9
  import type { Empty } from "../../../google/protobuf/empty";
10
10
  import type { BaseUserIDType } from "../../common/v1/common";
11
11
  import type { BaseMenuType } from "./common";
@@ -202,10 +202,10 @@ export interface RouteParams {
202
202
  export interface UserPasswordForm {
203
203
  /** 原密码 */
204
204
  old_pwd:
205
- | PasswordCrypto
205
+ | SecretCrypto
206
206
  | undefined;
207
207
  /** 新密码 */
208
- new_pwd: PasswordCrypto | undefined;
208
+ new_pwd: SecretCrypto | undefined;
209
209
  }
210
210
 
211
211
  /** 用户手机号表单 */
@@ -1,5 +1,5 @@
1
1
  export * from "./utils/oauthProvider";
2
- export * from "./utils/passwordCrypto";
2
+ export * from "./utils/secretCrypto";
3
3
  export * from "./utils/passwordStrength";
4
4
  export * from "./utils/webauthn";
5
5
  export * from "./utils/clipboard";
@@ -256,7 +256,8 @@ export const getAllBreadcrumbList = (
256
256
  ) => {
257
257
  for (const item of menuList) {
258
258
  const routePath = item.path ?? "";
259
- const breadcrumb = [...parent, item];
259
+ // 目录型菜单(path 为空的 Layout 容器)不进入面包屑,避免与重定向后的子页面同名重复。
260
+ const breadcrumb = routePath ? [...parent, item] : parent;
260
261
  if (routePath) result[routePath] = breadcrumb;
261
262
  if (item.children) getAllBreadcrumbList(item.children, breadcrumb, result);
262
263
  }
@@ -22,7 +22,7 @@ const PASSWORD_CHANGE_STATE_STORAGE_KEY = "admin-password-change-required";
22
22
  const CAPTCHA_URL = "/v1/base/captcha";
23
23
  const CONFIG_URL = "/v1/base/config";
24
24
  const LANGUAGE_URL = "/v1/base/language";
25
- const PASSWORD_PUBLIC_KEY_URL = "/v1/base/password-public-key";
25
+ const SECRET_PUBLIC_KEY_URL = "/v1/base/secret-public-key";
26
26
  const OAUTH_PROVIDER_URL = "/v1/base/oauth/provider";
27
27
  const OAUTH_AUTHORIZATION_URL = "/v1/base/oauth/authorization";
28
28
  const OAUTH_TICKET_URL = "/v1/base/oauth/ticket";
@@ -36,7 +36,7 @@ const NO_AUTH_URL_SET = new Set([
36
36
  CAPTCHA_URL,
37
37
  CONFIG_URL,
38
38
  LANGUAGE_URL,
39
- PASSWORD_PUBLIC_KEY_URL,
39
+ SECRET_PUBLIC_KEY_URL,
40
40
  OAUTH_PROVIDER_URL,
41
41
  OAUTH_AUTHORIZATION_URL,
42
42
  OAUTH_TICKET_URL,
@@ -50,7 +50,7 @@ const AUTH_EXPIRED_EXCLUDED_URL_SET = new Set([
50
50
  CAPTCHA_URL,
51
51
  CONFIG_URL,
52
52
  LANGUAGE_URL,
53
- PASSWORD_PUBLIC_KEY_URL,
53
+ SECRET_PUBLIC_KEY_URL,
54
54
  OAUTH_PROVIDER_URL,
55
55
  OAUTH_AUTHORIZATION_URL,
56
56
  OAUTH_TICKET_URL,
@@ -369,7 +369,8 @@ service.interceptors.response.use(
369
369
  const requestConfig = error.config as RetryableRequestConfig | undefined;
370
370
 
371
371
  // 退出或认证失效后,页面中的在途请求返回 401 属于预期结果,不再重复刷新令牌、弹窗或提示错误。
372
- if (isAuthTransitioning()) {
372
+ // 登录等公共认证接口不受此限制:用户在登录页主动提交失败时必须给出错误反馈(如用户名或密码错误)。
373
+ if (isAuthTransitioning() && !shouldSkipAuthExpiredPrompt(requestConfig)) {
373
374
  return Promise.reject(error);
374
375
  }
375
376
 
@@ -1,13 +1,112 @@
1
- import { defLoginService } from "../api/base/v1/login";
2
- import { PasswordCryptoScene, type PasswordPublicKeyResponse } from "../rpc/base/v1/login";
3
- import type { PasswordCrypto } from "../rpc/common/v1/types";
1
+ import { defSecretCryptoService } from "../api/base/v1/secret_crypto";
2
+ import type { GetSecretPublicKeyResponse } from "../rpc/base/v1/secret_crypto";
3
+ import type { SecretCrypto } from "../rpc/common/v1/types";
4
+ import type { ProFormField } from "../components/ProForm/interface";
4
5
  import { t } from "../locales";
5
6
  import * as portableCrypto from "asmcrypto.js";
6
7
 
7
8
  type PortableCrypto = typeof import("asmcrypto.js");
8
9
 
9
- export const PASSWORD_CRYPTO_SCENE = PasswordCryptoScene;
10
- export type { PasswordCryptoScene };
10
+ /** 获取敏感字段一次性临时公钥,同一次提交的多个密文字段共用该公钥。 */
11
+ export async function fetchSecretPublicKey(): Promise<GetSecretPublicKeyResponse> {
12
+ return defSecretCryptoService.GetSecretPublicKey({});
13
+ }
14
+
15
+ /** encodeSecretFields 在提交前把声明 secret 的字段统一加密为 SecretCrypto,返回模型浅克隆且不改动原表单。 */
16
+ export async function encodeSecretFields(
17
+ fields: ProFormField[],
18
+ model: Record<string, any>
19
+ ): Promise<Record<string, any>> {
20
+ const secretFields = fields.filter(field => field.secret);
21
+ const filled = secretFields
22
+ .map(field => ({ field, value: getByPath(model, field.prop) }))
23
+ .filter((item): item is { field: ProFormField; value: string } => typeof item.value === "string" && item.value.trim() !== "");
24
+ if (secretFields.length === 0) {
25
+ return { ...model };
26
+ }
27
+ const publicKey = filled.length > 0 ? await fetchSecretPublicKey() : null;
28
+ const payload = { ...model };
29
+ for (const item of secretFields) {
30
+ const value = getByPath(model, item.prop);
31
+ if (typeof value === "string" && value.trim() !== "") {
32
+ payload[item.prop] = await encryptWithPublicKey(publicKey!, value);
33
+ } else {
34
+ payload[item.prop] = undefined;
35
+ }
36
+ }
37
+ return payload;
38
+ }
39
+
40
+ /** 按点路径读取对象字段值。 */
41
+ function getByPath(model: Record<string, any>, path: string) {
42
+ return path.split(".").reduce((current, key) => (current == null ? current : current[key]), model as any);
43
+ }
44
+
45
+ /** 使用已获取的临时公钥加密单个敏感值,供一次取钥加密多个字段的场景复用。 */
46
+ export async function encryptWithPublicKey(publicKey: GetSecretPublicKeyResponse, value: string): Promise<SecretCrypto> {
47
+ if (!value) {
48
+ throw new Error(t("core.password.required"));
49
+ }
50
+ const cryptoApi = globalThis.crypto;
51
+ const isSecureContext = typeof window === "undefined" || window.isSecureContext;
52
+ if (!cryptoApi?.subtle || !isSecureContext) {
53
+ return encryptPortableSecret(portableCrypto, value, publicKey);
54
+ }
55
+ const subtleCrypto = getSubtleCrypto();
56
+ const publicKeyKey = await subtleCrypto.subtle.importKey(
57
+ "spki",
58
+ pemToArrayBuffer(publicKey.public_key),
59
+ { name: "RSA-OAEP", hash: "SHA-256" },
60
+ false,
61
+ ["encrypt"]
62
+ );
63
+ const aesKey = await subtleCrypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt"]);
64
+ const rawAesKey = await subtleCrypto.subtle.exportKey("raw", aesKey);
65
+ const iv = subtleCrypto.getRandomValues(new Uint8Array(12));
66
+ const ciphertext = await subtleCrypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, new TextEncoder().encode(value));
67
+ const encryptedKey = await subtleCrypto.subtle.encrypt({ name: "RSA-OAEP" }, publicKeyKey, rawAesKey);
68
+
69
+ return {
70
+ key_id: publicKey.key_id,
71
+ nonce: publicKey.nonce,
72
+ algorithm: publicKey.algorithm,
73
+ encrypted_key: arrayBufferToBase64(encryptedKey),
74
+ iv: arrayBufferToBase64(iv.buffer),
75
+ text: arrayBufferToBase64(ciphertext)
76
+ };
77
+ }
78
+
79
+ /** 加密单个敏感字段值,自动获取一次性临时公钥。 */
80
+ export async function encryptSecret(value: string): Promise<SecretCrypto> {
81
+ return encryptWithPublicKey(await fetchSecretPublicKey(), value);
82
+ }
83
+
84
+ /** SecretRevealHandler 按资源标识与字段名查看密钥明文的实现。 */
85
+ export type SecretRevealHandler = (resource: string, id: number, field: string) => Promise<string>;
86
+
87
+ let secretRevealHandler: SecretRevealHandler | null = null;
88
+
89
+ /** registerSecretRevealHandler 注册密钥明文查看实现,由业务模块在启动时注入。 */
90
+ export function registerSecretRevealHandler(handler: SecretRevealHandler) {
91
+ secretRevealHandler = handler;
92
+ }
93
+
94
+ /** revealSecret 查看注册过的密钥字段明文。 */
95
+ export async function revealSecret(resource: string, id: number, field: string): Promise<string> {
96
+ if (!secretRevealHandler) {
97
+ throw new Error(t("core.password.reveal_unsupported"));
98
+ }
99
+ return secretRevealHandler(resource, id, field);
100
+ }
101
+
102
+ /** 加密密码字段:去除首尾空白且不允许为空。 */
103
+ export async function encryptPassword(value: string): Promise<SecretCrypto> {
104
+ const plainPassword = value.trim();
105
+ if (!plainPassword) {
106
+ throw new Error(t("core.password.required"));
107
+ }
108
+ return encryptSecret(plainPassword);
109
+ }
11
110
 
12
111
  /** 将 PEM 公钥转换为二进制 DER 数据。 */
13
112
  function pemToArrayBuffer(pem: string) {
@@ -129,7 +228,7 @@ function generateMgf1Mask(cryptoApi: PortableCrypto, seed: Uint8Array, length: n
129
228
  return mask;
130
229
  }
131
230
 
132
- /** 生成纯 JavaScript 密码加密所需的随机字节。 */
231
+ /** 生成纯 JavaScript 加密所需的随机字节。 */
133
232
  function getPortableRandomBytes(length: number) {
134
233
  const cryptoApi = globalThis.crypto;
135
234
  if (!cryptoApi?.getRandomValues) {
@@ -138,15 +237,15 @@ function getPortableRandomBytes(length: number) {
138
237
  return cryptoApi.getRandomValues(new Uint8Array(length));
139
238
  }
140
239
 
141
- /** 使用纯 JavaScript 实现兼容缺少 WebCrypto 的密码加密协议。 */
142
- async function encryptPortablePassword(
240
+ /** 使用纯 JavaScript 实现兼容缺少 WebCrypto 环境的敏感值加密协议。 */
241
+ async function encryptPortableSecret(
143
242
  cryptoApi: PortableCrypto,
144
- password: string,
145
- publicKeyResponse: PasswordPublicKeyResponse
146
- ): Promise<PasswordCrypto> {
243
+ value: string,
244
+ publicKeyResponse: GetSecretPublicKeyResponse
245
+ ): Promise<SecretCrypto> {
147
246
  const aesKey = getPortableRandomBytes(32);
148
247
  const iv = getPortableRandomBytes(12);
149
- const plaintext = cryptoApi.string_to_bytes(password, true);
248
+ const plaintext = cryptoApi.string_to_bytes(value, true);
150
249
  const [modulus, exponent] = parsePortableRsaPublicKey(publicKeyResponse.public_key);
151
250
  const hash = new cryptoApi.Sha256();
152
251
  const keySize = Math.ceil(new cryptoApi.BigNumber(modulus).bitLength / 8);
@@ -187,43 +286,6 @@ async function encryptPortablePassword(
187
286
  algorithm: publicKeyResponse.algorithm,
188
287
  encrypted_key: arrayBufferToBase64(encryptedKey.slice().buffer),
189
288
  iv: arrayBufferToBase64(iv.buffer),
190
- ciphertext: arrayBufferToBase64(ciphertext.slice().buffer)
191
- };
192
- }
193
-
194
- /** 加密单个密码字段,返回后端可解析的密码密文。 */
195
- export async function encryptPassword(password: string, scene: PasswordCryptoScene): Promise<PasswordCrypto> {
196
- const plainPassword = password.trim();
197
- if (!plainPassword) {
198
- throw new Error(t("core.password.required"));
199
- }
200
-
201
- const publicKeyResponse = await defLoginService.PasswordPublicKey({ scene });
202
- const cryptoApi = globalThis.crypto;
203
- const isSecureContext = typeof window === "undefined" || window.isSecureContext;
204
- if (!cryptoApi?.subtle || !isSecureContext) {
205
- return encryptPortablePassword(portableCrypto, plainPassword, publicKeyResponse);
206
- }
207
- const subtleCrypto = getSubtleCrypto();
208
- const publicKey = await subtleCrypto.subtle.importKey(
209
- "spki",
210
- pemToArrayBuffer(publicKeyResponse.public_key),
211
- { name: "RSA-OAEP", hash: "SHA-256" },
212
- false,
213
- ["encrypt"]
214
- );
215
- const aesKey = await subtleCrypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt"]);
216
- const rawAesKey = await subtleCrypto.subtle.exportKey("raw", aesKey);
217
- const iv = subtleCrypto.getRandomValues(new Uint8Array(12));
218
- const ciphertext = await subtleCrypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, new TextEncoder().encode(plainPassword));
219
- const encryptedKey = await subtleCrypto.subtle.encrypt({ name: "RSA-OAEP" }, publicKey, rawAesKey);
220
-
221
- return {
222
- key_id: publicKeyResponse.key_id,
223
- nonce: publicKeyResponse.nonce,
224
- algorithm: publicKeyResponse.algorithm,
225
- encrypted_key: arrayBufferToBase64(encryptedKey),
226
- iv: arrayBufferToBase64(iv.buffer),
227
- ciphertext: arrayBufferToBase64(ciphertext)
289
+ text: arrayBufferToBase64(ciphertext.slice().buffer)
228
290
  };
229
291
  }
@@ -0,0 +1,108 @@
1
+ <!--
2
+ Demo 共享详情抽屉:演示管理端标准的“详情展示”形态。
3
+
4
+ 组成结构(均为 Element Plus 原生组件,core 未封装 ProDescriptions):
5
+ - el-drawer(size 62%)承载数据,关闭时通过 update:modelValue 同步父组件;
6
+ - el-tabs 分页签:基本信息 / 流程进度 / 操作时间线 / 原始数据;
7
+ - el-descriptions 描述列表(参照 system/base/api/index.vue 的详情抽屉写法);
8
+ - el-steps / el-timeline / el-collapse 展示流程、时间线与 JSON 原文。
9
+
10
+ 各表格页把行数据组装成 DemoDetailModel 后传给本组件即可复用。
11
+ -->
12
+ <template>
13
+ <el-drawer
14
+ :model-value="modelValue"
15
+ :title="detail?.title ?? t('common.action.detail')"
16
+ size="62%"
17
+ @update:model-value="value => emit('update:modelValue', value)"
18
+ >
19
+ <el-tabs v-if="detail">
20
+ <!-- 页签一:基本信息,多组 el-descriptions 描述列表 -->
21
+ <el-tab-pane :label="t('core.demo.detail.tab.basic')">
22
+ <div v-for="section in detail.sections" :key="section.title" class="detail-section">
23
+ <el-divider content-position="left">{{ section.title }}</el-divider>
24
+ <el-descriptions :column="2" border>
25
+ <el-descriptions-item v-for="field in section.fields" :key="field.label" :label="field.label">
26
+ <el-tag v-if="field.tag" :type="field.tagType ?? 'info'" effect="plain">{{ field.value }}</el-tag>
27
+ <template v-else>{{ field.value }}</template>
28
+ </el-descriptions-item>
29
+ </el-descriptions>
30
+ </div>
31
+ </el-tab-pane>
32
+
33
+ <!-- 页签二:流程进度,el-steps 展示节点流转 -->
34
+ <el-tab-pane :label="t('core.demo.detail.tab.progress')">
35
+ <el-steps :active="detail.steps.length - 1" align-center finish-status="success">
36
+ <el-step v-for="(step, index) in detail.steps" :key="step.title" :title="step.title" :description="`T+${index}`" />
37
+ </el-steps>
38
+ </el-tab-pane>
39
+
40
+ <!-- 页签三:操作时间线,el-timeline 按时间倒序展示 -->
41
+ <el-tab-pane :label="t('core.demo.detail.tab.timeline')">
42
+ <el-timeline>
43
+ <el-timeline-item
44
+ v-for="item in detail.timeline"
45
+ :key="item.timestamp"
46
+ :timestamp="item.timestamp"
47
+ :type="item.type"
48
+ >
49
+ {{ item.title }}
50
+ </el-timeline-item>
51
+ </el-timeline>
52
+ </el-tab-pane>
53
+
54
+ <!-- 页签四:原始数据,el-collapse 折叠面板内展示格式化 JSON -->
55
+ <el-tab-pane :label="t('core.demo.detail.tab.raw')">
56
+ <el-collapse :model-value="rawPanelName">
57
+ <el-collapse-item :title="t('core.demo.detail.raw.title')" :name="rawPanelName">
58
+ <pre class="detail-raw">{{ JSON.stringify(detail.raw, null, 2) }}</pre>
59
+ </el-collapse-item>
60
+ </el-collapse>
61
+ </el-tab-pane>
62
+ </el-tabs>
63
+ </el-drawer>
64
+ </template>
65
+
66
+ <script setup lang="ts">
67
+ import { t } from "../../../locales";
68
+ import type { DemoDetailModel } from "../demo-data";
69
+
70
+ defineOptions({
71
+ name: "DemoDetailDrawer"
72
+ });
73
+
74
+ /** 组件入参:modelValue 控制显隐,detail 为页面组装好的详情模型。 */
75
+ defineProps<{
76
+ /** 抽屉显隐,v-model 双向绑定。 */
77
+ modelValue: boolean;
78
+ /** 详情模型,为空时抽屉仅显示标题占位。 */
79
+ detail: DemoDetailModel | null;
80
+ }>();
81
+
82
+ const emit = defineEmits<{
83
+ "update:modelValue": [value: boolean];
84
+ }>();
85
+
86
+ /** 原始数据折叠面板默认展开的名称。 */
87
+ const rawPanelName = "raw";
88
+ </script>
89
+
90
+ <style scoped>
91
+ /* 分组之间留出间距,描述列表与分组标题紧凑排列 */
92
+ .detail-section + .detail-section {
93
+ margin-top: 16px;
94
+ }
95
+
96
+ /* JSON 原文使用等宽字体并限制纵向滚动,适配暗色主题文字变量 */
97
+ .detail-raw {
98
+ max-height: 420px;
99
+ margin: 0;
100
+ overflow: auto;
101
+ font-family: var(--el-font-family-monospace, monospace);
102
+ font-size: 12px;
103
+ line-height: 1.6;
104
+ color: var(--el-text-color-regular);
105
+ white-space: pre-wrap;
106
+ word-break: break-all;
107
+ }
108
+ </style>