node-resource-utils 0.0.0-stage → 1.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +35 -2
- package/lib/sync.js +121 -0
- package/package.json +14 -4
- package/resources/.cache/ref.b64 +1 -0
- package/resources/README.md +97 -0
- package/resources/_.env +6 -0
- package/resources/_.env.example +25 -0
- package/resources/_.gitignore +18 -0
- package/resources/_.lintstagedrc.json +9 -0
- package/resources/_.npmrc +5 -0
- package/resources/_.prettierrc +7 -0
- package/resources/commitlint.config.mjs +1 -0
- package/resources/content/docs/README.md +63 -0
- package/resources/content/docs/faq.md +107 -0
- package/resources/content/docs/features/_meta.json +5 -0
- package/resources/content/docs/features/ai.md +71 -0
- package/resources/content/docs/features/appearance.md +47 -0
- package/resources/content/docs/features/feedback.md +79 -0
- package/resources/content/docs/features/files.md +74 -0
- package/resources/content/docs/features/graph.md +66 -0
- package/resources/content/docs/features/inbox.md +61 -0
- package/resources/content/docs/guide/_meta.json +5 -0
- package/resources/content/docs/guide/account.md +76 -0
- package/resources/content/docs/guide/data.md +87 -0
- package/resources/content/docs/guide/navigation.md +81 -0
- package/resources/content/docs/guide/permissions.md +67 -0
- package/resources/content/docs/guide/system.md +92 -0
- package/resources/content/docs/quick-start.md +70 -0
- package/resources/content/docs/shortcuts.md +69 -0
- package/resources/eslint.config.mjs +17 -0
- package/resources/next-env.d.ts +6 -0
- package/resources/next.config.ts +13 -0
- package/resources/postcss.config.mjs +7 -0
- package/resources/prisma/org.config.ts +98 -0
- package/resources/prisma/schema.prisma +336 -0
- package/resources/prisma/seed.ts +721 -0
- package/resources/prisma/seedSafe.ts +347 -0
- package/resources/prisma.config.ts +14 -0
- package/resources/scripts/check-config.mjs +773 -0
- package/resources/scripts/lib/schema-graph.mjs +410 -0
- package/resources/scripts/patch-next-trace.mjs +86 -0
- package/resources/scripts/suggest-relations.mjs +105 -0
- package/resources/src/app/(admin)/ai/page.tsx +469 -0
- package/resources/src/app/(admin)/dashboard/page.tsx +260 -0
- package/resources/src/app/(admin)/demo/employees/page.tsx +508 -0
- package/resources/src/app/(admin)/demo/files/page.tsx +451 -0
- package/resources/src/app/(admin)/demo/loading/page.tsx +513 -0
- package/resources/src/app/(admin)/error.tsx +96 -0
- package/resources/src/app/(admin)/feedback/page.tsx +573 -0
- package/resources/src/app/(admin)/graph/page.tsx +1224 -0
- package/resources/src/app/(admin)/inbox/page.tsx +31 -0
- package/resources/src/app/(admin)/layout.tsx +58 -0
- package/resources/src/app/(admin)/loading.tsx +31 -0
- package/resources/src/app/(admin)/system/depts/page.tsx +415 -0
- package/resources/src/app/(admin)/system/logs/page.tsx +215 -0
- package/resources/src/app/(admin)/system/menus/page.tsx +403 -0
- package/resources/src/app/(admin)/system/roles/page.tsx +433 -0
- package/resources/src/app/(admin)/system/users/page.tsx +620 -0
- package/resources/src/app/api/account-recovery/route.ts +132 -0
- package/resources/src/app/api/ai/act/route.ts +70 -0
- package/resources/src/app/api/ai/ask/route.ts +82 -0
- package/resources/src/app/api/ai/chat/route.ts +404 -0
- package/resources/src/app/api/ai/report/route.ts +164 -0
- package/resources/src/app/api/auth/[...nextauth]/route.ts +3 -0
- package/resources/src/app/api/auth/change-password/route.ts +57 -0
- package/resources/src/app/api/auth/forgot/route.ts +98 -0
- package/resources/src/app/api/auth/register/route.ts +95 -0
- package/resources/src/app/api/auth/register-config/route.ts +51 -0
- package/resources/src/app/api/badges/route.ts +39 -0
- package/resources/src/app/api/depts/[id]/route.ts +145 -0
- package/resources/src/app/api/depts/route.ts +126 -0
- package/resources/src/app/api/docs/export/route.ts +87 -0
- package/resources/src/app/api/docs/parse/route.ts +57 -0
- package/resources/src/app/api/docs/search/route.ts +22 -0
- package/resources/src/app/api/employees/[id]/route.ts +147 -0
- package/resources/src/app/api/employees/export/route.ts +115 -0
- package/resources/src/app/api/employees/import/route.ts +205 -0
- package/resources/src/app/api/employees/route.ts +149 -0
- package/resources/src/app/api/export/route.ts +254 -0
- package/resources/src/app/api/favorites/route.ts +56 -0
- package/resources/src/app/api/feedback/ai/route.ts +31 -0
- package/resources/src/app/api/feedback/approve/route.ts +67 -0
- package/resources/src/app/api/feedback/report/route.ts +213 -0
- package/resources/src/app/api/feedback/route.ts +246 -0
- package/resources/src/app/api/files/[name]/route.ts +82 -0
- package/resources/src/app/api/graph/focus/route.ts +75 -0
- package/resources/src/app/api/graph/node/move/route.ts +48 -0
- package/resources/src/app/api/graph/node/route.ts +274 -0
- package/resources/src/app/api/graph/route.ts +272 -0
- package/resources/src/app/api/health/route.ts +11 -0
- package/resources/src/app/api/inbox/read/route.ts +36 -0
- package/resources/src/app/api/inbox/summary/route.ts +15 -0
- package/resources/src/app/api/logs/route.ts +82 -0
- package/resources/src/app/api/menus/[id]/route.ts +132 -0
- package/resources/src/app/api/menus/mine/route.ts +23 -0
- package/resources/src/app/api/menus/route.ts +89 -0
- package/resources/src/app/api/roles/[id]/menus/route.ts +73 -0
- package/resources/src/app/api/roles/[id]/route.ts +96 -0
- package/resources/src/app/api/roles/route.ts +114 -0
- package/resources/src/app/api/search/route.ts +22 -0
- package/resources/src/app/api/stats/route.ts +92 -0
- package/resources/src/app/api/upload/route.ts +38 -0
- package/resources/src/app/api/users/[id]/password/route.ts +55 -0
- package/resources/src/app/api/users/[id]/route.ts +145 -0
- package/resources/src/app/api/users/route.ts +153 -0
- package/resources/src/app/api/visits/route.ts +48 -0
- package/resources/src/app/change-password/ChangePasswordForm.tsx +174 -0
- package/resources/src/app/change-password/page.tsx +19 -0
- package/resources/src/app/docs/[[...slug]]/page.tsx +56 -0
- package/resources/src/app/docs/layout.tsx +34 -0
- package/resources/src/app/docs/loading.tsx +19 -0
- package/resources/src/app/docs/not-found.tsx +25 -0
- package/resources/src/app/forgot/page.tsx +392 -0
- package/resources/src/app/global-error.tsx +75 -0
- package/resources/src/app/globals.css +235 -0
- package/resources/src/app/layout.tsx +44 -0
- package/resources/src/app/login/page.tsx +267 -0
- package/resources/src/app/page.tsx +14 -0
- package/resources/src/app/register/page.tsx +237 -0
- package/resources/src/auth.ts +215 -0
- package/resources/src/components/AiPanel.tsx +970 -0
- package/resources/src/components/AuthContext.tsx +82 -0
- package/resources/src/components/DataTable.tsx +459 -0
- package/resources/src/components/EChart.tsx +140 -0
- package/resources/src/components/FeedbackAiModal.tsx +476 -0
- package/resources/src/components/FeedbackReportModal.tsx +479 -0
- package/resources/src/components/Markdown.tsx +532 -0
- package/resources/src/components/OntologyGraph.tsx +1196 -0
- package/resources/src/components/Providers.tsx +99 -0
- package/resources/src/components/VisitTracker.tsx +89 -0
- package/resources/src/components/admin/AdminShell.tsx +749 -0
- package/resources/src/components/admin/CountBadge.tsx +67 -0
- package/resources/src/components/admin/GlobalSearch.tsx +509 -0
- package/resources/src/components/admin/InboxBell.tsx +78 -0
- package/resources/src/components/admin/VisitStats.tsx +145 -0
- package/resources/src/components/admin/menu-icons.tsx +56 -0
- package/resources/src/components/ai/AiInputForm.tsx +351 -0
- package/resources/src/components/appearance/AppearanceDrawer.tsx +268 -0
- package/resources/src/components/appearance/AppearanceProvider.tsx +160 -0
- package/resources/src/components/docs/DocArticle.tsx +106 -0
- package/resources/src/components/docs/DocToc.tsx +100 -0
- package/resources/src/components/docs/DocsChrome.tsx +259 -0
- package/resources/src/components/docs/DocsHome.tsx +209 -0
- package/resources/src/components/docs/DocsSearch.tsx +365 -0
- package/resources/src/components/docs/docs.css +1458 -0
- package/resources/src/components/feedback/AsyncButton.tsx +108 -0
- package/resources/src/components/feedback/LoadingOverlay.tsx +125 -0
- package/resources/src/components/feedback/PressableCard.tsx +92 -0
- package/resources/src/components/feedback/README.md +97 -0
- package/resources/src/components/feedback/RouteProgress.tsx +108 -0
- package/resources/src/components/feedback/Skeleton.tsx +249 -0
- package/resources/src/components/feedback/StatCard.tsx +70 -0
- package/resources/src/components/feedback/TaskHint.tsx +153 -0
- package/resources/src/components/feedback/index.ts +93 -0
- package/resources/src/components/feedback/motion.css +204 -0
- package/resources/src/components/feedback/motion.ts +63 -0
- package/resources/src/components/feedback/useCountUp.ts +110 -0
- package/resources/src/components/feedback/usePending.ts +209 -0
- package/resources/src/components/feedback/useRowFlash.ts +40 -0
- package/resources/src/components/inbox/InboxView.tsx +409 -0
- package/resources/src/components/ui/button.tsx +35 -0
- package/resources/src/components/ui/card.tsx +37 -0
- package/resources/src/components/ui/input.tsx +19 -0
- package/resources/src/lib/ai.ts +310 -0
- package/resources/src/lib/aiContext.ts +357 -0
- package/resources/src/lib/aiForm.test.ts +191 -0
- package/resources/src/lib/aiForm.ts +532 -0
- package/resources/src/lib/aiFormTypes.ts +83 -0
- package/resources/src/lib/aiToken.ts +72 -0
- package/resources/src/lib/aiTools.ts +823 -0
- package/resources/src/lib/api.ts +160 -0
- package/resources/src/lib/appInfo.ts +128 -0
- package/resources/src/lib/appearance.ts +643 -0
- package/resources/src/lib/badge-count.ts +146 -0
- package/resources/src/lib/badges.test.ts +58 -0
- package/resources/src/lib/badges.ts +79 -0
- package/resources/src/lib/brand-mark.test.ts +87 -0
- package/resources/src/lib/brand-mark.ts +45 -0
- package/resources/src/lib/docs-shared.test.ts +148 -0
- package/resources/src/lib/docs-shared.ts +422 -0
- package/resources/src/lib/docs.ts +381 -0
- package/resources/src/lib/err-text.ts +21 -0
- package/resources/src/lib/excel.ts +152 -0
- package/resources/src/lib/favorite-path.ts +60 -0
- package/resources/src/lib/favorites.ts +59 -0
- package/resources/src/lib/features.ts +69 -0
- package/resources/src/lib/feedback-access.ts +17 -0
- package/resources/src/lib/feedbackAi.ts +1621 -0
- package/resources/src/lib/formRules.ts +76 -0
- package/resources/src/lib/format.ts +52 -0
- package/resources/src/lib/graph-cascade.ts +304 -0
- package/resources/src/lib/graph-focus.ts +301 -0
- package/resources/src/lib/graph-link.ts +17 -0
- package/resources/src/lib/graph-node-write.ts +150 -0
- package/resources/src/lib/graph-schema.ts +475 -0
- package/resources/src/lib/graph-scope.test.ts +46 -0
- package/resources/src/lib/graph-scope.ts +28 -0
- package/resources/src/lib/http.ts +157 -0
- package/resources/src/lib/inbox-types.ts +57 -0
- package/resources/src/lib/inbox.ts +55 -0
- package/resources/src/lib/menu-service.ts +204 -0
- package/resources/src/lib/notifications.ts +113 -0
- package/resources/src/lib/office.ts +531 -0
- package/resources/src/lib/ontology.ts +277 -0
- package/resources/src/lib/openaiCompat.ts +257 -0
- package/resources/src/lib/pending.ts +98 -0
- package/resources/src/lib/prisma.ts +30 -0
- package/resources/src/lib/rbac.test.ts +106 -0
- package/resources/src/lib/rbac.ts +185 -0
- package/resources/src/lib/search-data.ts +158 -0
- package/resources/src/lib/security-questions.ts +20 -0
- package/resources/src/lib/skin-theme.ts +155 -0
- package/resources/src/lib/system-config.ts +55 -0
- package/resources/src/lib/theme.ts +96 -0
- package/resources/src/lib/todos/feedback.ts +86 -0
- package/resources/src/lib/todos/index.ts +73 -0
- package/resources/src/lib/todos/types.ts +33 -0
- package/resources/src/lib/upload.ts +106 -0
- package/resources/src/lib/utils.test.ts +9 -0
- package/resources/src/lib/utils.ts +6 -0
- package/resources/src/lib/validation.test.ts +297 -0
- package/resources/src/lib/validation.ts +658 -0
- package/resources/src/lib/visit-stats.ts +109 -0
- package/resources/src/lib/visits.ts +111 -0
- package/resources/src/lib/zodField.ts +44 -0
- package/resources/tsconfig.json +41 -0
- package/resources/vitest.config.ts +16 -0
- package/resources/vitest.setup.ts +1 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
4
|
+
import type { SessionUser } from '@/auth';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 客户端权限上下文。
|
|
8
|
+
*
|
|
9
|
+
* 权限集合由服务端 layout 一次性算好并传下来(见 (admin)/layout.tsx),
|
|
10
|
+
* 前端的按钮显隐只是「体验优化」,真正的拦截永远在接口层(requirePerm)。
|
|
11
|
+
* 所以这里不需要额外的网络请求,纯内存判断即可。
|
|
12
|
+
*
|
|
13
|
+
* 注意:不要在这个文件里 import '@/lib/rbac' —— 那个模块引了 next/server,
|
|
14
|
+
* 会污染客户端产物。超管标识在此本地判断即可。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const PERM_SUPER = '*';
|
|
18
|
+
|
|
19
|
+
export type AuthValue = {
|
|
20
|
+
user: SessionUser | null;
|
|
21
|
+
perms: string[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type AuthContextType = {
|
|
25
|
+
user: SessionUser | null;
|
|
26
|
+
perms: string[];
|
|
27
|
+
/** 任一命中即可;不传 code 表示「登录即可」 */
|
|
28
|
+
hasPerm: (code?: string | string[]) => boolean;
|
|
29
|
+
/** 必须全部命中 */
|
|
30
|
+
hasAll: (codes: string[]) => boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const AuthContext = createContext<AuthContextType>({
|
|
34
|
+
user: null,
|
|
35
|
+
perms: [],
|
|
36
|
+
hasPerm: () => false,
|
|
37
|
+
hasAll: () => false,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export function AuthProvider({ value, children }: { value: AuthValue; children: ReactNode }) {
|
|
41
|
+
const ctx = useMemo<AuthContextType>(() => {
|
|
42
|
+
const user = value.user;
|
|
43
|
+
const perms = value.perms ?? [];
|
|
44
|
+
const isSuper = Boolean(user?.isSuper) || perms.includes(PERM_SUPER);
|
|
45
|
+
|
|
46
|
+
const hasPerm = (code?: string | string[]) => {
|
|
47
|
+
if (!user) return false;
|
|
48
|
+
if (!code) return true;
|
|
49
|
+
if (isSuper) return true;
|
|
50
|
+
const need = Array.isArray(code) ? code : [code];
|
|
51
|
+
return need.some((c) => perms.includes(c));
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const hasAll = (codes: string[]) => {
|
|
55
|
+
if (!user) return false;
|
|
56
|
+
if (isSuper) return true;
|
|
57
|
+
return codes.every((c) => perms.includes(c));
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return { user, perms, hasPerm, hasAll };
|
|
61
|
+
}, [value]);
|
|
62
|
+
|
|
63
|
+
return <AuthContext.Provider value={ctx}>{children}</AuthContext.Provider>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function useAuth(): AuthContextType {
|
|
67
|
+
return useContext(AuthContext);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 权限包裹组件:无权限时渲染 fallback(默认什么都不渲染) */
|
|
71
|
+
export function Can({
|
|
72
|
+
perm,
|
|
73
|
+
fallback = null,
|
|
74
|
+
children,
|
|
75
|
+
}: {
|
|
76
|
+
perm?: string | string[];
|
|
77
|
+
fallback?: ReactNode;
|
|
78
|
+
children: ReactNode;
|
|
79
|
+
}) {
|
|
80
|
+
const { hasPerm } = useAuth();
|
|
81
|
+
return <>{hasPerm(perm) ? children : fallback}</>;
|
|
82
|
+
}
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState, type Key, type ReactNode } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
App,
|
|
6
|
+
Button,
|
|
7
|
+
Checkbox,
|
|
8
|
+
DatePicker,
|
|
9
|
+
Dropdown,
|
|
10
|
+
Form,
|
|
11
|
+
Input,
|
|
12
|
+
Select,
|
|
13
|
+
Space,
|
|
14
|
+
Table,
|
|
15
|
+
Tooltip,
|
|
16
|
+
} from 'antd';
|
|
17
|
+
import type { ColumnsType, ColumnType } from 'antd/es/table';
|
|
18
|
+
import {
|
|
19
|
+
DeleteOutlined,
|
|
20
|
+
DownOutlined,
|
|
21
|
+
PlusOutlined,
|
|
22
|
+
ReloadOutlined,
|
|
23
|
+
SearchOutlined,
|
|
24
|
+
SettingOutlined,
|
|
25
|
+
UploadOutlined,
|
|
26
|
+
} from '@ant-design/icons';
|
|
27
|
+
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
|
28
|
+
import axios from 'axios';
|
|
29
|
+
import dayjs from 'dayjs';
|
|
30
|
+
import { errText } from '@/lib/err-text';
|
|
31
|
+
import { SkeletonTable, flashTableRow } from '@/components/feedback';
|
|
32
|
+
import { MILITARY } from '@/lib/theme';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 通用 CRUD 表格。
|
|
36
|
+
*
|
|
37
|
+
* 模板里 5 个列表页(用户/角色/日志/员工…)长得几乎一样,
|
|
38
|
+
* 差别只在「列定义 + 搜索项 + 顶部按钮」。把这四样做成 props,
|
|
39
|
+
* 新增一个业务列表页就从「复制 200 行再改」变成「写 30 行配置」。
|
|
40
|
+
*
|
|
41
|
+
* 约定:后端列表接口统一返回 pageResult 形状 { code, data: { list, total, page, pageSize } }。
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export type SearchField = {
|
|
45
|
+
name: string;
|
|
46
|
+
label: string;
|
|
47
|
+
type?: 'text' | 'select' | 'dateRange';
|
|
48
|
+
options?: { label: string; value: string | number }[];
|
|
49
|
+
placeholder?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type DataTableProps<T extends object> = {
|
|
53
|
+
/** 列表接口地址,例如 /api/users */
|
|
54
|
+
api: string;
|
|
55
|
+
columns: ColumnsType<T>;
|
|
56
|
+
rowKey?: string;
|
|
57
|
+
searchFields?: SearchField[];
|
|
58
|
+
/** 顶部右侧自定义按钮区 */
|
|
59
|
+
toolbar?: ReactNode;
|
|
60
|
+
/** 固定附加查询参数(如列表按模块过滤) */
|
|
61
|
+
extraQuery?: Record<string, unknown>;
|
|
62
|
+
/** 提供后展示「导出 Excel」按钮,走 /api/export */
|
|
63
|
+
exportable?: { filename: string; module: string };
|
|
64
|
+
/** 提供后展示「新增」按钮 */
|
|
65
|
+
canAdd?: boolean;
|
|
66
|
+
onAdd?: () => void;
|
|
67
|
+
addText?: string;
|
|
68
|
+
/** 开启行选择(批量删除) */
|
|
69
|
+
selectable?: boolean;
|
|
70
|
+
onBatchDelete?: (keys: Key[]) => Promise<void> | void;
|
|
71
|
+
/** 父组件增删改成功后自增这个值即可让表格重新拉数据 */
|
|
72
|
+
refreshKey?: number;
|
|
73
|
+
/**
|
|
74
|
+
* 数据刷新后要「闪一下」的行,回答的是「刚才改的是哪一行」。
|
|
75
|
+
* 传 { key, tick }:tick 每次自增,同一行连续触发也能重播动画。
|
|
76
|
+
* 只在数据真的重新拉回来之后才闪,避免闪到还没更新的旧行上。
|
|
77
|
+
*/
|
|
78
|
+
highlightRow?: { key: string | number; tick: number } | null;
|
|
79
|
+
scrollX?: number;
|
|
80
|
+
defaultPageSize?: number;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type PageData<T> = { list: T[]; total: number; page: number; pageSize: number };
|
|
84
|
+
|
|
85
|
+
/** 统一走 lib/err-text:后端业务失败也是 HTTP 200 + message,不能只看 axios 的错误 */
|
|
86
|
+
function errMsg(e: unknown): string {
|
|
87
|
+
return errText(e, '加载失败');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 触发浏览器下载,用完及时 revoke,避免内存泄漏 */
|
|
91
|
+
function downloadBlob(data: Blob, filename: string) {
|
|
92
|
+
const url = window.URL.createObjectURL(data);
|
|
93
|
+
const a = document.createElement('a');
|
|
94
|
+
a.href = url;
|
|
95
|
+
a.download = filename;
|
|
96
|
+
document.body.appendChild(a);
|
|
97
|
+
a.click();
|
|
98
|
+
a.remove();
|
|
99
|
+
window.URL.revokeObjectURL(url);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 取列的稳定标识:key 优先,其次 dataIndex,最后用下标 */
|
|
103
|
+
function colKey<T extends object>(col: ColumnType<T>, index: number): string {
|
|
104
|
+
const k = col.key ?? (Array.isArray(col.dataIndex) ? col.dataIndex.join('.') : col.dataIndex);
|
|
105
|
+
return k === undefined || k === null ? `col-${index}` : String(k);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export default function DataTable<T extends object>({
|
|
109
|
+
api,
|
|
110
|
+
columns,
|
|
111
|
+
rowKey = 'id',
|
|
112
|
+
searchFields,
|
|
113
|
+
toolbar,
|
|
114
|
+
extraQuery,
|
|
115
|
+
exportable,
|
|
116
|
+
canAdd,
|
|
117
|
+
onAdd,
|
|
118
|
+
addText = '新增',
|
|
119
|
+
selectable,
|
|
120
|
+
onBatchDelete,
|
|
121
|
+
refreshKey = 0,
|
|
122
|
+
highlightRow,
|
|
123
|
+
scrollX,
|
|
124
|
+
defaultPageSize = 10,
|
|
125
|
+
}: DataTableProps<T>) {
|
|
126
|
+
const { message, modal } = App.useApp();
|
|
127
|
+
const [form] = Form.useForm();
|
|
128
|
+
|
|
129
|
+
const [page, setPage] = useState(1);
|
|
130
|
+
const [pageSize, setPageSize] = useState(defaultPageSize);
|
|
131
|
+
const [keyword, setKeyword] = useState('');
|
|
132
|
+
const [filters, setFilters] = useState<Record<string, unknown>>({});
|
|
133
|
+
const [sort, setSort] = useState<{ sortField: string; sortOrder: 'ascend' | 'descend' } | null>(null);
|
|
134
|
+
const [selectedKeys, setSelectedKeys] = useState<Key[]>([]);
|
|
135
|
+
const [hiddenCols, setHiddenCols] = useState<string[]>([]);
|
|
136
|
+
const [searchOpen, setSearchOpen] = useState(true);
|
|
137
|
+
/** 导出 Excel 可能要几秒(几千行 + 服务端 Workbook),按钮必须自己转圈 */
|
|
138
|
+
const [exporting, setExporting] = useState(false);
|
|
139
|
+
/** 批量删除:确认弹窗关掉之后表格还在等,按钮上要接着转 */
|
|
140
|
+
const [deleting, setDeleting] = useState(false);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 深链接:`/xxx?kw=张三` 自动把关键词填进筛选框。
|
|
144
|
+
* 全局搜索(Ctrl K)点"数据"结果就是跳到这种地址 ——
|
|
145
|
+
* 有了它,任何用 DataTable 的列表页都不用手写接收逻辑。
|
|
146
|
+
* 放在 effect 里而不是 useState 初始值:避免 SSR 读不到 window 造成水合不一致。
|
|
147
|
+
* ★ 表单里那个输入框是 Form.Item 自己管的,必须同时 setFieldValue,
|
|
148
|
+
* 否则会出现"列表已过滤、输入框却是空的"这种看不懂的状态。
|
|
149
|
+
*/
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
const kw = new URLSearchParams(window.location.search).get('kw');
|
|
152
|
+
if (kw) {
|
|
153
|
+
setKeyword(kw);
|
|
154
|
+
form.setFieldValue('keyword', kw);
|
|
155
|
+
}
|
|
156
|
+
}, [form]);
|
|
157
|
+
|
|
158
|
+
const params = useMemo(() => {
|
|
159
|
+
const p: Record<string, unknown> = { page, pageSize, ...extraQuery };
|
|
160
|
+
if (keyword.trim()) p.keyword = keyword.trim();
|
|
161
|
+
if (sort) Object.assign(p, sort);
|
|
162
|
+
for (const [k, v] of Object.entries(filters)) {
|
|
163
|
+
if (v !== undefined && v !== null && v !== '') p[k] = v;
|
|
164
|
+
}
|
|
165
|
+
return p;
|
|
166
|
+
}, [page, pageSize, keyword, sort, filters, extraQuery]);
|
|
167
|
+
|
|
168
|
+
// queryKey 用 JSON 串而不是对象:对象每次渲染都是新引用,会让 react-query 疯狂重拉
|
|
169
|
+
const paramsKey = JSON.stringify(params);
|
|
170
|
+
|
|
171
|
+
const query = useQuery({
|
|
172
|
+
queryKey: [api, paramsKey, refreshKey],
|
|
173
|
+
queryFn: async (): Promise<PageData<T>> => {
|
|
174
|
+
const { data } = await axios.get(api, { params });
|
|
175
|
+
if (!data || data.code !== 0) throw new Error(data?.message || '加载失败');
|
|
176
|
+
return data.data as PageData<T>;
|
|
177
|
+
},
|
|
178
|
+
placeholderData: keepPreviousData,
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
if (query.isError) message.error(errMsg(query.error));
|
|
183
|
+
}, [query.isError, query.error, message]);
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 「改的是这一行」的高亮。
|
|
187
|
+
*
|
|
188
|
+
* 时序是这里唯一需要小心的地方:
|
|
189
|
+
* 页面保存成功 → 设 highlightRow + refreshKey 自增 → 重新请求 → 数据换新。
|
|
190
|
+
* 如果在 highlightRow 一变就去找行,闪的是**旧数据那一行**,
|
|
191
|
+
* 等新数据回来时动画已经过半,看着像闪错了对象。
|
|
192
|
+
* 所以这里等 dataUpdatedAt 变了(数据真的落地)再找 DOM。
|
|
193
|
+
*/
|
|
194
|
+
const flashTarget = useRef<{ key: string | number; tick: number } | null>(null);
|
|
195
|
+
const flashedTick = useRef(-1);
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
flashTarget.current = highlightRow ?? null;
|
|
199
|
+
}, [highlightRow]);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
const target = flashTarget.current;
|
|
203
|
+
if (!target || flashedTick.current === target.tick) return;
|
|
204
|
+
|
|
205
|
+
// 60ms 让 antd 把新的 tbody 提交到 DOM;找不到行就留着下次数据变化再试
|
|
206
|
+
const timer = window.setTimeout(() => {
|
|
207
|
+
if (flashTableRow(target.key, target.tick)) flashedTick.current = target.tick;
|
|
208
|
+
}, 60);
|
|
209
|
+
return () => window.clearTimeout(timer);
|
|
210
|
+
}, [query.dataUpdatedAt]);
|
|
211
|
+
|
|
212
|
+
const onSearch = (values: Record<string, unknown>) => {
|
|
213
|
+
const next: Record<string, unknown> = {};
|
|
214
|
+
for (const f of searchFields ?? []) {
|
|
215
|
+
const v = values[f.name];
|
|
216
|
+
if (f.type === 'dateRange') {
|
|
217
|
+
if (Array.isArray(v) && v[0] && v[1]) {
|
|
218
|
+
next.startDate = dayjs(v[0]).format('YYYY-MM-DD');
|
|
219
|
+
next.endDate = dayjs(v[1]).format('YYYY-MM-DD');
|
|
220
|
+
}
|
|
221
|
+
} else if (v !== undefined && v !== null && v !== '') {
|
|
222
|
+
next[f.name] = v;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
setKeyword(String(values.keyword ?? '').trim());
|
|
226
|
+
setFilters(next);
|
|
227
|
+
setPage(1);
|
|
228
|
+
setSelectedKeys([]);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const onReset = () => {
|
|
232
|
+
form.resetFields();
|
|
233
|
+
setKeyword('');
|
|
234
|
+
setFilters({});
|
|
235
|
+
setSort(null);
|
|
236
|
+
setPage(1);
|
|
237
|
+
setSelectedKeys([]);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const handleExport = async () => {
|
|
241
|
+
if (!exportable || exporting) return;
|
|
242
|
+
setExporting(true);
|
|
243
|
+
try {
|
|
244
|
+
const res = await axios.get('/api/export', {
|
|
245
|
+
// module 必须放在最后:列表自身的筛选参数里也可能有 module 字段(日志页),
|
|
246
|
+
// 被它覆盖掉的话后端就分发不到对应模块了
|
|
247
|
+
params: { ...params, page: undefined, pageSize: undefined, module: exportable.module },
|
|
248
|
+
responseType: 'blob',
|
|
249
|
+
});
|
|
250
|
+
const ct = String(res.headers['content-type'] || '');
|
|
251
|
+
// 失败时后端返回的是 JSON(HTTP 仍是 200),要当错误处理而不是当文件下载
|
|
252
|
+
if (ct.includes('application/json')) {
|
|
253
|
+
const text = await (res.data as Blob).text();
|
|
254
|
+
const j = JSON.parse(text) as { message?: string };
|
|
255
|
+
throw new Error(j.message || '导出失败');
|
|
256
|
+
}
|
|
257
|
+
downloadBlob(res.data as Blob, exportable.filename);
|
|
258
|
+
message.success('导出成功');
|
|
259
|
+
} catch (e) {
|
|
260
|
+
message.error(`导出失败:${errMsg(e)}`);
|
|
261
|
+
} finally {
|
|
262
|
+
setExporting(false);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const doBatchDelete = () => {
|
|
267
|
+
if (!onBatchDelete) return;
|
|
268
|
+
modal.confirm({
|
|
269
|
+
title: '确认删除',
|
|
270
|
+
content: `已选中 ${selectedKeys.length} 条记录,删除后不可恢复,是否继续?`,
|
|
271
|
+
okText: '删除',
|
|
272
|
+
okButtonProps: { danger: true },
|
|
273
|
+
cancelText: '取消',
|
|
274
|
+
onOk: async () => {
|
|
275
|
+
if (deleting) return;
|
|
276
|
+
setDeleting(true);
|
|
277
|
+
try {
|
|
278
|
+
await onBatchDelete(selectedKeys);
|
|
279
|
+
setSelectedKeys([]);
|
|
280
|
+
} finally {
|
|
281
|
+
setDeleting(false);
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const visibleColumns = useMemo(
|
|
288
|
+
() => columns.filter((c, i) => !hiddenCols.includes(colKey(c, i))),
|
|
289
|
+
[columns, hiddenCols]
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const showBatch = Boolean(selectable && onBatchDelete && selectedKeys.length > 0);
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<div>
|
|
296
|
+
<Form
|
|
297
|
+
form={form}
|
|
298
|
+
layout="inline"
|
|
299
|
+
onFinish={onSearch}
|
|
300
|
+
style={{ rowGap: 12, marginBottom: 12, display: searchOpen ? undefined : 'none' }}
|
|
301
|
+
>
|
|
302
|
+
<Form.Item name="keyword" label="关键字" style={{ marginBottom: 12 }}>
|
|
303
|
+
<Input
|
|
304
|
+
allowClear
|
|
305
|
+
placeholder="输入关键字"
|
|
306
|
+
style={{ width: 180 }}
|
|
307
|
+
onPressEnter={() => form.submit()}
|
|
308
|
+
/>
|
|
309
|
+
</Form.Item>
|
|
310
|
+
{(searchFields ?? []).map((f) => (
|
|
311
|
+
<Form.Item key={f.name} name={f.name} label={f.label} style={{ marginBottom: 12 }}>
|
|
312
|
+
{f.type === 'select' ? (
|
|
313
|
+
<Select
|
|
314
|
+
allowClear
|
|
315
|
+
placeholder={f.placeholder ?? `请选择${f.label}`}
|
|
316
|
+
options={f.options}
|
|
317
|
+
style={{ width: 160 }}
|
|
318
|
+
/>
|
|
319
|
+
) : f.type === 'dateRange' ? (
|
|
320
|
+
<DatePicker.RangePicker allowClear />
|
|
321
|
+
) : (
|
|
322
|
+
<Input
|
|
323
|
+
allowClear
|
|
324
|
+
placeholder={f.placeholder ?? `请输入${f.label}`}
|
|
325
|
+
style={{ width: 160 }}
|
|
326
|
+
onPressEnter={() => form.submit()}
|
|
327
|
+
/>
|
|
328
|
+
)}
|
|
329
|
+
</Form.Item>
|
|
330
|
+
))}
|
|
331
|
+
<Form.Item style={{ marginBottom: 12 }}>
|
|
332
|
+
<Space>
|
|
333
|
+
<Button type="primary" htmlType="submit" icon={<SearchOutlined />}>
|
|
334
|
+
查询
|
|
335
|
+
</Button>
|
|
336
|
+
<Button onClick={onReset}>重置</Button>
|
|
337
|
+
</Space>
|
|
338
|
+
</Form.Item>
|
|
339
|
+
</Form>
|
|
340
|
+
|
|
341
|
+
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
|
342
|
+
{searchFields?.length ? (
|
|
343
|
+
<Button type="text" onClick={() => setSearchOpen((v) => !v)} icon={<DownOutlined rotate={searchOpen ? 180 : 0} />}>
|
|
344
|
+
{searchOpen ? '收起筛选' : '展开筛选'}
|
|
345
|
+
</Button>
|
|
346
|
+
) : null}
|
|
347
|
+
{toolbar}
|
|
348
|
+
{showBatch ? (
|
|
349
|
+
<Button danger icon={<DeleteOutlined />} loading={deleting} onClick={doBatchDelete}>
|
|
350
|
+
批量删除 ({selectedKeys.length})
|
|
351
|
+
</Button>
|
|
352
|
+
) : null}
|
|
353
|
+
{exportable ? (
|
|
354
|
+
<Button icon={<UploadOutlined />} loading={exporting} onClick={handleExport}>
|
|
355
|
+
导出 Excel
|
|
356
|
+
</Button>
|
|
357
|
+
) : null}
|
|
358
|
+
<Tooltip title="刷新">
|
|
359
|
+
{/* 首屏已经有骨架在表达"在加载",这里再转一圈就是重复信息;
|
|
360
|
+
只在"已有数据、正在刷新"时才让按钮转 */}
|
|
361
|
+
<Button
|
|
362
|
+
icon={<ReloadOutlined />}
|
|
363
|
+
loading={query.isFetching && !query.isLoading}
|
|
364
|
+
onClick={() => query.refetch()}
|
|
365
|
+
/>
|
|
366
|
+
</Tooltip>
|
|
367
|
+
<Dropdown
|
|
368
|
+
trigger={['click']}
|
|
369
|
+
popupRender={() => (
|
|
370
|
+
<div
|
|
371
|
+
style={{
|
|
372
|
+
background: MILITARY.card,
|
|
373
|
+
borderRadius: 8,
|
|
374
|
+
boxShadow: MILITARY.shadowSm,
|
|
375
|
+
padding: 12,
|
|
376
|
+
maxHeight: 320,
|
|
377
|
+
overflow: 'auto',
|
|
378
|
+
}}
|
|
379
|
+
>
|
|
380
|
+
{columns.map((c, i) => {
|
|
381
|
+
const k = colKey(c, i);
|
|
382
|
+
return (
|
|
383
|
+
<div key={k} style={{ padding: '4px 0' }}>
|
|
384
|
+
<Checkbox
|
|
385
|
+
checked={!hiddenCols.includes(k)}
|
|
386
|
+
onChange={(e) =>
|
|
387
|
+
setHiddenCols((prev) =>
|
|
388
|
+
e.target.checked ? prev.filter((x) => x !== k) : [...prev, k]
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
>
|
|
392
|
+
{String(c.title ?? k)}
|
|
393
|
+
</Checkbox>
|
|
394
|
+
</div>
|
|
395
|
+
);
|
|
396
|
+
})}
|
|
397
|
+
</div>
|
|
398
|
+
)}
|
|
399
|
+
>
|
|
400
|
+
<Button icon={<SettingOutlined />}>列设置</Button>
|
|
401
|
+
</Dropdown>
|
|
402
|
+
{canAdd ? (
|
|
403
|
+
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
|
|
404
|
+
{addText}
|
|
405
|
+
</Button>
|
|
406
|
+
) : null}
|
|
407
|
+
</div>
|
|
408
|
+
|
|
409
|
+
{/*
|
|
410
|
+
首屏用骨架而不是「空表格 + 转圈」:
|
|
411
|
+
表格骨架的形状和真表格一致,数据填进来时高度不跳,用户也不会以为"没数据"。
|
|
412
|
+
只在**首次**加载(还没有任何数据)时走这条;
|
|
413
|
+
翻页/筛选时 query.data 有旧值(keepPreviousData),继续走 Table 的蒙层。
|
|
414
|
+
*/}
|
|
415
|
+
{query.isLoading && !query.data ? (
|
|
416
|
+
<SkeletonTable
|
|
417
|
+
rows={Math.min(pageSize, 10)}
|
|
418
|
+
// 列太多时骨架会挤成一堆细条,收敛到 3~6 列更像真实表格
|
|
419
|
+
cols={Math.min(Math.max(visibleColumns.length, 3), 6)}
|
|
420
|
+
// 连分页条一起占位,数据回来时底部不跳
|
|
421
|
+
pagination
|
|
422
|
+
/>
|
|
423
|
+
) : (
|
|
424
|
+
<Table<T>
|
|
425
|
+
rowKey={rowKey}
|
|
426
|
+
columns={visibleColumns}
|
|
427
|
+
dataSource={query.data?.list ?? []}
|
|
428
|
+
// delay: 200ms 内返回的刷新看不到转圈,避免点一下筛就闪一片
|
|
429
|
+
loading={{ spinning: query.isFetching, delay: 200 }}
|
|
430
|
+
size="middle"
|
|
431
|
+
scroll={scrollX ? { x: scrollX } : undefined}
|
|
432
|
+
rowSelection={
|
|
433
|
+
selectable
|
|
434
|
+
? { selectedRowKeys: selectedKeys, onChange: (keys) => setSelectedKeys(keys) }
|
|
435
|
+
: undefined
|
|
436
|
+
}
|
|
437
|
+
pagination={{
|
|
438
|
+
current: page,
|
|
439
|
+
pageSize,
|
|
440
|
+
total: query.data?.total ?? 0,
|
|
441
|
+
showSizeChanger: true,
|
|
442
|
+
showQuickJumper: true,
|
|
443
|
+
showTotal: (t) => `共 ${t} 条`,
|
|
444
|
+
}}
|
|
445
|
+
onChange={(pg, _f, sorter) => {
|
|
446
|
+
setPage(pg.current ?? 1);
|
|
447
|
+
setPageSize(pg.pageSize ?? defaultPageSize);
|
|
448
|
+
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
|
449
|
+
if (s?.field && s.order) {
|
|
450
|
+
setSort({ sortField: String(s.field), sortOrder: s.order });
|
|
451
|
+
} else {
|
|
452
|
+
setSort(null);
|
|
453
|
+
}
|
|
454
|
+
}}
|
|
455
|
+
/>
|
|
456
|
+
)}
|
|
457
|
+
</div>
|
|
458
|
+
);
|
|
459
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
4
|
+
import { init, use as registerECharts, type ECharts, type EChartsCoreOption } from 'echarts/core';
|
|
5
|
+
import { BarChart, LineChart, PieChart, GraphChart } from 'echarts/charts';
|
|
6
|
+
import {
|
|
7
|
+
DatasetComponent,
|
|
8
|
+
GridComponent,
|
|
9
|
+
LegendComponent,
|
|
10
|
+
TitleComponent,
|
|
11
|
+
ToolboxComponent,
|
|
12
|
+
TooltipComponent,
|
|
13
|
+
TransformComponent,
|
|
14
|
+
} from 'echarts/components';
|
|
15
|
+
import { LabelLayout, UniversalTransition } from 'echarts/features';
|
|
16
|
+
import { CanvasRenderer } from 'echarts/renderers';
|
|
17
|
+
import { FONT_STACK } from '@/lib/appearance';
|
|
18
|
+
import { useSkinInk } from '@/components/appearance/AppearanceProvider';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 按需引入 echarts。
|
|
22
|
+
*
|
|
23
|
+
* 直接 `import * as echarts from 'echarts'` 会把全部图表类型(地图、K线、关系图…)
|
|
24
|
+
* 打进产物,动辄 1MB+。这里只 use 模板真正用到的柱/线/饼 + 基础组件,
|
|
25
|
+
* 首屏 JS 体积能小一大截 —— 内网机器上打开速度差别是肉眼可见的。
|
|
26
|
+
*/
|
|
27
|
+
// 注意:echarts 的注册函数就叫 use,直接 import 会被 eslint 的 rules-of-hooks 误判成 React Hook,
|
|
28
|
+
// 所以这里重命名成 registerECharts(不能叫 useXxx,否则同样会被当成 Hook)。
|
|
29
|
+
registerECharts([
|
|
30
|
+
BarChart,
|
|
31
|
+
GraphChart,
|
|
32
|
+
LineChart,
|
|
33
|
+
PieChart,
|
|
34
|
+
GridComponent,
|
|
35
|
+
TooltipComponent,
|
|
36
|
+
LegendComponent,
|
|
37
|
+
TitleComponent,
|
|
38
|
+
ToolboxComponent,
|
|
39
|
+
DatasetComponent,
|
|
40
|
+
TransformComponent,
|
|
41
|
+
LabelLayout,
|
|
42
|
+
UniversalTransition,
|
|
43
|
+
CanvasRenderer,
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
export type EChartProps = {
|
|
47
|
+
option: EChartsCoreOption;
|
|
48
|
+
height?: number | string;
|
|
49
|
+
loading?: boolean;
|
|
50
|
+
/** 图表事件,如 { click: (params) => {...} };图谱节点点击靠它 */
|
|
51
|
+
onEvents?: Record<string, (params: unknown) => void>;
|
|
52
|
+
/**
|
|
53
|
+
* 图表实例就绪/销毁回调。
|
|
54
|
+
*
|
|
55
|
+
* 用来做「不重建 option 的高亮切换」:力导布局只要 setOption 就会重新跑模拟,
|
|
56
|
+
* 高亮必须走 dispatchAction,否则鼠标一悬停整张图就重新布局。
|
|
57
|
+
*/
|
|
58
|
+
onChart?: (chart: ECharts | null) => void;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export default function EChart({ option, height = 320, loading, onEvents, onChart }: EChartProps) {
|
|
62
|
+
const elRef = useRef<HTMLDivElement>(null);
|
|
63
|
+
const chartRef = useRef<ECharts | null>(null);
|
|
64
|
+
/**
|
|
65
|
+
* 图表文字颜色也跟着皮肤走。
|
|
66
|
+
*
|
|
67
|
+
* canvas 不认 CSS 变量,而坐标轴标签、图例、系列数值这些东西又没有单独的
|
|
68
|
+
* token 可以继承(它们默认是深灰 #333,在深色皮肤上基本看不见)。
|
|
69
|
+
* 所以在这里统一兜一层根级 textStyle:页面 option 自己写的优先级更高,
|
|
70
|
+
* 没写的都自动拿到当前皮肤的文字色 —— 新页面画图不用再关心主题。
|
|
71
|
+
*/
|
|
72
|
+
const ink = useSkinInk();
|
|
73
|
+
const themedOption = useMemo(() => {
|
|
74
|
+
const opt = option as Record<string, unknown>;
|
|
75
|
+
return {
|
|
76
|
+
backgroundColor: 'transparent',
|
|
77
|
+
...opt,
|
|
78
|
+
textStyle: { color: ink.text2, fontFamily: FONT_STACK, ...(opt.textStyle as object | undefined) },
|
|
79
|
+
} as EChartsCoreOption;
|
|
80
|
+
}, [option, ink]);
|
|
81
|
+
|
|
82
|
+
// onEvents / onChart 内联函数每次渲染都是新引用,用 ref 持握最新值,避免反复 on/off
|
|
83
|
+
const eventsRef = useRef(onEvents);
|
|
84
|
+
eventsRef.current = onEvents;
|
|
85
|
+
const onChartRef = useRef(onChart);
|
|
86
|
+
onChartRef.current = onChart;
|
|
87
|
+
|
|
88
|
+
// 初始化只做一次:echarts 实例很重,随 option 重建会造成明显的闪烁
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
const el = elRef.current;
|
|
91
|
+
if (!el) return;
|
|
92
|
+
const chart = init(el);
|
|
93
|
+
chartRef.current = chart;
|
|
94
|
+
onChartRef.current?.(chart);
|
|
95
|
+
|
|
96
|
+
// 注册外部事件(如图谱的节点点击),卸载时统一 off 掉
|
|
97
|
+
const registered = Object.entries(eventsRef.current ?? {}).map(([name]) => {
|
|
98
|
+
const wrapped = (params: unknown) => eventsRef.current?.[name]?.(params);
|
|
99
|
+
chart.on(name, wrapped);
|
|
100
|
+
return { name, wrapped };
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// 容器宽度由 antd 栅格决定,窗口缩放时柱状图必须跟着变,否则会溢出或被裁切
|
|
104
|
+
const ro = new ResizeObserver(() => chart.resize());
|
|
105
|
+
ro.observe(el);
|
|
106
|
+
|
|
107
|
+
return () => {
|
|
108
|
+
for (const { name, wrapped } of registered) chart.off(name, wrapped);
|
|
109
|
+
ro.disconnect();
|
|
110
|
+
chart.dispose();
|
|
111
|
+
chartRef.current = null;
|
|
112
|
+
onChartRef.current?.(null);
|
|
113
|
+
};
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
// notMerge=true:新 option 与旧 option 完全替换,避免切换筛选条件时残留旧系列
|
|
118
|
+
chartRef.current?.setOption(themedOption, true);
|
|
119
|
+
}, [themedOption]);
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const chart = chartRef.current;
|
|
123
|
+
if (!chart) return;
|
|
124
|
+
if (loading)
|
|
125
|
+
chart.showLoading({
|
|
126
|
+
text: '加载中…',
|
|
127
|
+
color: ink.primary,
|
|
128
|
+
textColor: ink.text2,
|
|
129
|
+
maskColor: 'transparent',
|
|
130
|
+
});
|
|
131
|
+
else chart.hideLoading();
|
|
132
|
+
}, [loading, ink]);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div
|
|
136
|
+
ref={elRef}
|
|
137
|
+
style={{ width: '100%', height: typeof height === 'number' ? `${height}px` : height }}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
}
|