node-resource-utils 0.0.0-stage → 1.1.7
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,108 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { App, Button, type ButtonProps } from 'antd';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
import { errText } from '@/lib/err-text';
|
|
6
|
+
import { useAsyncTask, type TaskStage } from './usePending';
|
|
7
|
+
import { fs } from '@/lib/theme';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 会自己交代进度的按钮。
|
|
11
|
+
*
|
|
12
|
+
* 比赛里最常写的一行代码是:
|
|
13
|
+
* <Button onClick={async () => { await axios.post(...) }}>提交</Button>
|
|
14
|
+
* 它有三个隐患:不防重复点击、慢的时候没有任何反馈、失败只报 console。
|
|
15
|
+
*
|
|
16
|
+
* 换成 AsyncButton 这三件事一次解决:
|
|
17
|
+
* <AsyncButton type="primary" task={() => axios.post('/api/xxx')} successText="已提交">
|
|
18
|
+
* 提交
|
|
19
|
+
* </AsyncButton>
|
|
20
|
+
*
|
|
21
|
+
* 约定:`task` 收到 AbortSignal,fetch / axios 传进去就能被取消;
|
|
22
|
+
* 不响应也没关系,取消只是让按钮恢复、不再等结果。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type AsyncButtonProps = Omit<ButtonProps, 'onClick' | 'loading' | 'children' | 'href'> & {
|
|
26
|
+
/** 点击后要跑的耗时动作 */
|
|
27
|
+
task: (signal: AbortSignal) => Promise<unknown>;
|
|
28
|
+
children?: ReactNode;
|
|
29
|
+
/** 分阶段文案(只在按钮上显示短文案,长句交给全局浮层) */
|
|
30
|
+
stages?: TaskStage[];
|
|
31
|
+
/**
|
|
32
|
+
* 是否在按钮上显示已耗时:
|
|
33
|
+
* 'auto'(默认)超过 3 秒才显示,短任务完全不打扰
|
|
34
|
+
* true 一开始就显示
|
|
35
|
+
* false 从不显示
|
|
36
|
+
*/
|
|
37
|
+
showElapsed?: boolean | 'auto';
|
|
38
|
+
/** 成功后弹的提示;不传则不弹 */
|
|
39
|
+
successText?: string;
|
|
40
|
+
/** 失败提示。返回空字符串表示不弹(自己处理) */
|
|
41
|
+
errorText?: (e: unknown) => string;
|
|
42
|
+
/** 登记到全局浮层的文案,默认用按钮文字 */
|
|
43
|
+
label?: string;
|
|
44
|
+
/** 不进全局浮层,只在按钮上转 */
|
|
45
|
+
silent?: boolean;
|
|
46
|
+
/** 成功后回调(关闭弹窗、刷新列表都放这里) */
|
|
47
|
+
onDone?: (result: unknown) => void;
|
|
48
|
+
onFail?: (e: unknown) => void;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function nodeToText(node: ReactNode): string {
|
|
52
|
+
if (typeof node === 'string') return node;
|
|
53
|
+
if (typeof node === 'number') return String(node);
|
|
54
|
+
if (Array.isArray(node)) return node.map(nodeToText).join('');
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default function AsyncButton({
|
|
59
|
+
task,
|
|
60
|
+
children,
|
|
61
|
+
stages,
|
|
62
|
+
showElapsed = 'auto',
|
|
63
|
+
successText,
|
|
64
|
+
errorText,
|
|
65
|
+
label,
|
|
66
|
+
silent,
|
|
67
|
+
onDone,
|
|
68
|
+
onFail,
|
|
69
|
+
...rest
|
|
70
|
+
}: AsyncButtonProps) {
|
|
71
|
+
const { message } = App.useApp();
|
|
72
|
+
|
|
73
|
+
const job = useAsyncTask(task, {
|
|
74
|
+
label: label ?? (nodeToText(children) || undefined),
|
|
75
|
+
silent,
|
|
76
|
+
stages,
|
|
77
|
+
onSuccess: (r) => {
|
|
78
|
+
if (successText) message.success(successText);
|
|
79
|
+
onDone?.(r);
|
|
80
|
+
},
|
|
81
|
+
onError: (e) => {
|
|
82
|
+
const text = errorText ? errorText(e) : errText(e);
|
|
83
|
+
if (text) message.error(text);
|
|
84
|
+
onFail?.(e);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const seconds = job.elapsed / 1000;
|
|
89
|
+
const withSeconds =
|
|
90
|
+
job.pending &&
|
|
91
|
+
(showElapsed === true || (showElapsed === 'auto' && job.elapsed >= 3000));
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Button
|
|
95
|
+
{...rest}
|
|
96
|
+
// 用延迟后的 visible:200ms 内完成的操作按钮不会"抖一下"
|
|
97
|
+
loading={job.visible}
|
|
98
|
+
onClick={() => void job.run()}
|
|
99
|
+
>
|
|
100
|
+
{children}
|
|
101
|
+
{withSeconds ? (
|
|
102
|
+
<span style={{ marginLeft: 6, opacity: 0.72, fontSize: fs(12) }}>
|
|
103
|
+
{seconds < 10 ? `${seconds.toFixed(0)}s` : `${Math.round(seconds)}s`}
|
|
104
|
+
</span>
|
|
105
|
+
) : null}
|
|
106
|
+
</Button>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Spin } from 'antd';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
import { fs, MILITARY } from '@/lib/theme';
|
|
6
|
+
import { WAIT } from '@/lib/pending';
|
|
7
|
+
import { formatElapsed, stageAt, useDelayedLoading, useElapsed, type TaskStage } from './usePending';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 区域级等待遮罩。
|
|
11
|
+
*
|
|
12
|
+
* 典型用法:一块卡片/一个面板整体在等数据时,把内容蒙住而不是让它空着。
|
|
13
|
+
* <LoadingOverlay loading={q.isLoading} text="正在读取用户…">
|
|
14
|
+
* <Table ... />
|
|
15
|
+
* </LoadingOverlay>
|
|
16
|
+
*
|
|
17
|
+
* 三个细节决定了它好不好用:
|
|
18
|
+
* 1. **保留旧内容**(半透明蒙层)—— 翻页/刷新时不清空,用户知道自己还在原地
|
|
19
|
+
* 2. **延迟出现**—— 200ms 内返回的请求看不到这层东西,界面不会闪
|
|
20
|
+
* 3. **显示已耗时**—— 超过 3 秒开始报秒数,用户知道它没死
|
|
21
|
+
*/
|
|
22
|
+
export type LoadingOverlayProps = {
|
|
23
|
+
loading: boolean;
|
|
24
|
+
/** 主文案;不传用「正在加载…」 */
|
|
25
|
+
text?: string;
|
|
26
|
+
/** 分阶段文案,覆盖 text */
|
|
27
|
+
stages?: TaskStage[];
|
|
28
|
+
/** 延迟出现毫秒,默认 WAIT.LOCAL_DELAY;传 0 表示立刻出现 */
|
|
29
|
+
delay?: number;
|
|
30
|
+
/** 容器最小高度,防止空内容时遮罩塌成一条线 */
|
|
31
|
+
minHeight?: number;
|
|
32
|
+
radius?: number;
|
|
33
|
+
/** 蒙层不透明度 0~1,默认 0.82(能透出下面的版式,但字看不清) */
|
|
34
|
+
veil?: number;
|
|
35
|
+
/** 蒙层底色 */
|
|
36
|
+
tone?: 'paper' | 'card';
|
|
37
|
+
/** 从多少毫秒开始显示秒数,默认 3 秒 */
|
|
38
|
+
elapsedAfter?: number;
|
|
39
|
+
cancelText?: string;
|
|
40
|
+
onCancel?: () => void;
|
|
41
|
+
children?: ReactNode;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** #RRGGBB → rgba()。遮罩用 rgba 而不是 opacity,否则文案会跟着一起被淡化 */
|
|
45
|
+
function withAlpha(hex: string, a: number): string {
|
|
46
|
+
const h = hex.replace('#', '');
|
|
47
|
+
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
|
|
48
|
+
const n = parseInt(full, 16);
|
|
49
|
+
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default function LoadingOverlay({
|
|
53
|
+
loading,
|
|
54
|
+
text = '正在加载…',
|
|
55
|
+
stages,
|
|
56
|
+
delay = WAIT.LOCAL_DELAY,
|
|
57
|
+
minHeight = 160,
|
|
58
|
+
radius = 12,
|
|
59
|
+
veil = 0.82,
|
|
60
|
+
tone = 'paper',
|
|
61
|
+
elapsedAfter = WAIT.GLOBAL_APPEAR,
|
|
62
|
+
cancelText,
|
|
63
|
+
onCancel,
|
|
64
|
+
children,
|
|
65
|
+
}: LoadingOverlayProps) {
|
|
66
|
+
const visible = useDelayedLoading(loading, delay);
|
|
67
|
+
const elapsed = useElapsed(loading);
|
|
68
|
+
const stageText = stages ? stageAt(stages, elapsed) : text;
|
|
69
|
+
const base = tone === 'card' ? MILITARY.card : MILITARY.paper;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div style={{ position: 'relative', minHeight, borderRadius: radius }}>
|
|
73
|
+
{children}
|
|
74
|
+
|
|
75
|
+
{visible ? (
|
|
76
|
+
<div
|
|
77
|
+
role="status"
|
|
78
|
+
aria-live="polite"
|
|
79
|
+
style={{
|
|
80
|
+
position: 'absolute',
|
|
81
|
+
inset: 0,
|
|
82
|
+
borderRadius: radius,
|
|
83
|
+
// 用 rgba 而不是 opacity:opacity 会把里面的文案、spinner 一起淡化,
|
|
84
|
+
// 实测下来小字几乎看不清(踩过)
|
|
85
|
+
background: withAlpha(base, veil),
|
|
86
|
+
display: 'flex',
|
|
87
|
+
flexDirection: 'column',
|
|
88
|
+
alignItems: 'center',
|
|
89
|
+
justifyContent: 'center',
|
|
90
|
+
gap: 10,
|
|
91
|
+
zIndex: 5,
|
|
92
|
+
// 蒙层期间不拦截既有内容的滚动,只是视觉上盖住
|
|
93
|
+
pointerEvents: onCancel ? 'auto' : 'none',
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
<Spin />
|
|
97
|
+
<div style={{ fontSize: fs(13), color: MILITARY.textSecondary }}>{stageText}</div>
|
|
98
|
+
{elapsed >= elapsedAfter ? (
|
|
99
|
+
<div style={{ fontSize: fs(11.5), color: MILITARY.textMuted }}>
|
|
100
|
+
已等待 {formatElapsed(elapsed)}
|
|
101
|
+
</div>
|
|
102
|
+
) : null}
|
|
103
|
+
{onCancel ? (
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
onClick={onCancel}
|
|
107
|
+
style={{
|
|
108
|
+
marginTop: 2,
|
|
109
|
+
padding: '2px 10px',
|
|
110
|
+
fontSize: fs(12),
|
|
111
|
+
color: MILITARY.textSecondary,
|
|
112
|
+
background: 'transparent',
|
|
113
|
+
border: `1px solid ${MILITARY.border}`,
|
|
114
|
+
borderRadius: 999,
|
|
115
|
+
cursor: 'pointer',
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
{cancelText ?? '取消'}
|
|
119
|
+
</button>
|
|
120
|
+
) : null}
|
|
121
|
+
</div>
|
|
122
|
+
) : null}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
import type { KeyboardEvent, ReactNode } from 'react';
|
|
5
|
+
import { Card, type CardProps } from 'antd';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 可点击卡片 —— 「手感」这件事的唯一样板。
|
|
9
|
+
*
|
|
10
|
+
* 为什么要有这个组件,而不是让页面自己写 className:
|
|
11
|
+
* 1. `wb-pressable` 加错地方是有害的(不响应的卡却在动,比不动更像 bug)。
|
|
12
|
+
* 这里**自动判断**:只有给了 href / onPress 才加,从根上避免误用。
|
|
13
|
+
* 2. Link + Card 的组合有三处琐碎细节(display:block、颜色继承、去下划线),
|
|
14
|
+
* 每个列表页重写一遍必然会漏。
|
|
15
|
+
* 3. 右上角的「›」提示要能提前告诉用户"这张卡能点",
|
|
16
|
+
* 它和可点性是绑定的,不该由调用方记得加。
|
|
17
|
+
*
|
|
18
|
+
* 用法:
|
|
19
|
+
* <PressableCard href="/system/users" title="用户总数">…</PressableCard>
|
|
20
|
+
* <PressableCard onPress={() => setOpen(true)}>…</PressableCard>
|
|
21
|
+
* <PressableCard>…</PressableCard> // 纯静态,不会有任何动效
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ 不要再用 antd 的 `hoverable`:它只改阴影不抬升,和这套手感打架。
|
|
24
|
+
* 需要更强的反馈就调 motion.css 里的 --wb-lift / --wb-dur。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type PressableCardProps = Omit<CardProps, 'hoverable' | 'onClick'> & {
|
|
28
|
+
/** 给了就当整卡是链接:可右键新标签页、可被爬虫识别 */
|
|
29
|
+
href?: string;
|
|
30
|
+
/** 没有链接但有行为时用;给了它自动开启手感 + 键盘可达(Enter / Space) */
|
|
31
|
+
onPress?: () => void;
|
|
32
|
+
/** 右上角「›」提示,默认跟着可点性走;静态卡想伪装成可点时才手动置 true */
|
|
33
|
+
cue?: boolean;
|
|
34
|
+
children?: ReactNode;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export default function PressableCard({
|
|
38
|
+
href,
|
|
39
|
+
onPress,
|
|
40
|
+
cue,
|
|
41
|
+
className,
|
|
42
|
+
children,
|
|
43
|
+
...rest
|
|
44
|
+
}: PressableCardProps) {
|
|
45
|
+
const clickable = Boolean(href) || typeof onPress === 'function';
|
|
46
|
+
const showCue = cue ?? clickable;
|
|
47
|
+
|
|
48
|
+
// 不可点就不挂手感类:这一条是这个组件的核心,不要为了"统一"放开
|
|
49
|
+
const cls = [clickable ? 'wb-pressable' : '', className].filter(Boolean).join(' ') || undefined;
|
|
50
|
+
|
|
51
|
+
const body = (
|
|
52
|
+
<>
|
|
53
|
+
{showCue ? (
|
|
54
|
+
<span className="wb-pressable__cue" aria-hidden>
|
|
55
|
+
›
|
|
56
|
+
</span>
|
|
57
|
+
) : null}
|
|
58
|
+
{children}
|
|
59
|
+
</>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (href) {
|
|
63
|
+
return (
|
|
64
|
+
<Link href={href} style={{ display: 'block', color: 'inherit', textDecoration: 'none' }}>
|
|
65
|
+
<Card {...rest} className={cls}>
|
|
66
|
+
{body}
|
|
67
|
+
</Card>
|
|
68
|
+
</Link>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
73
|
+
if (!onPress) return;
|
|
74
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
onPress();
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<Card
|
|
82
|
+
{...rest}
|
|
83
|
+
className={cls}
|
|
84
|
+
onClick={onPress}
|
|
85
|
+
role={clickable ? 'button' : undefined}
|
|
86
|
+
tabIndex={clickable ? 0 : undefined}
|
|
87
|
+
onKeyDown={clickable ? onKeyDown : undefined}
|
|
88
|
+
>
|
|
89
|
+
{body}
|
|
90
|
+
</Card>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# 反馈底座(feedback)
|
|
2
|
+
|
|
3
|
+
比赛每道新题都要重做一遍页面,但"用户点了之后有没有反应"这件事是**通用**的。
|
|
4
|
+
这个目录把它做成了一层能力:换题时不用重新设计动效,照着下面的样板接就行。
|
|
5
|
+
|
|
6
|
+
## 两条铁律
|
|
7
|
+
|
|
8
|
+
| 类型 | 回答的问题 | 用谁 |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| 等待反馈 | 系统还在忙吗? | `AsyncButton` / `LoadingOverlay` / `Skeleton*` / `TaskHint`(已全局挂好) |
|
|
11
|
+
| 操作反馈 | 我这一下,系统收到了吗? | `PressableCard` / `StatCard` / `useRowFlash` |
|
|
12
|
+
|
|
13
|
+
混着用会两头不讨好:给慢请求做按压动效,用户还是在干等;给快操作做遮罩,用户被打断。
|
|
14
|
+
|
|
15
|
+
## 三个高频场景(复制即用)
|
|
16
|
+
|
|
17
|
+
### 1. 看板指标卡
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { StatCard } from '@/components/feedback';
|
|
21
|
+
import { CHART_COLORS } from '@/lib/theme';
|
|
22
|
+
|
|
23
|
+
<Col span={6}>
|
|
24
|
+
<StatCard
|
|
25
|
+
title="订单总数"
|
|
26
|
+
value={stats?.orderCount}
|
|
27
|
+
icon={<ShoppingOutlined />}
|
|
28
|
+
color={CHART_COLORS[0]}
|
|
29
|
+
href="/orders" // 有下钻就写,没有就删掉 —— 没有 href 时不会动,避免骗人
|
|
30
|
+
loading={isLoading}
|
|
31
|
+
/>
|
|
32
|
+
</Col>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`StatCard` 内部已经处理:骨架期间不计数、加载完从 0 滚到真实值、可点才抬升、右上角 `›` 提示。
|
|
36
|
+
|
|
37
|
+
### 2. 列表页"刚才改的是哪一行"
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
import { useRowFlash } from '@/components/feedback';
|
|
41
|
+
|
|
42
|
+
const { highlightRow, flashRow } = useRowFlash();
|
|
43
|
+
|
|
44
|
+
// 保存 / 删除 / 切换状态成功后
|
|
45
|
+
await save(row);
|
|
46
|
+
message.success('保存成功');
|
|
47
|
+
flashRow(row.id); // ← 只需这一行
|
|
48
|
+
|
|
49
|
+
// 传给表格
|
|
50
|
+
<DataTable highlightRow={highlightRow} … />
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`DataTable` 会等新数据真的拉回来再闪,不会闪到刷新前的旧行。同一行连点两次也能重播。
|
|
54
|
+
|
|
55
|
+
### 3. 自定义卡片(不走 antd Card)
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
<div className="wb-pressable" onClick={open}>
|
|
59
|
+
<span className="wb-pressable__cue" aria-hidden>›</span>
|
|
60
|
+
…
|
|
61
|
+
</div>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
⚠️ 只有**真能点**才加 `wb-pressable`。静态卡片加了,鼠标过去会动、点了没反应,比完全不动更像 bug。
|
|
65
|
+
|
|
66
|
+
## 调力度(只改 `motion.css` 的 `:root`)
|
|
67
|
+
|
|
68
|
+
| 变量 | 默认 | 说明 |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| `--wb-dur` | 0.18s | 悬停抬升 / 边框变色 |
|
|
71
|
+
| `--wb-dur-fast` | 0.08s | 按下回弹(必须比悬停快) |
|
|
72
|
+
| `--wb-flash-dur` | 0.5s | 结果落地闪一下的总时长 |
|
|
73
|
+
| `--wb-lift` | -1px | 抬升距离 |
|
|
74
|
+
| `--wb-shrink` | 0.995 | 按下缩放 |
|
|
75
|
+
|
|
76
|
+
超过 0.24s 的响应就开始像"系统在表演"。宁可轻,不要飘。
|
|
77
|
+
|
|
78
|
+
## 三条禁令
|
|
79
|
+
|
|
80
|
+
1. **不给静态内容加动效** —— 不动比乱动好。
|
|
81
|
+
2. **不引动画库**(framer-motion / react-spring)—— 脚手架红线是不引新依赖,
|
|
82
|
+
CSS transition + 一个 RAF 足够覆盖上面所有场景。
|
|
83
|
+
3. **新写的动画必须补 `prefers-reduced-motion` 降级** —— `motion.css` 末尾有统一处理,
|
|
84
|
+
新增规则时记得加进那段 `@media`。
|
|
85
|
+
|
|
86
|
+
## 目录
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
motion.css 视觉定义(由 app/globals.css @import,页面不用引)
|
|
90
|
+
motion.ts flashElement / flashTableRow,命令式闪一下
|
|
91
|
+
useCountUp.ts 计数动画(⚠️ 必须放在独立子组件里,否则每帧重渲整页)
|
|
92
|
+
useRowFlash.ts 列表页行高亮的状态容器
|
|
93
|
+
PressableCard.tsx 可点击卡片(自动判断可点性)
|
|
94
|
+
StatCard.tsx 统计卡(计数 + 手感 + 加载态)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
等待反馈那几个文件不在这里展开,见 `index.ts` 的注释。
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { usePathname } from 'next/navigation';
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
5
|
+
import { MILITARY } from '@/lib/theme';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 顶部路由进度条。
|
|
9
|
+
*
|
|
10
|
+
* 解决的是「点了菜单之后什么都没发生」那 0.3~1 秒:
|
|
11
|
+
* Next.js 的客户端跳转要先把下一屏的 JS 拉下来再渲染,
|
|
12
|
+
* 重页面(知识图谱、图表工作台)尤其明显。
|
|
13
|
+
*
|
|
14
|
+
* 用法:跳转入口调一次 `startRouteProgress()`,
|
|
15
|
+
* 本条在 pathname 变化时自己收尾 —— 不需要页面配合。
|
|
16
|
+
* (菜单、全局搜索、收藏、面包屑这些入口已在 AdminShell 里接好)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
let startFn: (() => void) | null = null;
|
|
20
|
+
|
|
21
|
+
/** 通知进度条「开始跳转了」。非 React 场景(菜单 onClick)直接调这个 */
|
|
22
|
+
export function startRouteProgress(): void {
|
|
23
|
+
startFn?.();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 兜底:万一跳转被拦下(权限拦截、路由报错),8 秒后自己消失,不会卡一条线在顶上 */
|
|
27
|
+
const AUTO_FINISH_MS = 8000;
|
|
28
|
+
|
|
29
|
+
export default function RouteProgress() {
|
|
30
|
+
const pathname = usePathname();
|
|
31
|
+
const [state, setState] = useState<{ visible: boolean; pct: number }>({
|
|
32
|
+
visible: false,
|
|
33
|
+
pct: 0,
|
|
34
|
+
});
|
|
35
|
+
const startedRef = useRef(false);
|
|
36
|
+
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
|
|
37
|
+
|
|
38
|
+
const clearTimers = useCallback(() => {
|
|
39
|
+
for (const t of timers.current) clearTimeout(t);
|
|
40
|
+
timers.current = [];
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
const finish = useCallback(() => {
|
|
44
|
+
if (!startedRef.current) return;
|
|
45
|
+
startedRef.current = false;
|
|
46
|
+
clearTimers();
|
|
47
|
+
setState({ visible: true, pct: 100 });
|
|
48
|
+
// 走到 100% 再淡出,视觉上才是"完成"而不是"消失"
|
|
49
|
+
timers.current.push(
|
|
50
|
+
setTimeout(() => setState({ visible: false, pct: 0 }), 320)
|
|
51
|
+
);
|
|
52
|
+
}, [clearTimers]);
|
|
53
|
+
|
|
54
|
+
const start = useCallback(() => {
|
|
55
|
+
clearTimers();
|
|
56
|
+
startedRef.current = true;
|
|
57
|
+
setState({ visible: true, pct: 6 });
|
|
58
|
+
// 三段爬升:先快(有反应)→ 再慢(在加载)→ 最后极慢(还没完但不是卡死)
|
|
59
|
+
timers.current.push(
|
|
60
|
+
setTimeout(() => setState((s) => (s.visible ? { ...s, pct: 42 } : s)), 240),
|
|
61
|
+
setTimeout(() => setState((s) => (s.visible ? { ...s, pct: 68 } : s)), 900),
|
|
62
|
+
setTimeout(() => setState((s) => (s.visible ? { ...s, pct: 84 } : s)), 2400),
|
|
63
|
+
setTimeout(finish, AUTO_FINISH_MS)
|
|
64
|
+
);
|
|
65
|
+
}, [clearTimers, finish]);
|
|
66
|
+
|
|
67
|
+
// 把 start 交给外部调用方
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
startFn = start;
|
|
70
|
+
return () => {
|
|
71
|
+
if (startFn === start) startFn = null;
|
|
72
|
+
};
|
|
73
|
+
}, [start]);
|
|
74
|
+
|
|
75
|
+
// 地址变了 = 新页面已经就位,收尾
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
finish();
|
|
78
|
+
}, [pathname, finish]);
|
|
79
|
+
|
|
80
|
+
useEffect(() => clearTimers, [clearTimers]);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
aria-hidden
|
|
85
|
+
style={{
|
|
86
|
+
position: 'fixed',
|
|
87
|
+
top: 0,
|
|
88
|
+
left: 0,
|
|
89
|
+
right: 0,
|
|
90
|
+
height: 2,
|
|
91
|
+
zIndex: 1200,
|
|
92
|
+
pointerEvents: 'none',
|
|
93
|
+
opacity: state.visible ? 1 : 0,
|
|
94
|
+
transition: 'opacity .28s ease',
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
<div
|
|
98
|
+
style={{
|
|
99
|
+
height: '100%',
|
|
100
|
+
width: `${state.pct}%`,
|
|
101
|
+
background: `linear-gradient(90deg, ${MILITARY.primary}, ${MILITARY.accent})`,
|
|
102
|
+
boxShadow: `0 0 8px ${MILITARY.primary}`,
|
|
103
|
+
transition: 'width .32s ease',
|
|
104
|
+
}}
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|