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
|
+
import { Prisma } from '@prisma/client';
|
|
2
|
+
import dayjs from 'dayjs';
|
|
3
|
+
import { audit, clientInfo, ok, pageResult, parseListQuery, withRoute } from '@/lib/api';
|
|
4
|
+
import { prisma } from '@/lib/prisma';
|
|
5
|
+
import { requireUser } from '@/auth';
|
|
6
|
+
import { P, DATA_SCOPE, effectiveDataScope, requirePerm } from '@/lib/rbac';
|
|
7
|
+
export const dynamic = 'force-dynamic';
|
|
8
|
+
|
|
9
|
+
/** GET /api/logs —— 操作日志分页 */
|
|
10
|
+
export const GET = withRoute(async (req) => {
|
|
11
|
+
const user = await requireUser();
|
|
12
|
+
requirePerm(user, P.logList);
|
|
13
|
+
|
|
14
|
+
const q = parseListQuery(req, { createdAt: 'desc' });
|
|
15
|
+
const sp = req.nextUrl.searchParams;
|
|
16
|
+
|
|
17
|
+
const where: Prisma.OperationLogWhereInput = {};
|
|
18
|
+
// 日志表没有 deptId,套不了部门范围:非「全部」的账号只能看自己的操作记录
|
|
19
|
+
// (通用导出的日志分支用的是同一条规则,两边必须一致,否则导出能绕过列表)
|
|
20
|
+
if (effectiveDataScope(user) !== DATA_SCOPE.ALL) where.userId = user.id;
|
|
21
|
+
|
|
22
|
+
if (q.keyword) {
|
|
23
|
+
where.OR = [
|
|
24
|
+
{ username: { contains: q.keyword } },
|
|
25
|
+
{ module: { contains: q.keyword } },
|
|
26
|
+
{ action: { contains: q.keyword } },
|
|
27
|
+
{ path: { contains: q.keyword } },
|
|
28
|
+
{ detail: { contains: q.keyword } },
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 日志页的模块筛选参数叫 logModule:通用导出 /api/export 会用 module 表示「导出哪个模块」,
|
|
33
|
+
// 两个语义撞在一起会互相覆盖,所以列表过滤这里优先读 logModule。
|
|
34
|
+
const logModule = sp.get('logModule') ?? sp.get('module');
|
|
35
|
+
const action = sp.get('action');
|
|
36
|
+
if (logModule) where.module = logModule;
|
|
37
|
+
if (action) where.action = action;
|
|
38
|
+
|
|
39
|
+
const startDate = sp.get('startDate');
|
|
40
|
+
const endDate = sp.get('endDate');
|
|
41
|
+
if (startDate || endDate) {
|
|
42
|
+
const createdAt: Prisma.DateTimeFilter = {};
|
|
43
|
+
if (startDate) createdAt.gte = dayjs(startDate).startOf('day').toDate();
|
|
44
|
+
if (endDate) createdAt.lte = dayjs(endDate).endOf('day').toDate();
|
|
45
|
+
where.createdAt = createdAt;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const [list, total] = await Promise.all([
|
|
49
|
+
prisma.operationLog.findMany({
|
|
50
|
+
where,
|
|
51
|
+
skip: q.skip,
|
|
52
|
+
take: q.take,
|
|
53
|
+
orderBy: q.orderBy as Prisma.OperationLogOrderByWithRelationInput,
|
|
54
|
+
}),
|
|
55
|
+
prisma.operationLog.count({ where }),
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
return ok(pageResult(list, total, q));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** DELETE /api/logs —— 清空日志(危险操作,单独权限码) */
|
|
62
|
+
export const DELETE = withRoute(async (req) => {
|
|
63
|
+
const user = await requireUser();
|
|
64
|
+
requirePerm(user, P.logDel);
|
|
65
|
+
|
|
66
|
+
const { count } = await prisma.operationLog.deleteMany({});
|
|
67
|
+
|
|
68
|
+
const { ip, userAgent } = clientInfo(req);
|
|
69
|
+
await audit({
|
|
70
|
+
userId: user.id,
|
|
71
|
+
username: user.username,
|
|
72
|
+
module: '操作日志',
|
|
73
|
+
action: '清空',
|
|
74
|
+
method: 'DELETE',
|
|
75
|
+
path: req.nextUrl.pathname,
|
|
76
|
+
detail: { deleted: count },
|
|
77
|
+
ip,
|
|
78
|
+
userAgent,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return ok({ deleted: count }, `已清空 ${count} 条日志`);
|
|
82
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { BizError, CODE, audit, clientInfo, ok, withRoute } from '@/lib/api';
|
|
3
|
+
import { prisma } from '@/lib/prisma';
|
|
4
|
+
import { requireUser } from '@/auth';
|
|
5
|
+
import { P, requirePerm } from '@/lib/rbac';
|
|
6
|
+
import { BADGE_META } from '@/lib/badges';
|
|
7
|
+
|
|
8
|
+
export const dynamic = 'force-dynamic';
|
|
9
|
+
|
|
10
|
+
const menuSchema = z.object({
|
|
11
|
+
name: z.string().trim().min(1, '菜单名称必填').max(32, '菜单名称过长'),
|
|
12
|
+
parentId: z.string().nullish(),
|
|
13
|
+
path: z.string().trim().nullish(),
|
|
14
|
+
icon: z.string().trim().nullish(),
|
|
15
|
+
keywords: z.string().trim().max(120, '搜索别名过长').nullish(),
|
|
16
|
+
// 只接受已登记的计数源:否则菜单上会挂一个永远不亮的红点,排查起来很像 bug
|
|
17
|
+
badgeKey: z
|
|
18
|
+
.string()
|
|
19
|
+
.trim()
|
|
20
|
+
.nullish()
|
|
21
|
+
.refine((v) => !v || !!BADGE_META[v], { message: '未知的徽标计数源' }),
|
|
22
|
+
type: z.enum(['dir', 'menu', 'button']).default('menu'),
|
|
23
|
+
permCode: z.string().trim().nullish(),
|
|
24
|
+
sort: z.coerce.number().int().default(0),
|
|
25
|
+
hidden: z.coerce.number().int().min(0).max(1).default(0),
|
|
26
|
+
status: z.coerce.number().int().min(0).max(1).default(1),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
type Ctx = { params: Promise<{ id: string }> };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 从候选父节点向上回溯,若撞到自己说明会形成环。
|
|
33
|
+
* 不校验的话,一次误操作就能让菜单树自引用,整棵树的渲染会直接栈溢出。
|
|
34
|
+
*/
|
|
35
|
+
async function assertNoCycle(id: string, parentId: string | null) {
|
|
36
|
+
let cursor = parentId;
|
|
37
|
+
const seen = new Set<string>();
|
|
38
|
+
while (cursor) {
|
|
39
|
+
if (cursor === id) throw new BizError('父级不能是自身或其子节点');
|
|
40
|
+
if (seen.has(cursor)) break;
|
|
41
|
+
seen.add(cursor);
|
|
42
|
+
const parent = await prisma.menu.findUnique({ where: { id: cursor }, select: { parentId: true } });
|
|
43
|
+
cursor = parent?.parentId ?? null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** PUT /api/menus/[id] —— 更新菜单 */
|
|
48
|
+
export const PUT = withRoute<Ctx>(async (req, ctx) => {
|
|
49
|
+
const user = await requireUser();
|
|
50
|
+
requirePerm(user, P.menuEdit);
|
|
51
|
+
|
|
52
|
+
const { id } = await ctx.params;
|
|
53
|
+
const exist = await prisma.menu.findUnique({ where: { id } });
|
|
54
|
+
if (!exist) throw new BizError('菜单不存在', CODE.NOT_FOUND);
|
|
55
|
+
|
|
56
|
+
const parsed = menuSchema.parse(await req.json());
|
|
57
|
+
const parentId = parsed.parentId || null;
|
|
58
|
+
const permCode = parsed.permCode || null;
|
|
59
|
+
|
|
60
|
+
if (parentId) await assertNoCycle(id, parentId);
|
|
61
|
+
|
|
62
|
+
if (permCode) {
|
|
63
|
+
const dup = await prisma.menu.findFirst({ where: { permCode, NOT: { id } } });
|
|
64
|
+
if (dup) throw new BizError(`权限标识「${permCode}」已被占用`, CODE.CONFLICT);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const data = {
|
|
68
|
+
name: parsed.name,
|
|
69
|
+
parentId,
|
|
70
|
+
path: parsed.path || null,
|
|
71
|
+
icon: parsed.icon || null,
|
|
72
|
+
keywords: parsed.keywords || null,
|
|
73
|
+
badgeKey: parsed.badgeKey || null,
|
|
74
|
+
type: parsed.type,
|
|
75
|
+
permCode,
|
|
76
|
+
sort: parsed.sort,
|
|
77
|
+
hidden: parsed.hidden,
|
|
78
|
+
status: parsed.status,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
await prisma.menu.update({ where: { id }, data });
|
|
82
|
+
|
|
83
|
+
const { ip, userAgent } = clientInfo(req);
|
|
84
|
+
await audit({
|
|
85
|
+
userId: user.id,
|
|
86
|
+
username: user.username,
|
|
87
|
+
module: '菜单管理',
|
|
88
|
+
action: '修改',
|
|
89
|
+
method: 'PUT',
|
|
90
|
+
path: req.nextUrl.pathname,
|
|
91
|
+
targetId: id,
|
|
92
|
+
detail: data,
|
|
93
|
+
ip,
|
|
94
|
+
userAgent,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return ok(null, '保存成功');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/** DELETE /api/menus/[id] —— 删除菜单(有子节点时拒绝) */
|
|
101
|
+
export const DELETE = withRoute<Ctx>(async (req, ctx) => {
|
|
102
|
+
const user = await requireUser();
|
|
103
|
+
requirePerm(user, P.menuEdit);
|
|
104
|
+
|
|
105
|
+
const { id } = await ctx.params;
|
|
106
|
+
const exist = await prisma.menu.findUnique({ where: { id } });
|
|
107
|
+
if (!exist) throw new BizError('菜单不存在', CODE.NOT_FOUND);
|
|
108
|
+
|
|
109
|
+
const childCount = await prisma.menu.count({ where: { parentId: id } });
|
|
110
|
+
if (childCount > 0) {
|
|
111
|
+
throw new BizError(`该菜单下还有 ${childCount} 个子节点,请先删除子节点`, CODE.CONFLICT);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// RoleMenu 上已配 onDelete: Cascade,删除菜单会连带清掉角色关联
|
|
115
|
+
await prisma.menu.delete({ where: { id } });
|
|
116
|
+
|
|
117
|
+
const { ip, userAgent } = clientInfo(req);
|
|
118
|
+
await audit({
|
|
119
|
+
userId: user.id,
|
|
120
|
+
username: user.username,
|
|
121
|
+
module: '菜单管理',
|
|
122
|
+
action: '删除',
|
|
123
|
+
method: 'DELETE',
|
|
124
|
+
path: req.nextUrl.pathname,
|
|
125
|
+
targetId: id,
|
|
126
|
+
detail: { name: exist.name, path: exist.path },
|
|
127
|
+
ip,
|
|
128
|
+
userAgent,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
return ok(null, '删除成功');
|
|
132
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ok, withRoute } from '@/lib/api';
|
|
2
|
+
import { requireUser } from '@/auth';
|
|
3
|
+
import { getUserMenuTree, toSidebarTree } from '@/lib/menu-service';
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* GET /api/menus/mine —— 当前登录用户可见的菜单树。
|
|
9
|
+
*
|
|
10
|
+
* 返回的是**侧边栏那一份**:`toSidebarTree` 已经把按钮节点、隐藏菜单和
|
|
11
|
+
* 功能开关关掉的模块摘干净,前端直接渲染即可,不用再自己过滤一遍。
|
|
12
|
+
*
|
|
13
|
+
* 按钮级鉴权不走这个接口:登录时 `permCodes` 已经进了会话,
|
|
14
|
+
* 前端用 `AuthContext` 的 `perms` 判断(see `(admin)/layout.tsx` 的 AuthProvider),
|
|
15
|
+
* 所以没必要把按钮节点吐回去。
|
|
16
|
+
*
|
|
17
|
+
* 后台布局本身是服务端直出(`(admin)/layout.tsx`),首屏不经过这个接口;
|
|
18
|
+
* 它留给需要动态刷新菜单的前端场景。
|
|
19
|
+
*/
|
|
20
|
+
export const GET = withRoute(async () => {
|
|
21
|
+
const user = await requireUser();
|
|
22
|
+
return ok(toSidebarTree(await getUserMenuTree(user)));
|
|
23
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { BizError, CODE, audit, clientInfo, ok, withRoute } from '@/lib/api';
|
|
3
|
+
import { prisma } from '@/lib/prisma';
|
|
4
|
+
import { requireUser } from '@/auth';
|
|
5
|
+
import { P, requirePerm } from '@/lib/rbac';
|
|
6
|
+
import { getFullMenuTree } from '@/lib/menu-service';
|
|
7
|
+
import { BADGE_META } from '@/lib/badges';
|
|
8
|
+
|
|
9
|
+
export const dynamic = 'force-dynamic';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 菜单写入校验。
|
|
13
|
+
* 注意:route 文件里不能导出额外符号(Next 会校验导出表),
|
|
14
|
+
* 所以这里保持模块内私有,[id]/route.ts 里有一份等价的定义。
|
|
15
|
+
*/
|
|
16
|
+
const menuSchema = z.object({
|
|
17
|
+
name: z.string().trim().min(1, '菜单名称必填').max(32, '菜单名称过长'),
|
|
18
|
+
parentId: z.string().nullish(),
|
|
19
|
+
path: z.string().trim().nullish(),
|
|
20
|
+
icon: z.string().trim().nullish(),
|
|
21
|
+
keywords: z.string().trim().max(120, '搜索别名过长').nullish(),
|
|
22
|
+
// 只接受已登记的计数源:否则菜单上会挂一个永远不亮的红点,排查起来很像 bug
|
|
23
|
+
badgeKey: z
|
|
24
|
+
.string()
|
|
25
|
+
.trim()
|
|
26
|
+
.nullish()
|
|
27
|
+
.refine((v) => !v || !!BADGE_META[v], { message: '未知的徽标计数源' }),
|
|
28
|
+
type: z.enum(['dir', 'menu', 'button']).default('menu'),
|
|
29
|
+
permCode: z.string().trim().nullish(),
|
|
30
|
+
sort: z.coerce.number().int().default(0),
|
|
31
|
+
hidden: z.coerce.number().int().min(0).max(1).default(0),
|
|
32
|
+
status: z.coerce.number().int().min(0).max(1).default(1),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** 空串统一转 null:permCode 是 unique 列,多条空串会互相冲突 */
|
|
36
|
+
function normalizeMenuInput(v: z.infer<typeof menuSchema>) {
|
|
37
|
+
return {
|
|
38
|
+
name: v.name,
|
|
39
|
+
parentId: v.parentId || null,
|
|
40
|
+
path: v.path || null,
|
|
41
|
+
icon: v.icon || null,
|
|
42
|
+
keywords: v.keywords || null,
|
|
43
|
+
badgeKey: v.badgeKey || null,
|
|
44
|
+
type: v.type,
|
|
45
|
+
permCode: v.permCode || null,
|
|
46
|
+
sort: v.sort,
|
|
47
|
+
hidden: v.hidden,
|
|
48
|
+
status: v.status,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** GET /api/menus —— 全部菜单树(菜单管理页 / 角色授权抽屉) */
|
|
53
|
+
export const GET = withRoute(async () => {
|
|
54
|
+
const user = await requireUser();
|
|
55
|
+
requirePerm(user, P.menuList);
|
|
56
|
+
return ok(await getFullMenuTree());
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** POST /api/menus —— 新建菜单 */
|
|
60
|
+
export const POST = withRoute(async (req) => {
|
|
61
|
+
const user = await requireUser();
|
|
62
|
+
requirePerm(user, P.menuEdit);
|
|
63
|
+
|
|
64
|
+
const parsed = menuSchema.parse(await req.json());
|
|
65
|
+
const data = normalizeMenuInput(parsed);
|
|
66
|
+
|
|
67
|
+
if (data.permCode) {
|
|
68
|
+
const dup = await prisma.menu.findUnique({ where: { permCode: data.permCode } });
|
|
69
|
+
if (dup) throw new BizError(`权限标识「${data.permCode}」已被占用`, CODE.CONFLICT);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const created = await prisma.menu.create({ data });
|
|
73
|
+
|
|
74
|
+
const { ip, userAgent } = clientInfo(req);
|
|
75
|
+
await audit({
|
|
76
|
+
userId: user.id,
|
|
77
|
+
username: user.username,
|
|
78
|
+
module: '菜单管理',
|
|
79
|
+
action: '新增',
|
|
80
|
+
method: 'POST',
|
|
81
|
+
path: req.nextUrl.pathname,
|
|
82
|
+
targetId: created.id,
|
|
83
|
+
detail: data,
|
|
84
|
+
ip,
|
|
85
|
+
userAgent,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return ok({ id: created.id }, '新增成功');
|
|
89
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { BizError, CODE, audit, clientInfo, ok, withRoute } from '@/lib/api';
|
|
3
|
+
import { prisma } from '@/lib/prisma';
|
|
4
|
+
import { requireUser } from '@/auth';
|
|
5
|
+
import { P, requirePerm } from '@/lib/rbac';
|
|
6
|
+
|
|
7
|
+
export const dynamic = 'force-dynamic';
|
|
8
|
+
|
|
9
|
+
const schema = z.object({
|
|
10
|
+
menuIds: z.array(z.string()).default([]),
|
|
11
|
+
/** 数据范围:不传则不动(兼容只改菜单的老请求) */
|
|
12
|
+
dataScope: z.coerce.number().int().min(1).max(4).optional(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
type Ctx = { params: Promise<{ id: string }> };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* PUT /api/roles/[id]/menus —— 覆盖式设置角色菜单 + 数据范围。
|
|
19
|
+
*
|
|
20
|
+
* 两件事放同一个接口,因为它们在界面上就是同一次「分配权限」:
|
|
21
|
+
* 菜单决定能进哪些页面,dataScope 决定进去能看见哪些行。
|
|
22
|
+
* 全部放进一个事务,中途失败不会留下「权限清空一半 / 范围被改了一半」的中间态。
|
|
23
|
+
*/
|
|
24
|
+
export const PUT = withRoute<Ctx>(async (req, ctx) => {
|
|
25
|
+
const operator = await requireUser();
|
|
26
|
+
requirePerm(operator, P.rolePerm);
|
|
27
|
+
|
|
28
|
+
const { id } = await ctx.params;
|
|
29
|
+
const role = await prisma.role.findUnique({ where: { id } });
|
|
30
|
+
if (!role) throw new BizError('角色不存在', CODE.NOT_FOUND);
|
|
31
|
+
|
|
32
|
+
const { menuIds, dataScope } = schema.parse(await req.json());
|
|
33
|
+
|
|
34
|
+
// 过滤掉不存在的菜单 id,避免前端传脏数据导致外键报错
|
|
35
|
+
const validMenus = menuIds.length
|
|
36
|
+
? await prisma.menu.findMany({ where: { id: { in: menuIds } }, select: { id: true } })
|
|
37
|
+
: [];
|
|
38
|
+
const validIds = validMenus.map((m) => m.id);
|
|
39
|
+
|
|
40
|
+
await prisma.$transaction([
|
|
41
|
+
prisma.roleMenu.deleteMany({ where: { roleId: id } }),
|
|
42
|
+
...(validIds.length
|
|
43
|
+
? [
|
|
44
|
+
prisma.roleMenu.createMany({
|
|
45
|
+
data: validIds.map((menuId) => ({ roleId: id, menuId })),
|
|
46
|
+
}),
|
|
47
|
+
]
|
|
48
|
+
: []),
|
|
49
|
+
...(dataScope === undefined
|
|
50
|
+
? []
|
|
51
|
+
: [prisma.role.update({ where: { id }, data: { dataScope } })]),
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const { ip, userAgent } = clientInfo(req);
|
|
55
|
+
await audit({
|
|
56
|
+
userId: operator.id,
|
|
57
|
+
username: operator.username,
|
|
58
|
+
module: '角色管理',
|
|
59
|
+
action: '分配权限',
|
|
60
|
+
method: 'PUT',
|
|
61
|
+
path: req.nextUrl.pathname,
|
|
62
|
+
targetId: id,
|
|
63
|
+
detail: {
|
|
64
|
+
roleCode: role.code,
|
|
65
|
+
menuCount: validIds.length,
|
|
66
|
+
dataScope: dataScope ?? role.dataScope,
|
|
67
|
+
},
|
|
68
|
+
ip,
|
|
69
|
+
userAgent,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return ok({ count: validIds.length, dataScope: dataScope ?? role.dataScope }, '权限已保存');
|
|
73
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { Prisma } from '@prisma/client';
|
|
3
|
+
import { BizError, CODE, audit, clientInfo, ok, withRoute } from '@/lib/api';
|
|
4
|
+
import { prisma } from '@/lib/prisma';
|
|
5
|
+
import { requireUser } from '@/auth';
|
|
6
|
+
import { P, requirePerm } from '@/lib/rbac';
|
|
7
|
+
|
|
8
|
+
export const dynamic = 'force-dynamic';
|
|
9
|
+
|
|
10
|
+
const roleSchema = z.object({
|
|
11
|
+
code: z
|
|
12
|
+
.string()
|
|
13
|
+
.trim()
|
|
14
|
+
.min(2, '角色编码至少 2 个字符')
|
|
15
|
+
.max(32)
|
|
16
|
+
.regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/, '角色编码需以字母开头,仅含字母/数字/下划线/短横线'),
|
|
17
|
+
name: z.string().trim().min(1, '角色名称必填').max(32),
|
|
18
|
+
description: z.string().max(200).nullish(),
|
|
19
|
+
sort: z.coerce.number().int().default(0),
|
|
20
|
+
status: z.coerce.number().int().min(0).max(1).default(1),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
type Ctx = { params: Promise<{ id: string }> };
|
|
24
|
+
|
|
25
|
+
/** PUT /api/roles/[id] —— 更新角色 */
|
|
26
|
+
export const PUT = withRoute<Ctx>(async (req, ctx) => {
|
|
27
|
+
const operator = await requireUser();
|
|
28
|
+
requirePerm(operator, P.roleEdit);
|
|
29
|
+
|
|
30
|
+
const { id } = await ctx.params;
|
|
31
|
+
const exist = await prisma.role.findUnique({ where: { id } });
|
|
32
|
+
if (!exist) throw new BizError('角色不存在', CODE.NOT_FOUND);
|
|
33
|
+
|
|
34
|
+
const parsed = roleSchema.parse(await req.json());
|
|
35
|
+
|
|
36
|
+
const dup = await prisma.role.findFirst({ where: { code: parsed.code, NOT: { id } } });
|
|
37
|
+
if (dup) throw new BizError('角色编码已存在', CODE.CONFLICT);
|
|
38
|
+
|
|
39
|
+
const data: Prisma.RoleUpdateInput = {
|
|
40
|
+
code: parsed.code,
|
|
41
|
+
name: parsed.name,
|
|
42
|
+
description: parsed.description || null,
|
|
43
|
+
sort: parsed.sort,
|
|
44
|
+
status: parsed.status,
|
|
45
|
+
};
|
|
46
|
+
await prisma.role.update({ where: { id }, data });
|
|
47
|
+
|
|
48
|
+
const { ip, userAgent } = clientInfo(req);
|
|
49
|
+
await audit({
|
|
50
|
+
userId: operator.id,
|
|
51
|
+
username: operator.username,
|
|
52
|
+
module: '角色管理',
|
|
53
|
+
action: '修改',
|
|
54
|
+
method: 'PUT',
|
|
55
|
+
path: req.nextUrl.pathname,
|
|
56
|
+
targetId: id,
|
|
57
|
+
detail: data,
|
|
58
|
+
ip,
|
|
59
|
+
userAgent,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return ok(null, '保存成功');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** DELETE /api/roles/[id] —— 删除角色(有用户绑定时拒绝) */
|
|
66
|
+
export const DELETE = withRoute<Ctx>(async (req, ctx) => {
|
|
67
|
+
const operator = await requireUser();
|
|
68
|
+
requirePerm(operator, P.roleDel);
|
|
69
|
+
|
|
70
|
+
const { id } = await ctx.params;
|
|
71
|
+
const exist = await prisma.role.findUnique({ where: { id } });
|
|
72
|
+
if (!exist) throw new BizError('角色不存在', CODE.NOT_FOUND);
|
|
73
|
+
|
|
74
|
+
const userCount = await prisma.userRole.count({ where: { roleId: id } });
|
|
75
|
+
if (userCount > 0) {
|
|
76
|
+
throw new BizError(`该角色已分配给 ${userCount} 个用户,请先解除绑定后再删除`, CODE.CONFLICT);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await prisma.role.delete({ where: { id } });
|
|
80
|
+
|
|
81
|
+
const { ip, userAgent } = clientInfo(req);
|
|
82
|
+
await audit({
|
|
83
|
+
userId: operator.id,
|
|
84
|
+
username: operator.username,
|
|
85
|
+
module: '角色管理',
|
|
86
|
+
action: '删除',
|
|
87
|
+
method: 'DELETE',
|
|
88
|
+
path: req.nextUrl.pathname,
|
|
89
|
+
targetId: id,
|
|
90
|
+
detail: { code: exist.code, name: exist.name },
|
|
91
|
+
ip,
|
|
92
|
+
userAgent,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return ok(null, '删除成功');
|
|
96
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { Prisma } from '@prisma/client';
|
|
3
|
+
import {
|
|
4
|
+
BizError,
|
|
5
|
+
CODE,
|
|
6
|
+
audit,
|
|
7
|
+
clientInfo,
|
|
8
|
+
ok,
|
|
9
|
+
pageResult,
|
|
10
|
+
parseListQuery,
|
|
11
|
+
withRoute,
|
|
12
|
+
} from '@/lib/api';
|
|
13
|
+
import { prisma } from '@/lib/prisma';
|
|
14
|
+
import { requireUser } from '@/auth';
|
|
15
|
+
import { P, requirePerm } from '@/lib/rbac';
|
|
16
|
+
|
|
17
|
+
export const dynamic = 'force-dynamic';
|
|
18
|
+
|
|
19
|
+
const roleSchema = z.object({
|
|
20
|
+
code: z
|
|
21
|
+
.string()
|
|
22
|
+
.trim()
|
|
23
|
+
.min(2, '角色编码至少 2 个字符')
|
|
24
|
+
.max(32)
|
|
25
|
+
.regex(/^[a-zA-Z][a-zA-Z0-9_-]*$/, '角色编码需以字母开头,仅含字母/数字/下划线/短横线'),
|
|
26
|
+
name: z.string().trim().min(1, '角色名称必填').max(32),
|
|
27
|
+
description: z.string().max(200).nullish(),
|
|
28
|
+
sort: z.coerce.number().int().default(0),
|
|
29
|
+
status: z.coerce.number().int().min(0).max(1).default(1),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** GET /api/roles —— 分页列表(带 userCount 与 menuIds,供授权抽屉回显) */
|
|
33
|
+
export const GET = withRoute(async (req) => {
|
|
34
|
+
const user = await requireUser();
|
|
35
|
+
requirePerm(user, P.roleList);
|
|
36
|
+
|
|
37
|
+
const q = parseListQuery(req, { sort: 'asc' });
|
|
38
|
+
const where: Prisma.RoleWhereInput = {};
|
|
39
|
+
if (q.keyword) {
|
|
40
|
+
where.OR = [
|
|
41
|
+
{ name: { contains: q.keyword } },
|
|
42
|
+
{ code: { contains: q.keyword } },
|
|
43
|
+
{ description: { contains: q.keyword } },
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const [rows, total] = await Promise.all([
|
|
48
|
+
prisma.role.findMany({
|
|
49
|
+
where,
|
|
50
|
+
skip: q.skip,
|
|
51
|
+
take: q.take,
|
|
52
|
+
orderBy: q.orderBy as Prisma.RoleOrderByWithRelationInput,
|
|
53
|
+
include: {
|
|
54
|
+
_count: { select: { users: true } },
|
|
55
|
+
menus: { select: { menuId: true } },
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
prisma.role.count({ where }),
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const list = rows.map((r) => ({
|
|
62
|
+
id: r.id,
|
|
63
|
+
code: r.code,
|
|
64
|
+
name: r.name,
|
|
65
|
+
description: r.description,
|
|
66
|
+
sort: r.sort,
|
|
67
|
+
status: r.status,
|
|
68
|
+
/** 数据范围档位:1 全部 2 本部门及下级 3 本部门 4 仅本人(见 lib/rbac.ts) */
|
|
69
|
+
dataScope: r.dataScope,
|
|
70
|
+
createdAt: r.createdAt,
|
|
71
|
+
updatedAt: r.updatedAt,
|
|
72
|
+
userCount: r._count.users,
|
|
73
|
+
menuIds: r.menus.map((m) => m.menuId),
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
return ok(pageResult(list, total, q));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** POST /api/roles —— 新建角色 */
|
|
80
|
+
export const POST = withRoute(async (req) => {
|
|
81
|
+
const user = await requireUser();
|
|
82
|
+
requirePerm(user, P.roleAdd);
|
|
83
|
+
|
|
84
|
+
const parsed = roleSchema.parse(await req.json());
|
|
85
|
+
|
|
86
|
+
const exists = await prisma.role.findUnique({ where: { code: parsed.code } });
|
|
87
|
+
if (exists) throw new BizError('角色编码已存在', CODE.CONFLICT);
|
|
88
|
+
|
|
89
|
+
const created = await prisma.role.create({
|
|
90
|
+
data: {
|
|
91
|
+
code: parsed.code,
|
|
92
|
+
name: parsed.name,
|
|
93
|
+
description: parsed.description || null,
|
|
94
|
+
sort: parsed.sort,
|
|
95
|
+
status: parsed.status,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const { ip, userAgent } = clientInfo(req);
|
|
100
|
+
await audit({
|
|
101
|
+
userId: user.id,
|
|
102
|
+
username: user.username,
|
|
103
|
+
module: '角色管理',
|
|
104
|
+
action: '新增',
|
|
105
|
+
method: 'POST',
|
|
106
|
+
path: req.nextUrl.pathname,
|
|
107
|
+
targetId: created.id,
|
|
108
|
+
detail: { code: parsed.code, name: parsed.name },
|
|
109
|
+
ip,
|
|
110
|
+
userAgent,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
return ok({ id: created.id }, '新增成功');
|
|
114
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ok, withRoute } from '@/lib/api';
|
|
2
|
+
import { requireUser } from '@/auth';
|
|
3
|
+
import { searchData } from '@/lib/search-data';
|
|
4
|
+
|
|
5
|
+
export const dynamic = 'force-dynamic';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* GET /api/search?q=关键词 —— 全局搜索的「数据层」。
|
|
9
|
+
*
|
|
10
|
+
* 只接收关键词、不接收表名:能搜哪些表由 ontology 决定,
|
|
11
|
+
* 能看哪些数据由服务端按当前用户权限过滤(见 lib/search-data.ts)。
|
|
12
|
+
* 功能(菜单)搜索在前端本地做,不走这个接口。
|
|
13
|
+
*/
|
|
14
|
+
export const GET = withRoute(async (req) => {
|
|
15
|
+
const user = await requireUser();
|
|
16
|
+
|
|
17
|
+
const q = (req.nextUrl.searchParams.get('q') ?? '').trim();
|
|
18
|
+
// 空词 / 超长词直接返回空:搜索框每敲一下都会打过来,别做无意义的全表 LIKE
|
|
19
|
+
if (!q || q.length > 40) return ok({ items: [] });
|
|
20
|
+
|
|
21
|
+
return ok({ items: await searchData(user, q) });
|
|
22
|
+
});
|