@liujitcn/kratos-admin-system 0.0.52 → 0.0.54

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 (50) hide show
  1. package/dist/package/declarations/src/api/base/v1/sse.d.ts +1 -1
  2. package/dist/package/declarations/src/locales/generated.d.ts +32 -0
  3. package/dist/package/declarations/src/rpc/base/v1/ai_search.d.ts +26 -0
  4. package/dist/package/declarations/src/rpc/system/admin/v1/base_redact_output_policy.d.ts +0 -1
  5. package/dist/package/src/api/base/v1/sse.ts +19 -12
  6. package/dist/package/src/components/log/LogTable.vue +25 -18
  7. package/dist/package/src/components/notification/Notification.vue +14 -7
  8. package/dist/package/src/components/tenant-project/TenantProjectManager.vue +12 -6
  9. package/dist/package/src/components/tenant-project-grant/ProjectGrantDialog.vue +22 -7
  10. package/dist/package/src/locales/en-US.json +8 -0
  11. package/dist/package/src/locales/ja-JP.json +8 -0
  12. package/dist/package/src/locales/zh-CN.json +8 -0
  13. package/dist/package/src/locales/zh-TW.json +8 -0
  14. package/dist/package/src/rpc/base/v1/ai_search.ts +37 -0
  15. package/dist/package/src/rpc/system/admin/v1/base_redact_output_policy.ts +0 -1
  16. package/dist/package/src/views/ai/chat/index.vue +16 -7
  17. package/dist/package/src/views/base/api-log/index.vue +1 -1
  18. package/dist/package/src/views/base/area/index.vue +15 -9
  19. package/dist/package/src/views/base/backup-management/archive-config/index.vue +22 -10
  20. package/dist/package/src/views/base/backup-management/backup-config/index.vue +13 -6
  21. package/dist/package/src/views/base/config/index.vue +38 -14
  22. package/dist/package/src/views/base/dashboard/index.vue +201 -53
  23. package/dist/package/src/views/base/dept/index.vue +31 -25
  24. package/dist/package/src/views/base/dict/index.vue +12 -8
  25. package/dist/package/src/views/base/dict/item.vue +12 -8
  26. package/dist/package/src/views/base/i18n-custom/index.vue +13 -7
  27. package/dist/package/src/views/base/job/index.vue +12 -8
  28. package/dist/package/src/views/base/job/log.vue +14 -9
  29. package/dist/package/src/views/base/language/index.vue +7 -4
  30. package/dist/package/src/views/base/login-policy/index.vue +22 -11
  31. package/dist/package/src/views/base/menu/index.vue +23 -16
  32. package/dist/package/src/views/base/message/index.vue +65 -21
  33. package/dist/package/src/views/base/message-category/index.vue +10 -4
  34. package/dist/package/src/views/base/oauth-client/index.vue +15 -5
  35. package/dist/package/src/views/base/post/index.vue +10 -4
  36. package/dist/package/src/views/base/redact-output-policy/index.vue +12 -5
  37. package/dist/package/src/views/base/redact-rule/index.vue +8 -7
  38. package/dist/package/src/views/base/redact-storage-policy/index.vue +32 -8
  39. package/dist/package/src/views/base/role/index.vue +24 -17
  40. package/dist/package/src/views/base/tenant/index.vue +7 -6
  41. package/dist/package/src/views/base/tenant-project-grant/index.vue +44 -27
  42. package/dist/package/src/views/base/user/components/dept-tree.vue +4 -9
  43. package/dist/package/src/views/base/user/index.vue +39 -28
  44. package/dist/package/src/views/message/inbox/index.vue +8 -3
  45. package/dist/package/src/views/profile/components/base.vue +1 -1
  46. package/dist/package/src/views/profile/index.vue +1 -1
  47. package/dist/package/src/views/tool/cache/index.vue +1 -1
  48. package/dist/package/src/views/tool/code-gen/columns/index.vue +35 -16
  49. package/dist/package/src/views/tool/code-gen/table/index.vue +58 -31
  50. package/package.json +2 -2
@@ -113,7 +113,9 @@ async function loadParentIdFormTreeOptions(
113
113
  resolve(normalizeLazyTreeOptions((response.list ?? []) as GeneratedTreeOption[]));
114
114
  }
115
115
 
116
- void loadFormOptions();
116
+ void loadFormOptions().then(options => {
117
+ parentIdFormOptions.value = options;
118
+ });
117
119
 
118
120
  /** 行政区域表单字段配置。 */
119
121
  const formFields = computed<ProFormField[]>(() => [
@@ -218,7 +220,7 @@ async function loadFormOptions() {
218
220
  const parentIdFormResponse = await defBaseAreaService.OptionBaseArea({ parent_id: 0, lazy: true } as Parameters<
219
221
  typeof defBaseAreaService.OptionBaseArea
220
222
  >[0]);
221
- parentIdFormOptions.value = [
223
+ return [
222
224
  { label: t("system.base.area.value.root"), value: 0 },
223
225
  ...normalizeLazyTreeOptions((parentIdFormResponse.list ?? []) as GeneratedTreeOption[]).filter(
224
226
  option => Number(option.value) !== 0
@@ -231,19 +233,23 @@ async function loadFormOptions() {
231
233
  */
232
234
  async function handleOpenDialog(id?: number) {
233
235
  resetForm();
234
- await loadFormOptions();
235
236
  dialog.titleKey = id ? "system.base.area.action.edit" : "system.base.area.action.create";
236
- dialog.visible = true;
237
- if (!id) return;
238
-
239
- const data = await defBaseAreaService.GetBaseArea({ id });
240
- Object.assign(formData, data);
237
+ await formDialogRef.value?.open({
238
+ load: async () => ({
239
+ options: await loadFormOptions(),
240
+ data: id ? await defBaseAreaService.GetBaseArea({ id }) : undefined
241
+ }),
242
+ commit: ({ options, data }) => {
243
+ parentIdFormOptions.value = options;
244
+ if (data) Object.assign(formData, data);
245
+ }
246
+ });
241
247
  }
242
248
  /**
243
249
  * 关闭行政区域弹窗。
244
250
  */
245
251
  function handleCloseDialog() {
246
- dialog.visible = false;
252
+ formDialogRef.value?.close();
247
253
  resetForm();
248
254
  }
249
255
  /**
@@ -83,13 +83,21 @@ function refresh() { proTable.value?.getTableList(); }
83
83
  function defaultForm(): BaseTableArchiveForm { return { id: 0, source_name: "", table_name: "", archive_mode: BaseTableArchiveMode.BASE_TABLE_ARCHIVE_MODE_INTERNAL_DATABASE, online_retention_days: 180, archive_retention_days: 3650, batch_size: 5000, delete_after_verify: false, oss_prefix: "archive", status: Status.STATUS_ENABLE }; }
84
84
  function resetForm() { dialog.visible = false; formDialogRef.value?.resetFields(); Object.assign(formData, defaultForm()); }
85
85
  async function openDialog(id?: number) {
86
- await loadSourceOptions();
87
- Object.assign(formData, defaultForm());
88
- dialog.editing = Boolean(id);
89
- if (id) Object.assign(formData, await defBaseTableArchiveService.GetBaseTableArchive({ id }));
90
- if (!formData.source_name) formData.source_name = String(sourceOptions.value[0]?.value ?? "");
91
- await loadTableOptions(formData.source_name);
92
- dialog.visible = true;
86
+ await formDialogRef.value?.open({
87
+ load: async () => {
88
+ await loadSourceOptions();
89
+ const data = id ? await defBaseTableArchiveService.GetBaseTableArchive({ id }) : undefined;
90
+ const form = { ...defaultForm(), ...(data ?? {}) };
91
+ if (!form.source_name) form.source_name = String(sourceOptions.value[0]?.value ?? "");
92
+ const loadedTableOptions = form.source_name ? await requestTableOptions(form.source_name) : [];
93
+ return { form, loadedTableOptions };
94
+ },
95
+ commit: ({ form, loadedTableOptions }) => {
96
+ Object.assign(formData, form);
97
+ dialog.editing = Boolean(id);
98
+ tableOptions.value = loadedTableOptions;
99
+ }
100
+ });
93
101
  }
94
102
  async function loadSourceOptions() {
95
103
  if (sourceOptions.value.length || loadingSources.value) return;
@@ -107,12 +115,16 @@ async function handleSourceChange(value: string | number | boolean | undefined)
107
115
  await loadTableOptions(formData.source_name);
108
116
  }
109
117
  async function loadTableOptions(sourceName: string) {
110
- tableOptions.value = [];
111
- if (!sourceName) return;
118
+ tableOptions.value = await requestTableOptions(sourceName);
119
+ }
120
+
121
+ /** 请求指定数据源下的数据表选项。 */
122
+ async function requestTableOptions(sourceName: string) {
123
+ if (!sourceName) return [];
112
124
  loadingTables.value = true;
113
125
  try {
114
126
  const data = await defBaseTableSourceService.OptionBaseTable({ source_name: sourceName });
115
- tableOptions.value = (data.value ?? []).map(value => ({ label: value, value }));
127
+ return (data.value ?? []).map(value => ({ label: value, value }));
116
128
  } finally {
117
129
  loadingTables.value = false;
118
130
  }
@@ -68,12 +68,19 @@ function refresh() { proTable.value?.getTableList(); }
68
68
  function defaultForm(): BaseTableBackupForm { return { id: 0, source_name: "", backup_type: BaseTableBackupType.BASE_TABLE_BACKUP_TYPE_FULL, oss_prefix: "backup/database", retention_count: 7, status: Status.STATUS_ENABLE }; }
69
69
  function resetForm() { dialog.visible = false; formDialogRef.value?.resetFields(); Object.assign(formData, defaultForm()); }
70
70
  async function openDialog(id?: number) {
71
- await loadSourceOptions();
72
- Object.assign(formData, defaultForm());
73
- dialog.editing = Boolean(id);
74
- if (id) Object.assign(formData, await defBaseTableBackupService.GetBaseTableBackup({ id }));
75
- if (!formData.source_name) formData.source_name = String(sourceOptions.value[0]?.value ?? "");
76
- dialog.visible = true;
71
+ await formDialogRef.value?.open({
72
+ load: async () => {
73
+ await loadSourceOptions();
74
+ const data = id ? await defBaseTableBackupService.GetBaseTableBackup({ id }) : undefined;
75
+ const form = { ...defaultForm(), ...(data ?? {}) };
76
+ if (!form.source_name) form.source_name = String(sourceOptions.value[0]?.value ?? "");
77
+ return form;
78
+ },
79
+ commit: form => {
80
+ Object.assign(formData, form);
81
+ dialog.editing = Boolean(id);
82
+ }
83
+ });
77
84
  }
78
85
  async function loadSourceOptions() {
79
86
  if (sourceOptions.value.length || loadingSources.value) return;
@@ -10,10 +10,11 @@
10
10
  />
11
11
 
12
12
  <ProDialog
13
+ ref="dialogRef"
13
14
  v-model="dialog.visible"
14
15
  class="system-config-dialog"
15
16
  :title="t(dialog.titleKey, { resource: t('system.base.config.resource') })"
16
- width="min(1040px, calc(100vw - 32px))"
17
+ width="min(1280px, calc(100vw - 32px))"
17
18
  top="4vh"
18
19
  :confirm-loading="saving"
19
20
  @confirm="handleSubmit"
@@ -32,6 +33,7 @@
32
33
  :rules="rules"
33
34
  :col-span="12"
34
35
  :gutter="24"
36
+ label-width="10em"
35
37
  label-position="right"
36
38
  scroll-to-error
37
39
  >
@@ -96,6 +98,7 @@
96
98
  :fields="localizedFormFields"
97
99
  :col-span="12"
98
100
  :gutter="24"
101
+ label-width="10em"
99
102
  label-position="right"
100
103
  scroll-to-error
101
104
  />
@@ -160,6 +163,7 @@ defineOptions({
160
163
 
161
164
  const { BUTTONS } = useAuthButtons();
162
165
  const proTable = ref<ProTableInstance>();
166
+ const dialogRef = ref<InstanceType<typeof ProDialog>>();
163
167
  const basicFormRef = ref<ProFormInstance>();
164
168
  const activeTab = ref("basic");
165
169
 
@@ -635,29 +639,48 @@ function refreshTable() {
635
639
  * 打开系统配置弹窗。
636
640
  */
637
641
  async function handleOpenDialog(configId?: number) {
638
- await loadEnabledBaseLanguages();
639
642
  resetForm();
640
643
  dialog.titleKey = configId ? "common.action.edit_resource" : "common.action.create_resource";
641
- if (configId) {
642
- Object.assign(formData, await defBaseConfigService.GetBaseConfig({ id: configId }));
643
- if (formData.type === BaseConfigType.BASE_CONFIG_TYPE_FORM) {
644
- loadFormValue(formData.value);
644
+ await dialogRef.value?.open({
645
+ load: async () => ({
646
+ data: configId ? await defBaseConfigService.GetBaseConfig({ id: configId }) : undefined,
647
+ languages: await loadEnabledBaseLanguages()
648
+ }),
649
+ commit: ({ data }) => {
650
+ if (data) {
651
+ Object.assign(formData, data);
652
+ if (formData.type === BaseConfigType.BASE_CONFIG_TYPE_FORM) loadFormValue(formData.value);
653
+ }
654
+ activeTab.value = configId && formData.type === BaseConfigType.BASE_CONFIG_TYPE_FORM ? "form" : "basic";
645
655
  }
646
- }
647
- activeTab.value = configId && formData.type === BaseConfigType.BASE_CONFIG_TYPE_FORM ? "form" : "basic";
648
- dialog.visible = true;
656
+ });
649
657
  }
650
658
 
651
659
  /** 按注册定义创建独立模型,并用已保存的配置值覆盖默认值。 */
652
660
  function loadFormValue(value?: string) {
653
661
  const definition = formDefinition.value;
662
+ const parsed = value ? parseJSONValue(value) : {};
654
663
  formValue.value =
655
- formData.type === BaseConfigType.BASE_CONFIG_TYPE_FORM && definition
656
- ? Object.assign(definition.createModel(), value ? JSON.parse(value) : {})
657
- : {};
664
+ formData.type === BaseConfigType.BASE_CONFIG_TYPE_FORM && definition && isJSONObject(parsed)
665
+ ? Object.assign(definition.createModel(), parsed)
666
+ : definition?.createModel() ?? {};
658
667
  valueFormRef.value?.clearValidate();
659
668
  }
660
669
 
670
+ /** 安全解析运行配置 JSON,脏数据回退到表单默认模型。 */
671
+ function parseJSONValue(value: string): unknown {
672
+ try {
673
+ return JSON.parse(value);
674
+ } catch {
675
+ return undefined;
676
+ }
677
+ }
678
+
679
+ /** 判断运行配置 JSON 是否为可合并对象。 */
680
+ function isJSONObject(value: unknown): value is Record<string, unknown> {
681
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
682
+ }
683
+
661
684
  /** 按字段声明恢复运行配置提交值的 JSON 类型。 */
662
685
  function normalizeRuntimeConfigValue() {
663
686
  if (!formDefinition.value) return;
@@ -669,7 +692,8 @@ function normalizeRuntimeConfigValue() {
669
692
  } else if (field.valueType === "boolean" && typeof value === "string") {
670
693
  setRuntimeConfigFieldValue(field.prop, value === "true");
671
694
  } else if (field.valueType === "json" && typeof value === "string" && value !== "") {
672
- setRuntimeConfigFieldValue(field.prop, JSON.parse(value));
695
+ const parsed = parseJSONValue(value);
696
+ if (parsed !== undefined) setRuntimeConfigFieldValue(field.prop, parsed);
673
697
  }
674
698
  }
675
699
  }
@@ -690,7 +714,7 @@ function setRuntimeConfigFieldValue(prop: string, value: unknown) {
690
714
  * 关闭系统配置弹窗并恢复默认表单值。
691
715
  */
692
716
  function handleCloseDialog() {
693
- dialog.visible = false;
717
+ dialogRef.value?.close();
694
718
  resetForm();
695
719
  }
696
720
 
@@ -8,28 +8,42 @@
8
8
  :key="metric.key"
9
9
  :class="['metric-card', `metric-card--${metric.tone}`, 'admin-page-card']"
10
10
  >
11
- <div class="metric-content">
12
- <span class="metric-label">{{ metric.label }}</span>
13
- <strong class="metric-value">{{ metric.value }}</strong>
14
- </div>
15
- <div class="metric-icon" aria-hidden="true">
16
- <el-icon><component :is="metric.icon" /></el-icon>
11
+ <div class="metric-top">
12
+ <span class="metric-label">{{ metric.label }}</span
13
+ ><span class="metric-icon"
14
+ ><el-icon><component :is="metric.icon" /></el-icon
15
+ ></span>
17
16
  </div>
17
+ <strong class="metric-value">{{ metric.value }}</strong>
18
18
  </el-card>
19
19
  </section>
20
-
21
20
  <section class="chart-grid">
22
21
  <el-card class="chart-card chart-card-wide admin-page-card">
23
- <template #header>{{ t("system.dashboard.login_trend") }}</template>
24
- <div class="chart-wrap"><ECharts :option="loginTrendOption" height="320" /></div>
22
+ <template #header
23
+ ><div class="chart-heading">
24
+ <span>{{ t("system.dashboard.login_trend") }}</span
25
+ ><small>近 7 天</small>
26
+ </div></template
27
+ >
28
+ <div class="chart-wrap"><ECharts :option="loginTrendOption" height="285" /></div>
25
29
  </el-card>
26
30
  <el-card class="chart-card admin-page-card">
27
- <template #header>{{ t("system.dashboard.login_distribution") }}</template>
28
- <div class="chart-wrap"><ECharts :option="loginDistributionOption" height="320" /></div>
31
+ <template #header
32
+ ><div class="chart-heading">
33
+ <span>{{ t("system.dashboard.login_distribution") }}</span
34
+ ><small>总计 {{ loginDistributionTotal }}</small>
35
+ </div></template
36
+ >
37
+ <div class="chart-wrap"><ECharts :option="loginDistributionOption" height="265" /></div>
29
38
  </el-card>
30
39
  <el-card class="chart-card admin-page-card">
31
- <template #header>{{ t("system.dashboard.operation_distribution") }}</template>
32
- <div class="chart-wrap"><ECharts :option="operationDistributionOption" height="320" /></div>
40
+ <template #header
41
+ ><div class="chart-heading">
42
+ <span>{{ t("system.dashboard.operation_distribution") }}</span
43
+ ><small>总计 {{ operationDistributionTotal }}</small>
44
+ </div></template
45
+ >
46
+ <div class="chart-wrap"><ECharts :option="operationDistributionOption" height="265" /></div>
33
47
  </el-card>
34
48
  </section>
35
49
  </template>
@@ -37,7 +51,7 @@
37
51
  </template>
38
52
 
39
53
  <script setup lang="ts">
40
- import { computed, onMounted, ref } from "vue";
54
+ import { computed, onBeforeUnmount, onMounted, ref } from "vue";
41
55
  import ECharts from "@liujitcn/kratos-admin-core/components/ECharts/index.vue";
42
56
  import { defBaseDashboardService } from "@liujitcn/kratos-admin-system/api/system/admin/v1/base_dashboard";
43
57
  import type {
@@ -52,25 +66,84 @@ const overview = ref<BaseDashboardOverview>();
52
66
  const trend = ref<BaseDashboardTrendPoint[]>([]);
53
67
  const loginDistribution = ref<BaseDashboardDistributionItem[]>([]);
54
68
  const operationDistribution = ref<BaseDashboardDistributionItem[]>([]);
55
-
69
+ const chartTheme = ref({
70
+ primary: "#409eff",
71
+ success: "#67c23a",
72
+ warning: "#e6a23c",
73
+ info: "#909399",
74
+ danger: "#f56c6c",
75
+ text: "#606266",
76
+ divider: "#e4e7ed",
77
+ background: "#ffffff"
78
+ });
56
79
  const metrics = computed(() => [
57
80
  { key: "users", label: t("system.dashboard.users"), value: overview.value?.user_count ?? 0, icon: User, tone: "primary" },
58
81
  { key: "roles", label: t("system.dashboard.roles"), value: overview.value?.role_count ?? 0, icon: UserFilled, tone: "success" },
59
- { key: "logins", label: t("system.dashboard.today_logins"), value: overview.value?.today_login_count ?? 0, icon: Timer, tone: "warning" },
60
- { key: "operations", label: t("system.dashboard.today_operations"), value: overview.value?.today_operation_count ?? 0, icon: Operation, tone: "danger" }
82
+ {
83
+ key: "logins",
84
+ label: t("system.dashboard.today_logins"),
85
+ value: overview.value?.today_login_count ?? 0,
86
+ icon: Timer,
87
+ tone: "warning"
88
+ },
89
+ {
90
+ key: "operations",
91
+ label: t("system.dashboard.today_operations"),
92
+ value: overview.value?.today_operation_count ?? 0,
93
+ icon: Operation,
94
+ tone: "danger"
95
+ }
61
96
  ]);
97
+ const loginDistributionTotal = computed(() => loginDistribution.value.reduce((total, item) => total + item.count, 0));
98
+ const operationDistributionTotal = computed(() => operationDistribution.value.reduce((total, item) => total + item.count, 0));
62
99
 
100
+ function readThemeColor(name: string, fallback: string) {
101
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
102
+ }
103
+ function refreshChartTheme() {
104
+ chartTheme.value = {
105
+ primary: readThemeColor("--el-color-primary", "#409eff"),
106
+ success: readThemeColor("--el-color-success", "#67c23a"),
107
+ warning: readThemeColor("--el-color-warning", "#e6a23c"),
108
+ info: readThemeColor("--el-color-info", "#909399"),
109
+ danger: readThemeColor("--el-color-danger", "#f56c6c"),
110
+ text: readThemeColor("--el-text-color-secondary", "#606266"),
111
+ divider: readThemeColor("--el-border-color-lighter", "#e4e7ed"),
112
+ background: readThemeColor("--el-bg-color", "#ffffff")
113
+ };
114
+ }
63
115
  const loginTrendOption = computed(() => ({
116
+ color: [chartTheme.value.primary],
64
117
  tooltip: { trigger: "axis" as const },
65
- grid: { left: 40, right: 20, top: 24, bottom: 32 },
66
- xAxis: { type: "category" as const, boundaryGap: false, data: trend.value.map((item) => item.date) },
67
- yAxis: { type: "value" as const, minInterval: 1 },
68
- series: [{ type: "line" as const, smooth: true, data: trend.value.map((item) => item.count), areaStyle: {} }]
118
+ grid: { left: 42, right: 20, top: 20, bottom: 28 },
119
+ xAxis: {
120
+ type: "category" as const,
121
+ boundaryGap: false,
122
+ data: trend.value.map(item => item.date),
123
+ axisLine: { lineStyle: { color: chartTheme.value.divider } },
124
+ axisLabel: { color: chartTheme.value.text }
125
+ },
126
+ yAxis: {
127
+ type: "value" as const,
128
+ minInterval: 1,
129
+ splitLine: { lineStyle: { color: chartTheme.value.divider } },
130
+ axisLabel: { color: chartTheme.value.text }
131
+ },
132
+ series: [
133
+ {
134
+ type: "line" as const,
135
+ smooth: true,
136
+ data: trend.value.map(item => item.count),
137
+ symbol: "circle",
138
+ symbolSize: 7,
139
+ lineStyle: { width: 3, color: chartTheme.value.primary },
140
+ itemStyle: { color: chartTheme.value.primary, borderWidth: 2, borderColor: chartTheme.value.background },
141
+ areaStyle: { color: chartTheme.value.primary, opacity: 0.12 }
142
+ }
143
+ ]
69
144
  }));
70
-
71
145
  const loginDistributionOption = computed(() => distributionOption(loginDistribution.value));
72
146
  const operationDistributionOption = computed(() => distributionOption(operationDistribution.value));
73
-
74
147
  async function loadDashboard() {
75
148
  loading.value = true;
76
149
  try {
@@ -85,12 +158,11 @@ async function loadDashboard() {
85
158
  loginDistribution.value = loginResponse.items ?? [];
86
159
  operationDistribution.value = operationResponse.items ?? [];
87
160
  } catch {
88
- // 请求层已展示具体错误(包括强制改密跳转),页面不再重复弹出笼统提示。
161
+ /* 请求层已展示具体错误。 */
89
162
  } finally {
90
163
  loading.value = false;
91
164
  }
92
165
  }
93
-
94
166
  /** 将审计技术枚举名转换为当前语言的展示文案。 */
95
167
  function distributionItemLabel(label: string) {
96
168
  const mappings = [
@@ -102,67 +174,109 @@ function distributionItemLabel(label: string) {
102
174
  const value = label.slice(mapping[0].length).toLowerCase();
103
175
  return value === "unspecified" ? t("common.message.unknown") : t(`${mapping[1]}${value}`);
104
176
  }
105
-
106
177
  /** 创建使用本地化图例的分布图配置。 */
107
178
  function distributionOption(items: BaseDashboardDistributionItem[]) {
108
179
  return {
180
+ color: [
181
+ chartTheme.value.primary,
182
+ chartTheme.value.success,
183
+ chartTheme.value.info,
184
+ chartTheme.value.warning,
185
+ chartTheme.value.danger
186
+ ],
109
187
  tooltip: { trigger: "item" as const },
110
- legend: { bottom: 0, type: "scroll" as const },
111
- series: [{ type: "pie" as const, radius: ["38%", "68%"] as [string, string], center: ["50%", "45%"] as [string, string], data: items.map((item) => ({ name: distributionItemLabel(item.label), value: item.count })) }]
188
+ legend: { bottom: 0, type: "scroll" as const, textStyle: { color: chartTheme.value.text } },
189
+ series: [
190
+ {
191
+ type: "pie" as const,
192
+ radius: ["55%", "76%"] as [string, string],
193
+ center: ["50%", "43%"] as [string, string],
194
+ label: { color: chartTheme.value.text },
195
+ itemStyle: { borderColor: chartTheme.value.background, borderWidth: 3 },
196
+ data: items.map(item => ({ name: distributionItemLabel(item.label), value: item.count }))
197
+ }
198
+ ]
112
199
  };
113
200
  }
114
-
115
- onMounted(loadDashboard);
201
+ onMounted(() => {
202
+ refreshChartTheme();
203
+ loadDashboard();
204
+ window.addEventListener("theme-change", refreshChartTheme);
205
+ });
206
+ onBeforeUnmount(() => window.removeEventListener("theme-change", refreshChartTheme));
116
207
  </script>
117
208
 
118
209
  <style scoped>
119
210
  .dashboard-page {
120
211
  min-width: 0;
121
212
  }
213
+ .dashboard-heading {
214
+ display: flex;
215
+ align-items: flex-end;
216
+ justify-content: space-between;
217
+ margin-bottom: 22px;
218
+ }
219
+ .dashboard-eyebrow {
220
+ display: block;
221
+ color: var(--admin-page-text-placeholder);
222
+ font-size: 11px;
223
+ letter-spacing: 0.12em;
224
+ }
225
+ .dashboard-heading h1 {
226
+ margin: 6px 0 0;
227
+ color: var(--admin-page-text-primary);
228
+ font-size: 26px;
229
+ line-height: 1.2;
230
+ }
231
+ .dashboard-updated {
232
+ color: var(--admin-page-text-placeholder);
233
+ font-size: 12px;
234
+ }
122
235
  .metric-grid,
123
236
  .chart-grid {
124
237
  display: grid;
125
- gap: 10px;
238
+ gap: 16px;
126
239
  }
127
240
  .metric-grid {
128
241
  grid-template-columns: repeat(4, minmax(0, 1fr));
129
- margin-bottom: 10px;
242
+ margin-bottom: 16px;
243
+ }
244
+ .metric-card {
245
+ position: relative;
246
+ overflow: hidden;
130
247
  }
131
248
  .metric-card :deep(.el-card__body) {
249
+ min-height: 124px;
250
+ padding: 18px 20px 16px;
251
+ }
252
+ .metric-top {
132
253
  display: flex;
133
- gap: 16px;
134
254
  align-items: center;
135
255
  justify-content: space-between;
136
- min-height: 92px;
137
- }
138
- .metric-content {
139
- display: flex;
140
- flex-direction: column;
141
- gap: 10px;
142
- min-width: 0;
143
256
  }
144
257
  .metric-label {
258
+ color: var(--admin-page-text-secondary);
145
259
  font-size: 13px;
146
- color: var(--el-text-color-secondary);
147
260
  }
148
261
  .metric-value {
149
- font-size: 28px;
262
+ display: block;
263
+ margin-top: 18px;
264
+ color: var(--admin-page-text-primary);
265
+ font-size: 32px;
150
266
  line-height: 1;
151
- color: var(--el-text-color-primary);
267
+ font-variant-numeric: tabular-nums;
152
268
  }
153
269
  .metric-icon {
154
- display: flex;
155
- flex: 0 0 48px;
156
- align-items: center;
157
- justify-content: center;
158
- width: 48px;
159
- height: 48px;
270
+ display: grid;
271
+ width: 34px;
272
+ height: 34px;
273
+ place-items: center;
274
+ border-radius: 10px;
160
275
  color: var(--metric-color);
161
276
  background: var(--metric-bg);
162
- border-radius: var(--admin-page-radius);
163
277
  }
164
278
  .metric-icon .el-icon {
165
- font-size: 24px;
279
+ font-size: 17px;
166
280
  }
167
281
  .metric-card--primary {
168
282
  --metric-color: var(--el-color-primary);
@@ -186,11 +300,45 @@ onMounted(loadDashboard);
186
300
  .chart-card-wide {
187
301
  grid-column: 1 / -1;
188
302
  }
303
+ .chart-card :deep(.el-card__header) {
304
+ padding: 17px 22px 14px;
305
+ border-bottom-color: var(--admin-page-divider);
306
+ }
307
+ .chart-card :deep(.el-card__body) {
308
+ padding: 0 20px 16px;
309
+ }
310
+ .chart-heading {
311
+ display: flex;
312
+ align-items: center;
313
+ justify-content: space-between;
314
+ }
315
+ .chart-heading span {
316
+ color: var(--admin-page-text-primary);
317
+ font-size: 16px;
318
+ font-weight: 600;
319
+ }
320
+ .chart-heading small {
321
+ color: var(--admin-page-text-placeholder);
322
+ font-size: 12px;
323
+ font-weight: 400;
324
+ }
189
325
  .chart-wrap {
190
- height: 320px;
326
+ height: 285px;
327
+ }
328
+ .chart-card:not(.chart-card-wide) .chart-wrap {
329
+ height: 265px;
191
330
  }
192
-
193
331
  @media (width <= 960px) {
332
+ .metric-grid {
333
+ grid-template-columns: repeat(2, minmax(0, 1fr));
334
+ }
335
+ }
336
+ @media (width <= 640px) {
337
+ .dashboard-heading {
338
+ align-items: flex-start;
339
+ flex-direction: column;
340
+ gap: 8px;
341
+ }
194
342
  .metric-grid,
195
343
  .chart-grid {
196
344
  grid-template-columns: 1fr;