Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑
This commit is contained in:
30
src/server/routers/_app.ts
Normal file
30
src/server/routers/_app.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createTRPCRouter } from '@/server/trpc'
|
||||
import { usersRouter } from './users'
|
||||
import { selectionRouter } from './selection'
|
||||
import { uploadRouter } from './upload'
|
||||
import { globalRouter } from './global'
|
||||
import { devFileRouter } from './dev/file'
|
||||
import { devFrontendDesignRouter } from './dev/frontend-design'
|
||||
import { devArchRouter } from './dev/arch'
|
||||
import { jobsRouter } from './jobs'
|
||||
import { devPanelRouter } from './dev/panel'
|
||||
import { commonRouter } from './common'
|
||||
|
||||
// 这是根路由
|
||||
export const appRouter = createTRPCRouter({
|
||||
common: commonRouter,
|
||||
users: usersRouter,
|
||||
selection: selectionRouter,
|
||||
upload: uploadRouter,
|
||||
global: globalRouter,
|
||||
jobs: jobsRouter,
|
||||
|
||||
...(process.env.NODE_ENV === 'development' ? {
|
||||
devFile: devFileRouter,
|
||||
devFrontendDesign: devFrontendDesignRouter,
|
||||
devArch: devArchRouter,
|
||||
devPanel: devPanelRouter
|
||||
} : {})
|
||||
})
|
||||
|
||||
export type AppRouter = typeof appRouter
|
||||
8
src/server/routers/common.ts
Normal file
8
src/server/routers/common.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// 通用接口,与特定业务关联性不强,需要在不同的地方反复使用
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
|
||||
|
||||
export const commonRouter = createTRPCRouter({
|
||||
getDepts: permissionRequiredProcedure('').query(({ ctx }) =>
|
||||
ctx.db.dept.findMany({ orderBy: { code: 'asc' } })
|
||||
),
|
||||
})
|
||||
68
src/server/routers/dev/arch.ts
Normal file
68
src/server/routers/dev/arch.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
|
||||
import { analyzePackagesQueue } from '@/server/queues'
|
||||
import type { AnalyzePackagesProgress } from '@/server/queues'
|
||||
import { z } from 'zod'
|
||||
import { inferProcedureOutput, TRPCError } from '@trpc/server'
|
||||
|
||||
export const devArchRouter = createTRPCRouter({
|
||||
// 获取所有包类型
|
||||
getAllPkgTypes: permissionRequiredProcedure('SUPER_ADMIN_ONLY').query(async ({ ctx }) => {
|
||||
const pkgTypes = await ctx.db.devPkgType.findMany({
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
})
|
||||
return pkgTypes
|
||||
}),
|
||||
|
||||
// 获取所有依赖包数据(按类型分组)
|
||||
getAllPackages: permissionRequiredProcedure('SUPER_ADMIN_ONLY').query(async ({ ctx }) => {
|
||||
// 获取所有依赖包,包含类型信息
|
||||
const packages = await ctx.db.devAnalyzedPkg.findMany({
|
||||
include: {
|
||||
pkgType: true,
|
||||
},
|
||||
orderBy: [
|
||||
{ pkgTypeId: 'asc' },
|
||||
{ relatedFileCount: 'desc' },
|
||||
],
|
||||
})
|
||||
|
||||
// 按类型分组
|
||||
const packagesByType: Record<string, typeof packages> = {}
|
||||
|
||||
packages.forEach((pkg) => {
|
||||
const typeId = pkg.pkgTypeId
|
||||
if (!packagesByType[typeId]) {
|
||||
packagesByType[typeId] = []
|
||||
}
|
||||
packagesByType[typeId].push(pkg)
|
||||
})
|
||||
|
||||
return packagesByType
|
||||
}),
|
||||
|
||||
// 启动依赖包分析任务
|
||||
startAnalyzePackages: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.mutation(async () => {
|
||||
const job = await analyzePackagesQueue.add('analyze-packages', {})
|
||||
return { jobId: job.id }
|
||||
}),
|
||||
|
||||
// 取消依赖包分析任务
|
||||
cancelAnalyzePackagesJob: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({ jobId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const job = await analyzePackagesQueue.getJob(input.jobId)
|
||||
if (!job) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '任务不存在' })
|
||||
}
|
||||
|
||||
// 更新进度标记为已取消
|
||||
await job.updateProgress({ ...(job.progress as AnalyzePackagesProgress), canceled: true })
|
||||
return { success: true }
|
||||
}),
|
||||
})
|
||||
|
||||
export type DevArchRouter = typeof devArchRouter;
|
||||
export type PackageData = inferProcedureOutput<DevArchRouter['getAllPackages']>[string][number];
|
||||
602
src/server/routers/dev/file.ts
Normal file
602
src/server/routers/dev/file.ts
Normal file
@@ -0,0 +1,602 @@
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
|
||||
import { dataTableQueryParamsSchema } from '@/lib/schema/data-table'
|
||||
import { transformDataTableQueryParams } from '@/server/utils/data-table-helper'
|
||||
import { analyzeFilesQueue, analyzeFoldersQueue } from '@/server/queues'
|
||||
import type { AnalyzeFilesProgress, AnalyzeFoldersProgress } from '@/server/queues'
|
||||
import { getProjectFiles, getFileGitHistory, getFileContentAtCommit, getFileDiffBetweenCommits } from '@/server/utils/git-helper'
|
||||
import { z } from 'zod'
|
||||
import { inferProcedureOutput, TRPCError } from '@trpc/server'
|
||||
|
||||
export const devFileRouter = createTRPCRouter({
|
||||
// 获取最近分析时间
|
||||
getLatestAnalyzedTime: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async ({ ctx }) => {
|
||||
const latestFile = await ctx.db.devAnalyzedFile.findFirst({
|
||||
orderBy: {
|
||||
lastAnalyzedAt: 'desc',
|
||||
},
|
||||
select: {
|
||||
lastAnalyzedAt: true,
|
||||
},
|
||||
})
|
||||
|
||||
return latestFile?.lastAnalyzedAt || null
|
||||
}),
|
||||
|
||||
// 获取 Commit ID 统计(包含每个 commit 的文件数量和最小分析时间)
|
||||
getCommitIdStats: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async ({ ctx }) => {
|
||||
// 聚合统计每个 commitId 的文件数量和最小 lastAnalyzedAt
|
||||
const stats = await ctx.db.devAnalyzedFile.groupBy({
|
||||
by: ['commitId'],
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
_min: {
|
||||
lastAnalyzedAt: true,
|
||||
},
|
||||
orderBy: [
|
||||
{ _min: { lastAnalyzedAt: 'desc'} },
|
||||
{ commitId: 'desc' }
|
||||
]
|
||||
})
|
||||
|
||||
return stats
|
||||
.map(stat => ({
|
||||
id: stat.commitId,
|
||||
name: stat.commitId,
|
||||
count: stat._count.id,
|
||||
minAnalyzedAt: stat._min.lastAnalyzedAt,
|
||||
}))
|
||||
}),
|
||||
|
||||
getTagsStats: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async ({ ctx }) => {
|
||||
const data = await ctx.db.devAnalyzedFile.findMany({
|
||||
where: {
|
||||
tags: {
|
||||
isEmpty: false
|
||||
}
|
||||
},
|
||||
select: { tags: true }
|
||||
});
|
||||
const tagCounts = data.reduce((acc, data) => {
|
||||
data.tags.forEach(value => {
|
||||
acc[value] = (acc[value] || 0) + 1;
|
||||
});
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
return Object.entries(tagCounts)
|
||||
.map(([name, count]) => ({
|
||||
id: name,
|
||||
name: name,
|
||||
count: count
|
||||
}))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}),
|
||||
|
||||
// 获取包依赖统计
|
||||
getPkgDependencyStats: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async ({ ctx }) => {
|
||||
// 统计每个包名的使用次数(按不同的源文件计数)
|
||||
const stats = await ctx.db.devFilePkgDependency.groupBy({
|
||||
by: ['packageName'],
|
||||
_count: {
|
||||
sourceFileId: true,
|
||||
},
|
||||
orderBy: {
|
||||
packageName: 'asc',
|
||||
},
|
||||
})
|
||||
|
||||
return stats.map(stat => ({
|
||||
id: stat.packageName,
|
||||
name: stat.packageName,
|
||||
count: stat._count.sourceFileId,
|
||||
}))
|
||||
}),
|
||||
|
||||
|
||||
// 获取文件类型统计(包含每种类型的文件数量)
|
||||
getFileTypeStats: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async ({ ctx }) => {
|
||||
// 获取所有文件类型
|
||||
const fileTypes = await ctx.db.devFileType.findMany({
|
||||
orderBy: { order: 'asc' },
|
||||
})
|
||||
|
||||
// 统计每种文件类型的数量
|
||||
const stats = await ctx.db.devAnalyzedFile.groupBy({
|
||||
by: ['fileTypeId'],
|
||||
_count: {
|
||||
fileTypeId: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 创建统计映射
|
||||
const countMap = new Map(
|
||||
stats.map(stat => [stat.fileTypeId, stat._count.fileTypeId])
|
||||
)
|
||||
|
||||
// 返回带有数量的文件类型列表
|
||||
return fileTypes.map(fileType => ({
|
||||
id: fileType.id,
|
||||
name: fileType.name,
|
||||
count: countMap.get(fileType.id) || 0,
|
||||
}))
|
||||
}),
|
||||
|
||||
// 获取已分析文件列表
|
||||
listAnalyzedFiles: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(dataTableQueryParamsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { where, orderBy, skip, take } = transformDataTableQueryParams(input, {
|
||||
model: 'DevAnalyzedFile',
|
||||
columns: {
|
||||
path: { field: 'path', sortable: true },
|
||||
fileName: { field: 'fileName', sortable: true },
|
||||
commitId: { field: 'commitId', variant: 'select', sortable: true },
|
||||
fileTypeId: { field: 'fileTypeId', variant: 'select', sortable: true },
|
||||
summary: { field: 'summary', sortable: true },
|
||||
pkgDependencies: { field: 'pkgDependencies.some.packageName', variant: 'multiSelect' },
|
||||
createdAt: { field: 'createdAt', variant: 'dateRange', sortable: true },
|
||||
lastAnalyzedAt: { field: 'lastAnalyzedAt', variant: 'dateRange', sortable: true },
|
||||
},
|
||||
})
|
||||
|
||||
// 如果对 commitId 排序,则转换为对 lastAnalyzedAt 排序
|
||||
const commitIdSortIndex = input.sorting.findIndex(s => s.id === 'commitId')
|
||||
if (commitIdSortIndex !== -1) {
|
||||
const commitIdSort = input.sorting[commitIdSortIndex]!
|
||||
// 移除 commitId 排序,添加 lastAnalyzedAt 排序
|
||||
orderBy.splice(commitIdSortIndex, 0, { lastAnalyzedAt: commitIdSort.desc ? 'desc' : 'asc' })
|
||||
}
|
||||
|
||||
const conditions = []
|
||||
|
||||
const fileNameFilter = input.filters.find(f => f.id === 'fileName')
|
||||
if (fileNameFilter?.value) {
|
||||
conditions.push({
|
||||
OR: [
|
||||
{ path: { contains: fileNameFilter.value, mode: 'insensitive' } },
|
||||
{ fileName: { contains: fileNameFilter.value, mode: 'insensitive' } },
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const summaryFilter = input.filters.find(f => f.id === 'summary')
|
||||
if (summaryFilter?.value) {
|
||||
conditions.push({
|
||||
OR: [
|
||||
{ summary: { contains: summaryFilter.value, mode: 'insensitive' } },
|
||||
{ description: { contains: summaryFilter.value, mode: 'insensitive' } },
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const tagsFilter = input.filters.find(f => f.id === 'tags')
|
||||
if (tagsFilter && Array.isArray(tagsFilter.value) && tagsFilter.value.length > 0) {
|
||||
conditions.push({ tags: { hasSome: tagsFilter.value } })
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
where.AND = conditions
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
ctx.db.devAnalyzedFile.findMany({
|
||||
where,
|
||||
orderBy: orderBy.some(item => 'id' in item) ? orderBy : [...orderBy, { id: 'asc' }],
|
||||
skip,
|
||||
take,
|
||||
select: {
|
||||
id: true,
|
||||
path: true,
|
||||
fileName: true,
|
||||
fileTypeId: true,
|
||||
summary: true,
|
||||
description: true,
|
||||
exportedMembers: true,
|
||||
tags: true,
|
||||
commitId: true,
|
||||
lastAnalyzedAt: true,
|
||||
createdAt: true,
|
||||
fileType: true,
|
||||
dependencies: {
|
||||
select: {
|
||||
targetFilePath: true,
|
||||
usageDescription: true,
|
||||
},
|
||||
},
|
||||
pkgDependencies: {
|
||||
select: {
|
||||
packageName: true,
|
||||
usageDescription: true,
|
||||
},
|
||||
},
|
||||
// 排除 content 字段以提升性能
|
||||
},
|
||||
}),
|
||||
ctx.db.devAnalyzedFile.count({ where }),
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}),
|
||||
|
||||
// 启动文件分析任务
|
||||
startAnalyzeFiles: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.mutation(async () => {
|
||||
const job = await analyzeFilesQueue.add('analyze-files', {})
|
||||
return { jobId: job.id }
|
||||
}),
|
||||
|
||||
// 取消文件分析任务
|
||||
cancelAnalyzeFilesJob: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({ jobId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const job = await analyzeFilesQueue.getJob(input.jobId)
|
||||
if (!job) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '任务不存在' })
|
||||
}
|
||||
|
||||
// 更新进度标记为已取消
|
||||
await job.updateProgress({ ...(job.progress as AnalyzeFilesProgress), canceled: true })
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// 启动文件夹分析任务
|
||||
startAnalyzeFolders: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.mutation(async () => {
|
||||
const job = await analyzeFoldersQueue.add('analyze-folders', {})
|
||||
return { jobId: job.id }
|
||||
}),
|
||||
|
||||
// 取消文件夹分析任务
|
||||
cancelAnalyzeFoldersJob: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({ jobId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const job = await analyzeFoldersQueue.getJob(input.jobId)
|
||||
if (!job) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '任务不存在' })
|
||||
}
|
||||
|
||||
// 更新进度标记为已取消
|
||||
await job.updateProgress({ ...(job.progress as AnalyzeFoldersProgress), canceled: true })
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// 获取文件详情
|
||||
getFileById: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const file = await ctx.db.devAnalyzedFile.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
fileType: true,
|
||||
dependencies: {
|
||||
select: {
|
||||
targetFilePath: true,
|
||||
usageDescription: true,
|
||||
},
|
||||
},
|
||||
pkgDependencies: {
|
||||
select: {
|
||||
packageName: true,
|
||||
usageDescription: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
if (!file) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '文件不存在' })
|
||||
}
|
||||
|
||||
return file
|
||||
}),
|
||||
|
||||
// 获取文件内容
|
||||
getFileContent: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const file = await ctx.db.devAnalyzedFile.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { content: true },
|
||||
})
|
||||
|
||||
if (!file) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '文件不存在' })
|
||||
}
|
||||
|
||||
return file.content
|
||||
}),
|
||||
|
||||
// 获取文件依赖图数据
|
||||
getDependencyGraph: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async ({ ctx }) => {
|
||||
// 获取项目中当前存在的文件
|
||||
const currentFiles = new Set(await getProjectFiles())
|
||||
|
||||
// 获取所有文件最近一次分析记录
|
||||
const files = await ctx.db.devAnalyzedFile.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
path: true,
|
||||
fileName: true,
|
||||
fileTypeId: true,
|
||||
summary: true,
|
||||
dependencies: {
|
||||
select: {
|
||||
targetFilePath: true,
|
||||
},
|
||||
},
|
||||
fileType: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
lastAnalyzedAt: 'desc'
|
||||
},
|
||||
distinct: ['path'], // 当存在多条记录拥有相同的distinct字段组合时,Prisma会返回这些记录中的第一条
|
||||
})
|
||||
|
||||
// 构建路径到ID的映射
|
||||
const pathToIdMap = new Map<string, number>()
|
||||
files.forEach(file => {
|
||||
pathToIdMap.set(file.path, file.id)
|
||||
})
|
||||
|
||||
// 构建节点数据,标记已删除的文件
|
||||
const nodes = files.map(file => ({
|
||||
id: String(file.id),
|
||||
path: file.path,
|
||||
fileName: file.fileName,
|
||||
fileTypeId: file.fileTypeId,
|
||||
fileTypeName: file.fileType?.name || file.fileTypeId,
|
||||
summary: file.summary,
|
||||
dependencyCount: file.dependencies?.length || 0,
|
||||
isDeleted: !currentFiles.has(file.path), // 标记文件是否已删除
|
||||
}))
|
||||
|
||||
// 构建边数据
|
||||
const edges: Array<{ source: string; target: string; label?: string }> = []
|
||||
files.forEach(file => {
|
||||
if (file.dependencies && file.dependencies.length > 0) {
|
||||
file.dependencies.forEach(dep => {
|
||||
const targetId = pathToIdMap.get(dep.targetFilePath)
|
||||
if (targetId) {
|
||||
edges.push({
|
||||
source: String(file.id),
|
||||
target: String(targetId),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes, edges }
|
||||
}),
|
||||
|
||||
// 获取文件的Git变更历史
|
||||
getFileGitHistory: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// 先获取文件信息
|
||||
const file = await ctx.db.devAnalyzedFile.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { path: true },
|
||||
})
|
||||
|
||||
if (!file) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '文件不存在' })
|
||||
}
|
||||
|
||||
// 获取Git历史
|
||||
try {
|
||||
const history = await getFileGitHistory(file.path)
|
||||
return history
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `获取Git历史失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取指定commit时文件的内容
|
||||
getFileContentAtCommit: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
commitId: z.string(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// 先获取文件信息
|
||||
const file = await ctx.db.devAnalyzedFile.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { path: true },
|
||||
})
|
||||
|
||||
if (!file) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '文件不存在' })
|
||||
}
|
||||
|
||||
// 获取指定commit的文件内容
|
||||
try {
|
||||
const content = await getFileContentAtCommit(file.path, input.commitId)
|
||||
return content
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `获取文件内容失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取文件在两个commit之间的差异
|
||||
getFileDiffBetweenCommits: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
oldCommitId: z.string(),
|
||||
newCommitId: z.string(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// 先获取文件信息
|
||||
const file = await ctx.db.devAnalyzedFile.findUnique({
|
||||
where: { id: input.id },
|
||||
select: { path: true },
|
||||
})
|
||||
|
||||
if (!file) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '文件不存在' })
|
||||
}
|
||||
|
||||
// 获取文件差异
|
||||
try {
|
||||
const diff = await getFileDiffBetweenCommits(file.path, input.oldCommitId, input.newCommitId)
|
||||
return diff
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `获取文件差异失败: ${error instanceof Error ? error.message : '未知错误'}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取目录树结构(包含文件和文件夹的summary)
|
||||
getDirectoryTree: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async ({ ctx }) => {
|
||||
// 获取所有已分析的文件(最新版本),只保留当前存在的文件
|
||||
const files = await ctx.db.devAnalyzedFile.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
path: true,
|
||||
summary: true,
|
||||
},
|
||||
orderBy: { lastAnalyzedAt: 'desc' },
|
||||
distinct: ['path'],
|
||||
})
|
||||
|
||||
// 获取所有已分析的文件夹
|
||||
const folders = await ctx.db.devAnalyzedFolder.findMany({
|
||||
select: {
|
||||
path: true,
|
||||
summary: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 构建路径到summary和id的映射
|
||||
const pathSummaryMap = new Map<string, string>()
|
||||
const pathIdMap = new Map<string, number>()
|
||||
files.forEach(file => {
|
||||
pathSummaryMap.set(file.path, file.summary)
|
||||
pathIdMap.set(file.path, file.id)
|
||||
})
|
||||
folders.forEach(folder => pathSummaryMap.set(folder.path, folder.summary))
|
||||
|
||||
// 构建文件树结构
|
||||
interface TreeNode {
|
||||
name: string
|
||||
path: string
|
||||
isFolder: boolean
|
||||
summary?: string
|
||||
fileId?: number
|
||||
children?: TreeNode[]
|
||||
}
|
||||
|
||||
const root: TreeNode = {
|
||||
name: '',
|
||||
path: '',
|
||||
isFolder: true,
|
||||
summary: pathSummaryMap.get('/') || '项目根目录',
|
||||
children: [],
|
||||
}
|
||||
|
||||
// 收集所有需要的路径(文件及其父目录)
|
||||
const allPaths = new Set<string>()
|
||||
files.forEach(file => {
|
||||
allPaths.add(file.path)
|
||||
// 添加所有父目录
|
||||
const parts = file.path.split('/').filter(Boolean)
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
allPaths.add(parts.slice(0, i).join('/'))
|
||||
}
|
||||
})
|
||||
|
||||
// 添加已分析的文件夹路径
|
||||
folders.forEach(folder => {
|
||||
allPaths.add(folder.path)
|
||||
})
|
||||
|
||||
// 按路径深度排序
|
||||
const sortedPaths = Array.from(allPaths).sort((a, b) => {
|
||||
const depthDiff = a.split('/').length - b.split('/').length
|
||||
return depthDiff !== 0 ? depthDiff : a.localeCompare(b)
|
||||
})
|
||||
|
||||
// 路径到节点的映射
|
||||
const pathToNode = new Map<string, TreeNode>()
|
||||
pathToNode.set(root.path, root)
|
||||
|
||||
// 构建树结构
|
||||
const filePathSet = new Set(files.map(f => f.path))
|
||||
sortedPaths.forEach(path => {
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
const name = parts[parts.length - 1]!
|
||||
const parentPath = parts.length === 1 ? '' : parts.slice(0, -1).join('/')
|
||||
const isFile = filePathSet.has(path)
|
||||
|
||||
const node: TreeNode = {
|
||||
name,
|
||||
path,
|
||||
isFolder: !isFile,
|
||||
summary: pathSummaryMap.get(path),
|
||||
fileId: isFile ? pathIdMap.get(path) : undefined,
|
||||
children: isFile ? undefined : [],
|
||||
}
|
||||
|
||||
pathToNode.set(path, node)
|
||||
|
||||
// 添加到父节点
|
||||
const parentNode = pathToNode.get(parentPath)
|
||||
if (parentNode?.children) {
|
||||
parentNode.children.push(node)
|
||||
}
|
||||
})
|
||||
|
||||
// 递归排序:文件夹在前,然后按字母表顺序
|
||||
const sortChildren = (node: TreeNode) => {
|
||||
if (node.children?.length) {
|
||||
node.children.sort((a, b) =>
|
||||
a.isFolder !== b.isFolder ? (a.isFolder ? -1 : 1) : a.name.localeCompare(b.name)
|
||||
)
|
||||
node.children.forEach(sortChildren)
|
||||
}
|
||||
}
|
||||
|
||||
sortChildren(root)
|
||||
|
||||
return root
|
||||
}),
|
||||
|
||||
// 获取文件夹详情
|
||||
getFolderDetail: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({ path: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const folder = await ctx.db.devAnalyzedFolder.findUnique({
|
||||
where: { path: input.path },
|
||||
})
|
||||
|
||||
if (!folder) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '文件夹不存在' })
|
||||
}
|
||||
|
||||
return folder
|
||||
}),
|
||||
})
|
||||
|
||||
export type DevFileRouter = typeof devFileRouter;
|
||||
export type DevAnalyzedFile = inferProcedureOutput<DevFileRouter['listAnalyzedFiles']>['data'][number];
|
||||
export type FileTreeItem = inferProcedureOutput<DevFileRouter['getDirectoryTree']>;
|
||||
221
src/server/routers/dev/frontend-design.ts
Normal file
221
src/server/routers/dev/frontend-design.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
|
||||
import { z } from 'zod'
|
||||
import { generateUIComponentDemo } from '@/server/agents/ui-demo-generator'
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
export const devFrontendDesignRouter = createTRPCRouter({
|
||||
// 获取UI组件列表
|
||||
getUIComponents: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async ({ ctx }) => {
|
||||
const components = await ctx.db.devAnalyzedFile.findMany({
|
||||
orderBy: {
|
||||
lastAnalyzedAt: 'desc'
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
path: true,
|
||||
fileName: true,
|
||||
summary: true,
|
||||
lastAnalyzedAt: true,
|
||||
},
|
||||
distinct: ['path'], // 当存在多条记录拥有相同的distinct字段组合时,Prisma会返回这些记录中的第一条
|
||||
});
|
||||
|
||||
// 检查文件是否存在,过滤掉不存在的文件
|
||||
const validComponents = await Promise.all(
|
||||
components.map(async (component) => {
|
||||
if (!component.path.startsWith("src/components/")) {
|
||||
return null;
|
||||
}
|
||||
const fullPath = path.join(process.cwd(), component.path)
|
||||
try {
|
||||
await fs.access(fullPath)
|
||||
return component
|
||||
} catch {
|
||||
// 文件不存在,返回null
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return validComponents.filter(Boolean) as typeof components
|
||||
}),
|
||||
|
||||
// 生成UI组件演示代码
|
||||
generateComponentDemo: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
componentPaths: z.array(z.string()),
|
||||
prompt: z.string(),
|
||||
images: z.array(z.object({
|
||||
url: z.string(), // base64编码的图片数据
|
||||
mediaType: z.string(), // 图片MIME类型,如 image/png
|
||||
})).optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// 获取选中组件的源代码和导出信息
|
||||
const components = await ctx.db.devAnalyzedFile.findMany({
|
||||
where: {
|
||||
path: {
|
||||
in: input.componentPaths
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
lastAnalyzedAt: 'desc'
|
||||
},
|
||||
select: {
|
||||
path: true,
|
||||
fileName: true,
|
||||
content: true,
|
||||
summary: true,
|
||||
exportedMembers: true,
|
||||
},
|
||||
distinct: ['path']
|
||||
})
|
||||
|
||||
// 过滤掉没有内容的组件
|
||||
const validComponents = components.filter(c => c.content)
|
||||
|
||||
// 调用AI生成代码
|
||||
const generatedCode = await generateUIComponentDemo(
|
||||
validComponents.map(c => ({
|
||||
path: c.path,
|
||||
fileName: c.fileName,
|
||||
content: c.content!,
|
||||
summary: c.summary,
|
||||
})),
|
||||
input.prompt,
|
||||
input.images
|
||||
)
|
||||
|
||||
// 返回组件信息,包括导出的符号
|
||||
return {
|
||||
code: generatedCode,
|
||||
components: components.map(c => ({
|
||||
path: c.path,
|
||||
fileName: c.fileName,
|
||||
exportedMembers: c.exportedMembers as { name: string, type: string }[],
|
||||
}))
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取可用的registry列表
|
||||
getRegistries: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async () => {
|
||||
const componentsJsonPath = path.join(process.cwd(), 'components.json')
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(componentsJsonPath, 'utf-8')
|
||||
const config = JSON.parse(content)
|
||||
|
||||
if (!config.registries || typeof config.registries !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
// 将registries对象转换为数组,并提取网站链接
|
||||
return Object.entries(config.registries).map(([name, url]) => {
|
||||
const urlStr = url as string
|
||||
// 提取网站根路径
|
||||
let websiteUrl = ''
|
||||
try {
|
||||
const urlObj = new URL(urlStr.replace('{name}.json', ''))
|
||||
websiteUrl = urlObj.origin
|
||||
// 如果域名以registry.开头,去掉这个前缀
|
||||
if (urlObj.hostname.startsWith('registry.')) {
|
||||
const newHostname = urlObj.hostname.replace(/^registry\./, '')
|
||||
websiteUrl = `${urlObj.protocol}//${newHostname}`
|
||||
}
|
||||
} catch {
|
||||
// URL解析失败,使用空字符串
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
url: urlStr,
|
||||
websiteUrl,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('读取components.json失败:', error)
|
||||
return []
|
||||
}
|
||||
}),
|
||||
|
||||
// 搜索组件
|
||||
searchComponents: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
registries: z.array(z.string()), // registry名称数组,如 ['@ai-elements', '@basecn']
|
||||
query: z.string(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
// 并行执行所有registry的搜索
|
||||
const results = await Promise.all(
|
||||
input.registries.map(async (registry) => {
|
||||
try {
|
||||
const command = `npx shadcn@latest search ${registry} --query "${input.query}" --limit 5`
|
||||
const { stdout } = await execAsync(command, {
|
||||
cwd: process.cwd(),
|
||||
timeout: 30000, // 30秒超时
|
||||
})
|
||||
|
||||
// 解析JSON格式的输出
|
||||
try {
|
||||
const parsed = JSON.parse(stdout)
|
||||
|
||||
// 检查是否有items字段
|
||||
if (parsed.items && Array.isArray(parsed.items)) {
|
||||
const items = parsed.items.map((item: any) => ({
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
type: item.type,
|
||||
addCommandArgument: item.addCommandArgument,
|
||||
}))
|
||||
|
||||
return { registry, items }
|
||||
} else {
|
||||
// 如果没有items字段,返回空数组
|
||||
return { registry, items: [] }
|
||||
}
|
||||
} catch (parseError) {
|
||||
// JSON解析失败
|
||||
return { registry, items: [], error: 'JSON解析失败' }
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 命令执行失败,记录错误但继续处理其他registry
|
||||
return { registry, items: [], error: error.message || '搜索失败' }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return results
|
||||
}),
|
||||
|
||||
// 获取组件详细信息
|
||||
viewComponent: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
componentName: z.string(), // 组件名称,如 @shadcn/tabs
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const command = `npx shadcn@latest view ${input.componentName}`
|
||||
const { stdout } = await execAsync(command, {
|
||||
cwd: process.cwd(),
|
||||
timeout: 30000, // 30秒超时
|
||||
})
|
||||
|
||||
// 解析JSON格式的输出
|
||||
const parsed = JSON.parse(stdout)
|
||||
|
||||
// 返回解析后的数据
|
||||
return parsed
|
||||
} catch (error: any) {
|
||||
throw new Error(`获取组件详情失败: ${error.message}`)
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export type DevFrontendDesignRouter = typeof devFrontendDesignRouter;
|
||||
229
src/server/routers/dev/panel.ts
Normal file
229
src/server/routers/dev/panel.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
|
||||
import { z } from 'zod'
|
||||
import { sendTmuxCommand } from '@/server/service/dev/terminal'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import {
|
||||
getBranches,
|
||||
getCommitHistory,
|
||||
createCommit,
|
||||
checkoutCommit,
|
||||
checkoutBranch,
|
||||
revertCommit,
|
||||
resetToCommit,
|
||||
getCurrentBranch,
|
||||
hasUncommittedChanges,
|
||||
} from '@/server/utils/git-helper'
|
||||
|
||||
|
||||
export const devPanelRouter = createTRPCRouter({
|
||||
/**
|
||||
* 发送tmux命令到终端
|
||||
*/
|
||||
sendTmuxCommand: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
command: z.string().min(1, '命令不能为空'),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
// 发送命令到tmux session,没必要验证输入,因为本来就是给开发者在开发模式下用的
|
||||
const result = await sendTmuxCommand(input.command)
|
||||
|
||||
return { success: true, output: result }
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `执行命令失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 获取所有分支列表
|
||||
*/
|
||||
getBranches: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async () => {
|
||||
try {
|
||||
const branches = await getBranches()
|
||||
return branches
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `获取分支列表失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 获取当前分支
|
||||
*/
|
||||
getCurrentBranch: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async () => {
|
||||
try {
|
||||
const branch = await getCurrentBranch()
|
||||
return { branch }
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `获取当前分支失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 获取提交历史
|
||||
*/
|
||||
getCommitHistory: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
limit: z.number().min(1).max(200).default(50),
|
||||
branchName: z.string().optional(), // 可选的分支名称,用于查看指定分支的历史
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const commits = await getCommitHistory(input.limit, input.branchName)
|
||||
return commits
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `获取提交历史失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 创建新提交
|
||||
*/
|
||||
createCommit: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
message: z.string().min(1, '提交信息不能为空'),
|
||||
amend: z.boolean().optional().default(false),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const commitId = await createCommit(input.message, input.amend)
|
||||
return { success: true, commitId, message: input.amend ? '修订提交成功' : '提交成功' }
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `创建提交失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 切换到指定提交
|
||||
*/
|
||||
checkoutCommit: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
commitId: z.string().min(1, '提交ID不能为空'),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
// 检查是否有未提交的更改
|
||||
const hasChanges = await hasUncommittedChanges()
|
||||
if (hasChanges) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '有未提交的更改,请先提交或暂存更改',
|
||||
})
|
||||
}
|
||||
|
||||
await checkoutCommit(input.commitId)
|
||||
return { success: true, message: `已切换到提交: ${input.commitId}` }
|
||||
} catch (error: any) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `切换提交失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 切换到指定分支
|
||||
*/
|
||||
checkoutBranch: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
branchName: z.string().min(1, '分支名称不能为空'),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
// 检查是否有未提交的更改
|
||||
const hasChanges = await hasUncommittedChanges()
|
||||
if (hasChanges) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '有未提交的更改,请先提交或暂存更改',
|
||||
})
|
||||
}
|
||||
|
||||
await checkoutBranch(input.branchName)
|
||||
return { success: true, message: `已切换到分支: ${input.branchName}` }
|
||||
} catch (error: any) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `切换分支失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 反转提交(git revert)
|
||||
*/
|
||||
revertCommit: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
commitId: z.string().min(1, '提交ID不能为空'),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const newCommitId = await revertCommit(input.commitId)
|
||||
return { success: true, newCommitId, message: '已创建反转提交' }
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `反转提交失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 强制回滚到指定提交(git reset --hard)
|
||||
*/
|
||||
resetToCommit: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.input(z.object({
|
||||
commitId: z.string().min(1, '提交ID不能为空'),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await resetToCommit(input.commitId)
|
||||
return { success: true, message: `已强制回滚到提交: ${input.commitId}` }
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `强制回滚失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 检查是否有未提交的更改
|
||||
*/
|
||||
hasUncommittedChanges: permissionRequiredProcedure('SUPER_ADMIN_ONLY')
|
||||
.query(async () => {
|
||||
try {
|
||||
const hasChanges = await hasUncommittedChanges()
|
||||
return { hasChanges }
|
||||
} catch (error: any) {
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: `检查未提交更改失败: ${error.message}`,
|
||||
})
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export type DevPanelRouter = typeof devPanelRouter
|
||||
37
src/server/routers/global.ts
Normal file
37
src/server/routers/global.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createTRPCRouter, publicProcedure } from "@/server/trpc";
|
||||
|
||||
/**
|
||||
* 用于处理系统全局配置和与业务关联不大的API
|
||||
*/
|
||||
export const globalRouter = createTRPCRouter({
|
||||
/**
|
||||
* 检查是否已显示过欢迎对话框
|
||||
*/
|
||||
checkWelcomeShown: publicProcedure
|
||||
.query(async ({ ctx }) => {
|
||||
const config = await ctx.db.kVConfig.findUnique({
|
||||
where: { key: 'welcome_shown' },
|
||||
});
|
||||
return { shown: !!config };
|
||||
}),
|
||||
|
||||
/**
|
||||
* 标记欢迎对话框已显示
|
||||
*/
|
||||
markWelcomeShown: publicProcedure
|
||||
.mutation(async ({ ctx }) => {
|
||||
await ctx.db.kVConfig.upsert({
|
||||
where: { key: 'welcome_shown' },
|
||||
create: {
|
||||
key: 'welcome_shown',
|
||||
value: 'true',
|
||||
},
|
||||
update: {
|
||||
value: 'true',
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
export type GlobalRouter = typeof globalRouter;
|
||||
205
src/server/routers/jobs.ts
Normal file
205
src/server/routers/jobs.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
|
||||
import { tracked } from '@trpc/server'
|
||||
import { z } from 'zod'
|
||||
import type { Queue, QueueEvents } from 'bullmq'
|
||||
import {
|
||||
AnalyzeFilesProgress,
|
||||
analyzeFilesQueue,
|
||||
analyzeFilesQueueEvents,
|
||||
AnalyzePackagesProgress,
|
||||
analyzePackagesQueue,
|
||||
analyzePackagesQueueEvents,
|
||||
AnalyzeFoldersProgress,
|
||||
analyzeFoldersQueue,
|
||||
analyzeFoldersQueueEvents,
|
||||
} from '../queues'
|
||||
import { AsyncQueue } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* 任务状态类型
|
||||
*/
|
||||
export type TaskState = 'waiting' | 'active' | 'completed' | 'failed'
|
||||
|
||||
/**
|
||||
* 基础任务进度接口
|
||||
*/
|
||||
export interface BaseTaskProgress {
|
||||
jobId: string
|
||||
state: TaskState
|
||||
progressPercent: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 队列配置接口
|
||||
*/
|
||||
interface QueueConfig<TProgress> {
|
||||
queue: Queue
|
||||
events: QueueEvents
|
||||
calculateProgress: (progress: TProgress) => number
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建任务进度订阅的工厂函数
|
||||
*/
|
||||
function createProgressSubscription<TProgress>(
|
||||
queueConfig: QueueConfig<TProgress>
|
||||
) {
|
||||
return permissionRequiredProcedure('')
|
||||
.input(
|
||||
z.object({
|
||||
jobId: z.string(),
|
||||
lastEventId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.subscription(async function* (opts) {
|
||||
const { jobId } = opts.input
|
||||
const { queue, events, calculateProgress } = queueConfig
|
||||
|
||||
// 创建异步队列用于推送数据
|
||||
const dataQueue = new AsyncQueue<BaseTaskProgress & TProgress>()
|
||||
|
||||
// 获取任务当前状态
|
||||
const job = await queue.getJob(jobId)
|
||||
if (!job) {
|
||||
yield tracked(jobId, {
|
||||
jobId,
|
||||
state: 'failed' as TaskState,
|
||||
progressPercent: 0,
|
||||
error: '任务不存在'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 发送初始状态
|
||||
const state = await job.getState()
|
||||
const progress = job.progress as TProgress
|
||||
|
||||
yield tracked(jobId, {
|
||||
jobId,
|
||||
state: state as TaskState,
|
||||
progressPercent: progress ? calculateProgress(progress) : 0,
|
||||
...(progress),
|
||||
...(job.failedReason && { error: job.failedReason })
|
||||
})
|
||||
|
||||
// 如果任务已经完成或失败,直接返回
|
||||
if (state === 'completed' || state === 'failed') {
|
||||
return
|
||||
}
|
||||
|
||||
// 监听进度更新
|
||||
const progressListener = ({ jobId: eventJobId, data }: any) => {
|
||||
if (eventJobId === jobId) {
|
||||
const progressData = data as TProgress
|
||||
dataQueue.push({
|
||||
jobId,
|
||||
state: 'active' as TaskState,
|
||||
progressPercent: calculateProgress(progressData),
|
||||
...(progressData)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 监听完成
|
||||
const completedListener = async ({ jobId: eventJobId }: any) => {
|
||||
if (eventJobId === jobId) {
|
||||
const completedJob = await queue.getJob(jobId)
|
||||
if (completedJob) {
|
||||
const completedProgress = completedJob.progress as TProgress
|
||||
dataQueue.push({
|
||||
jobId,
|
||||
state: 'completed' as TaskState,
|
||||
progressPercent: 100,
|
||||
...(completedProgress)
|
||||
})
|
||||
}
|
||||
dataQueue.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听失败
|
||||
const failedListener = async ({ jobId: eventJobId, failedReason }: any) => {
|
||||
if (eventJobId === jobId) {
|
||||
const failedJob = await queue.getJob(jobId)
|
||||
const failedProgress = failedJob?.progress as TProgress
|
||||
dataQueue.push({
|
||||
jobId,
|
||||
state: 'failed' as TaskState,
|
||||
progressPercent: failedProgress ? calculateProgress(failedProgress) : 0,
|
||||
...(failedProgress),
|
||||
error: failedReason
|
||||
})
|
||||
dataQueue.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// 注册事件监听器
|
||||
events.on('progress', progressListener)
|
||||
events.on('completed', completedListener)
|
||||
events.on('failed', failedListener)
|
||||
|
||||
// 清理函数
|
||||
const cleanup = () => {
|
||||
events.off('progress', progressListener)
|
||||
events.off('completed', completedListener)
|
||||
events.off('failed', failedListener)
|
||||
dataQueue.finish()
|
||||
}
|
||||
|
||||
// 监听客户端断开连接
|
||||
opts.signal?.addEventListener('abort', cleanup)
|
||||
|
||||
try {
|
||||
// 持续yield数据直到队列结束
|
||||
for await (const data of dataQueue) {
|
||||
yield tracked(jobId, data)
|
||||
}
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 队列配置映射
|
||||
*/
|
||||
const queueConfigs = {
|
||||
analyzeFiles: {
|
||||
queue: analyzeFilesQueue,
|
||||
events: analyzeFilesQueueEvents,
|
||||
calculateProgress: (progress: AnalyzeFilesProgress) => {
|
||||
return (progress.analyzedFiles !== undefined && progress.totalFiles)
|
||||
? Math.round(((progress.analyzedFiles + (progress.failedFiles ?? 0)) / progress.totalFiles) * 100)
|
||||
: 0
|
||||
}
|
||||
},
|
||||
analyzePackages: {
|
||||
queue: analyzePackagesQueue,
|
||||
events: analyzePackagesQueueEvents,
|
||||
calculateProgress: (progress: AnalyzePackagesProgress) => {
|
||||
return (progress.analyzedPackages !== undefined && progress.totalPackages)
|
||||
? Math.round(((progress.analyzedPackages + (progress.failedPackages ?? 0)) / progress.totalPackages) * 100)
|
||||
: 0
|
||||
}
|
||||
},
|
||||
analyzeFolders: {
|
||||
queue: analyzeFoldersQueue,
|
||||
events: analyzeFoldersQueueEvents,
|
||||
calculateProgress: (progress: AnalyzeFoldersProgress) => {
|
||||
return (progress.analyzedFolders !== undefined && progress.totalFolders)
|
||||
? Math.round(((progress.analyzedFolders + (progress.failedFolders ?? 0)) / progress.totalFolders) * 100)
|
||||
: 0
|
||||
}
|
||||
}
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 任务路由
|
||||
*/
|
||||
export const jobsRouter = createTRPCRouter({
|
||||
// 为每个队列创建订阅
|
||||
subscribeAnalyzeFilesProgress: createProgressSubscription(queueConfigs.analyzeFiles),
|
||||
subscribeAnalyzePackagesProgress: createProgressSubscription(queueConfigs.analyzePackages),
|
||||
subscribeAnalyzeFoldersProgress: createProgressSubscription(queueConfigs.analyzeFolders),
|
||||
})
|
||||
60
src/server/routers/selection.ts
Normal file
60
src/server/routers/selection.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "@/server/trpc";
|
||||
|
||||
export const selectionRouter = createTRPCRouter({
|
||||
// 记录用户选择
|
||||
logSelection: permissionRequiredProcedure('')
|
||||
.input(z.object({
|
||||
context: z.string(),
|
||||
optionId: z.string(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.db.selectionLog.create({
|
||||
data: {
|
||||
userId: ctx.session!.user.id,
|
||||
context: input.context,
|
||||
optionId: input.optionId,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// 获取选项优先级
|
||||
getOptionPriorities: permissionRequiredProcedure('')
|
||||
.input(z.object({
|
||||
context: z.string(),
|
||||
// 'personal' 代表当前用户, 'global' 代表所有用户
|
||||
scope: z.enum(['personal', 'global']),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const whereCondition = {
|
||||
context: input.context,
|
||||
// 如果是 personal,则只查询当前用户
|
||||
...(input.scope === 'personal' && { userId: ctx.session!.user.id }),
|
||||
};
|
||||
|
||||
const frequencies = await ctx.db.selectionLog.groupBy({
|
||||
by: ['optionId'],
|
||||
where: whereCondition,
|
||||
_count: {
|
||||
optionId: true,
|
||||
},
|
||||
orderBy: {
|
||||
_count: {
|
||||
optionId: 'desc',
|
||||
},
|
||||
},
|
||||
take: 20, // 限制返回最常用的20个,避免数据量过大
|
||||
});
|
||||
|
||||
// 返回一个易于查找的Map,key为optionId,value为评估的选项优先级(暂时用频率)
|
||||
const frequencyMap = new Map<string, number>();
|
||||
for (const item of frequencies) {
|
||||
frequencyMap.set(item.optionId, item._count.optionId);
|
||||
}
|
||||
return frequencyMap;
|
||||
}),
|
||||
});
|
||||
|
||||
export type SelectionRouter = typeof selectionRouter;
|
||||
255
src/server/routers/upload.ts
Normal file
255
src/server/routers/upload.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { z } from 'zod';
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc';
|
||||
import {
|
||||
generatePresignedPostPolicy,
|
||||
generatePresignedGetObject,
|
||||
type PresignedPostPolicyOptions,
|
||||
type PresignedGetObjectOptions
|
||||
} from '@/server/minio';
|
||||
import { TRPCError, inferProcedureInput, inferProcedureOutput } from '@trpc/server';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { Permissions } from '@/constants/permissions';
|
||||
|
||||
|
||||
interface UploadConfig {
|
||||
/** 业务类型(category) public/开头是可以公开GetObject的 */
|
||||
category: string;
|
||||
/** 最大文件大小(字节),默认 100MB */
|
||||
maxSize?: number;
|
||||
/** 允许的文件类型(MIME类型,支持精确匹配和通配符如 'image/*'),默认允许所有类型 */
|
||||
allowedContentType?: string;
|
||||
/** 过期时间(秒),默认 1 小时 */
|
||||
expirySeconds?: number;
|
||||
/** 所需权限,默认为空字符串(需要登录但无特定权限要求) */
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface SingleUploadConfig extends UploadConfig {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface BatchUploadConfig extends UploadConfig {}
|
||||
|
||||
interface DownloadConfig {
|
||||
/** 过期时间(秒),默认 1 小时 */
|
||||
expirySeconds?: number;
|
||||
/** 所需权限,默认为空字符串(需要登录但无特定权限要求) */
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机哈希字符串
|
||||
*/
|
||||
function generateHash(length: number = 7): string {
|
||||
return randomBytes(Math.ceil(length / 2))
|
||||
.toString('hex')
|
||||
.slice(0, length);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建单文件上传接口的工厂函数
|
||||
*
|
||||
* 生成的对象路径格式:${category}/${userId}/${hash}_${fileName}
|
||||
*
|
||||
* @param config 单文件上传配置
|
||||
* @returns tRPC mutation procedure
|
||||
*/
|
||||
export function createSingleUploadProcedure(config: SingleUploadConfig) {
|
||||
const {
|
||||
category,
|
||||
maxSize = 100 * 1024 * 1024, // 默认 100MB
|
||||
allowedContentType,
|
||||
expirySeconds = 3600,
|
||||
permission = '',
|
||||
} = config;
|
||||
|
||||
return permissionRequiredProcedure(permission)
|
||||
.input(
|
||||
z.object({
|
||||
fileName: z.string().min(1, '文件名不能为空'),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { fileName } = input;
|
||||
const userId = ctx.session!.user.id;
|
||||
const hash = generateHash();
|
||||
|
||||
// 单文件上传指定完整的文件路径
|
||||
const prefix = `${category}/${userId}`;
|
||||
const fullFileName = `${hash}_${fileName}`;
|
||||
|
||||
try {
|
||||
const policyOptions: PresignedPostPolicyOptions = {
|
||||
prefix,
|
||||
fileName: fullFileName,
|
||||
expirySeconds: expirySeconds,
|
||||
maxSize,
|
||||
allowedContentType,
|
||||
};
|
||||
|
||||
const result = await generatePresignedPostPolicy(policyOptions);
|
||||
|
||||
return {
|
||||
postURL: result.postURL,
|
||||
formData: result.formData,
|
||||
objectName: result.objectName,
|
||||
expiresIn: expirySeconds,
|
||||
maxSize,
|
||||
allowedContentType,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('生成单文件上传策略失败:', error);
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: '生成上传策略失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建批量上传接口的工厂函数
|
||||
*
|
||||
* 生成的对象路径格式:${category}/${userId}/${hash}/(前缀,客户端上传时需要在此前缀下)
|
||||
*
|
||||
* @param config 批量上传配置
|
||||
* @returns tRPC mutation procedure
|
||||
*/
|
||||
export function createBatchUploadProcedure(config: BatchUploadConfig) {
|
||||
const {
|
||||
category,
|
||||
maxSize = 100 * 1024 * 1024, // 默认 100MB
|
||||
allowedContentType,
|
||||
expirySeconds = 3600,
|
||||
permission = '',
|
||||
} = config;
|
||||
|
||||
return permissionRequiredProcedure(permission)
|
||||
.mutation(async ({ ctx }) => {
|
||||
const userId = ctx.session!.user.id;
|
||||
const hash = generateHash();
|
||||
|
||||
// 批量上传只校验前缀,不指定具体文件名
|
||||
const prefix = `${category}/${userId}/${hash}`;
|
||||
|
||||
try {
|
||||
const policyOptions: PresignedPostPolicyOptions = {
|
||||
prefix,
|
||||
expirySeconds: expirySeconds,
|
||||
maxSize,
|
||||
allowedContentType,
|
||||
};
|
||||
|
||||
const result = await generatePresignedPostPolicy(policyOptions);
|
||||
|
||||
return {
|
||||
postURL: result.postURL,
|
||||
formData: result.formData,
|
||||
prefix: result.objectName,
|
||||
expiresIn: expirySeconds,
|
||||
maxSize,
|
||||
allowedContentType,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('生成批量上传策略失败:', error);
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: '生成上传策略失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建下载接口的工厂函数
|
||||
*
|
||||
* 生成预签名的 GET URL,用于下载对象
|
||||
* 文件名默认使用对象的 x-amz-meta-original-filename 元信息
|
||||
*
|
||||
* @param config 下载配置
|
||||
* @returns tRPC mutation procedure
|
||||
*/
|
||||
export function createDownloadProcedure(config: DownloadConfig) {
|
||||
const {
|
||||
expirySeconds = 3600,
|
||||
permission = '',
|
||||
} = config;
|
||||
|
||||
return permissionRequiredProcedure(permission)
|
||||
.input(
|
||||
z.object({
|
||||
objectName: z.string().min(1, '对象名称不能为空'),
|
||||
fileName: z.string().optional(),
|
||||
contentType: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const { objectName, fileName, contentType } = input;
|
||||
|
||||
try {
|
||||
const options: PresignedGetObjectOptions = {
|
||||
objectName,
|
||||
expirySeconds,
|
||||
responseHeaders: {},
|
||||
};
|
||||
|
||||
// 如果指定了自定义文件名,覆盖默认行为
|
||||
if (fileName) {
|
||||
options.responseHeaders!['response-content-disposition'] =
|
||||
`attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
// 如果指定了自定义 Content-Type,覆盖默认行为
|
||||
if (contentType) {
|
||||
options.responseHeaders!['response-content-type'] = contentType;
|
||||
}
|
||||
|
||||
const result = await generatePresignedGetObject(options);
|
||||
|
||||
return {
|
||||
url: result.url,
|
||||
expiresIn: result.expiresIn,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('生成下载链接失败:', error);
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: '生成下载链接失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传路由
|
||||
* 提供文件上传相关的接口,使用工厂函数创建特定业务的上传接口
|
||||
*/
|
||||
export const uploadRouter = createTRPCRouter({
|
||||
// 供给照片批量上传接口
|
||||
supplyPhotos: createBatchUploadProcedure({
|
||||
category: 'transfer/supply',
|
||||
maxSize: 1 * 1024 * 1024, // 1MB
|
||||
allowedContentType: 'image/*',
|
||||
expirySeconds: 3600,
|
||||
permission: Permissions.TRANSFER_SUPPLY_CREATE,
|
||||
}),
|
||||
supplyPdfs: createBatchUploadProcedure({
|
||||
category: 'transfer/supply',
|
||||
maxSize: 1 * 1024 * 1024, // 1MB
|
||||
allowedContentType: 'application/pdf',
|
||||
expirySeconds: 3600,
|
||||
permission: Permissions.TRANSFER_SUPPLY_CREATE,
|
||||
}),
|
||||
// 已上传的供给照片下载
|
||||
downloadSupplyPhotos: createDownloadProcedure({
|
||||
expirySeconds: 3600,
|
||||
permission: Permissions.TRANSFER_SUPPLY_CREATE,
|
||||
})
|
||||
});
|
||||
|
||||
export type SingleUploadProcedureInput = inferProcedureInput<ReturnType<typeof createSingleUploadProcedure>>; // createSingleUploadProcedure 创建的接口调用参数
|
||||
export type SingleUploadProcedureOutput = inferProcedureOutput<ReturnType<typeof createSingleUploadProcedure>>; // createSingleUploadProcedure 创建的接口返回值
|
||||
export type BatchUploadProcedureInput = inferProcedureInput<ReturnType<typeof createBatchUploadProcedure>>; // createBatchUploadProcedure 创建的接口调用参数
|
||||
export type BatchUploadProcedureOutput = inferProcedureOutput<ReturnType<typeof createBatchUploadProcedure>>; // createBatchUploadProcedure 创建的接口返回值
|
||||
export type DownloadProcedureInput = inferProcedureInput<ReturnType<typeof createDownloadProcedure>>; // createDownloadProcedure 创建的接口调用参数
|
||||
export type DownloadProcedureOutput = inferProcedureOutput<ReturnType<typeof createDownloadProcedure>>; // createDownloadProcedure 创建的接口返回值
|
||||
380
src/server/routers/users.ts
Normal file
380
src/server/routers/users.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'
|
||||
import { Permissions } from '@/constants/permissions'
|
||||
import { createUserSchema, updateUserSchema, changePasswordSchema } from '@/lib/schema/user'
|
||||
import { dataTableQueryParamsSchema } from '@/lib/schema/data-table'
|
||||
import { transformDataTableQueryParams } from '@/server/utils/data-table-helper'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import { inferProcedureOutput, TRPCError } from '@trpc/server'
|
||||
|
||||
export const usersRouter = createTRPCRouter({
|
||||
list: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
.input(dataTableQueryParamsSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { where, orderBy, skip, take } = transformDataTableQueryParams(input, {
|
||||
model: 'User',
|
||||
columns: {
|
||||
id: { field: 'id', variant: 'text', sortable: true },
|
||||
name: { field: 'name', variant: 'text', sortable: true },
|
||||
status: { field: 'status', variant: 'select', sortable: true },
|
||||
dept: { field: 'deptCode', variant: 'multiSelect', sortable: true },
|
||||
roles: { field: 'roles.some.id', variant: 'select', transform: Number },
|
||||
permissions: { field: 'roles.some.permissions.some.id', variant: 'select', transform: Number },
|
||||
lastLoginAt: { field: 'lastLoginAt', sortable: true },
|
||||
createdAt: { field: 'createdAt', sortable: true }
|
||||
},
|
||||
})
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
ctx.db.user.findMany({
|
||||
where,
|
||||
orderBy: orderBy.some(item => 'id' in item) ? orderBy : [...orderBy, { id: 'asc' }],
|
||||
skip,
|
||||
take,
|
||||
include: { roles: { include: { permissions: true } }, dept: true },
|
||||
}),
|
||||
ctx.db.user.count({ where }),
|
||||
])
|
||||
|
||||
return { data, total }
|
||||
}),
|
||||
|
||||
getRoles: permissionRequiredProcedure(Permissions.USER_MANAGE).query(({ ctx }) =>
|
||||
ctx.db.role.findMany({ orderBy: { name: 'asc' } })
|
||||
),
|
||||
|
||||
getPermissions: permissionRequiredProcedure(Permissions.USER_MANAGE).query(({ ctx }) =>
|
||||
ctx.db.permission.findMany({ orderBy: { name: 'asc' } })
|
||||
),
|
||||
|
||||
// 角色管理相关API
|
||||
getRolesWithStats: permissionRequiredProcedure(Permissions.USER_MANAGE).query(async ({ ctx }) => {
|
||||
const roles = await ctx.db.role.findMany({
|
||||
include: {
|
||||
permissions: true,
|
||||
_count: {
|
||||
select: { users: true }
|
||||
}
|
||||
},
|
||||
orderBy: { id: 'asc' }
|
||||
})
|
||||
|
||||
return roles.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
userCount: role._count.users,
|
||||
permissions: role.permissions
|
||||
}))
|
||||
}),
|
||||
|
||||
createRole: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
.input(z.object({
|
||||
name: z.string().min(1, '角色名称不能为空'),
|
||||
permissionIds: z.array(z.number())
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { name, permissionIds } = input
|
||||
|
||||
// 检查角色名是否已存在
|
||||
const existingRole = await ctx.db.role.findUnique({ where: { name } })
|
||||
if (existingRole) throw new TRPCError({ code: 'BAD_REQUEST', message: '角色名称已存在' })
|
||||
|
||||
return ctx.db.role.create({
|
||||
data: {
|
||||
name,
|
||||
permissions: { connect: permissionIds.map(id => ({ id })) }
|
||||
},
|
||||
include: {
|
||||
permissions: true,
|
||||
_count: {
|
||||
select: { users: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
updateRole: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
name: z.string().min(1, '角色名称不能为空'),
|
||||
permissionIds: z.array(z.number())
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, name, permissionIds } = input
|
||||
|
||||
// 检查角色是否存在
|
||||
const existingRole = await ctx.db.role.findUnique({ where: { id } })
|
||||
if (!existingRole) throw new TRPCError({ code: 'NOT_FOUND', message: '角色不存在' })
|
||||
|
||||
// 检查角色名是否被其他角色占用
|
||||
const roleWithSameName = await ctx.db.role.findUnique({ where: { name } })
|
||||
if (roleWithSameName && roleWithSameName.id !== id) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '角色名称已被其他角色使用' })
|
||||
}
|
||||
|
||||
return ctx.db.role.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
permissions: { set: permissionIds.map(permissionId => ({ id: permissionId })) }
|
||||
},
|
||||
include: {
|
||||
permissions: true,
|
||||
_count: {
|
||||
select: { users: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
deleteRole: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id } = input
|
||||
|
||||
// 检查角色是否存在
|
||||
const existingRole = await ctx.db.role.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { users: true } } }
|
||||
})
|
||||
if (!existingRole) throw new TRPCError({ code: 'NOT_FOUND', message: '角色不存在' })
|
||||
|
||||
// 检查是否有用户使用该角色
|
||||
if (existingRole._count.users > 0) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '该角色还有用户在使用,无法删除' })
|
||||
}
|
||||
|
||||
return ctx.db.role.delete({
|
||||
where: { id },
|
||||
include: {
|
||||
permissions: true,
|
||||
_count: {
|
||||
select: { users: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
batchUpdateRole: permissionRequiredProcedure(Permissions.USER_MANAGE)
|
||||
.input(z.object({
|
||||
roleId: z.number(),
|
||||
deptCodes: z.array(z.string()).optional(),
|
||||
action: z.enum(['grant', 'revoke'])
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { roleId, deptCodes, action } = input
|
||||
|
||||
// 检查角色是否存在
|
||||
const role = await ctx.db.role.findUnique({ where: { id: roleId } })
|
||||
if (!role) throw new TRPCError({ code: 'NOT_FOUND', message: '角色不存在' })
|
||||
|
||||
// 构建查询条件
|
||||
const where: any = {}
|
||||
if (deptCodes && deptCodes.length > 0) {
|
||||
where.deptCode = { in: deptCodes }
|
||||
}
|
||||
|
||||
// 获取符合条件的用户
|
||||
const users = await ctx.db.user.findMany({ where, select: { id: true } })
|
||||
|
||||
// 分批处理,每批1000个用户
|
||||
const batchSize = 1000
|
||||
let processedCount = 0
|
||||
|
||||
for (let i = 0; i < users.length; i += batchSize) {
|
||||
const batch = users.slice(i, i + batchSize)
|
||||
|
||||
await Promise.all(
|
||||
batch.map(user =>
|
||||
ctx.db.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
roles: action === 'grant'
|
||||
? { connect: { id: roleId } }
|
||||
: { disconnect: { id: roleId } }
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
processedCount += batch.length
|
||||
}
|
||||
|
||||
return { count: processedCount }
|
||||
}),
|
||||
|
||||
create: permissionRequiredProcedure(Permissions.USER_MANAGE).input(createUserSchema).mutation(async ({ ctx, input }) => {
|
||||
const { id, name, status, deptCode, password, roleIds, isSuperAdmin } = input
|
||||
|
||||
const existingUser = await ctx.db.user.findUnique({ where: { id } })
|
||||
if (existingUser) throw new TRPCError({ code: 'BAD_REQUEST', message: '用户ID已存在' })
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 12)
|
||||
|
||||
return ctx.db.user.create({
|
||||
data: {
|
||||
id,
|
||||
name: name?.trim() || '',
|
||||
status: status?.trim() || null,
|
||||
deptCode: deptCode?.trim() || null,
|
||||
password: hashedPassword,
|
||||
isSuperAdmin,
|
||||
roles: { connect: roleIds.map(id => ({ id })) }
|
||||
},
|
||||
include: {
|
||||
roles: { include: { permissions: true } },
|
||||
dept: true
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
update: permissionRequiredProcedure(Permissions.USER_MANAGE).input(updateUserSchema).mutation(async ({ ctx, input }) => {
|
||||
const { id, name, status, deptCode, password, roleIds, isSuperAdmin } = input
|
||||
|
||||
// 检查用户是否存在
|
||||
const existingUser = await ctx.db.user.findUnique({ where: { id } })
|
||||
if (!existingUser) throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
|
||||
|
||||
// 准备更新数据
|
||||
const updateData: any = {
|
||||
name: name?.trim() || '',
|
||||
status: status?.trim() || null,
|
||||
deptCode: deptCode?.trim() || null,
|
||||
isSuperAdmin,
|
||||
roles: { set: roleIds.map(roleId => ({ id: roleId })) }
|
||||
}
|
||||
|
||||
// 如果提供了密码,则更新密码
|
||||
if (password && password.trim()) {
|
||||
updateData.password = await bcrypt.hash(password, 12)
|
||||
}
|
||||
|
||||
return ctx.db.user.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
include: {
|
||||
roles: { include: { permissions: true } },
|
||||
dept: true
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
getById: permissionRequiredProcedure(Permissions.USER_MANAGE).input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
|
||||
const user = await ctx.db.user.findUnique({
|
||||
where: { id: input.id },
|
||||
include: {
|
||||
roles: { include: { permissions: true } },
|
||||
dept: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
|
||||
return user
|
||||
}),
|
||||
|
||||
delete: permissionRequiredProcedure(Permissions.USER_MANAGE).input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
||||
const { id } = input
|
||||
|
||||
// 检查用户是否存在
|
||||
const existingUser = await ctx.db.user.findUnique({ where: { id } })
|
||||
if (!existingUser) throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
|
||||
|
||||
// 防止用户删除自己
|
||||
if (ctx.session?.user?.id === id) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '不能删除自己的账户' })
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
return ctx.db.user.delete({
|
||||
where: { id },
|
||||
include: {
|
||||
roles: { include: { permissions: true } },
|
||||
dept: true
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
changePassword: permissionRequiredProcedure('')
|
||||
.input(changePasswordSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { oldPassword, newPassword } = input
|
||||
|
||||
// 获取当前用户ID
|
||||
const userId = ctx.session?.user?.id
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: '未登录' })
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const user = await ctx.db.user.findUnique({ where: { id: userId } })
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
|
||||
}
|
||||
|
||||
// 验证旧密码
|
||||
const isValidPassword = await bcrypt.compare(oldPassword, user.password)
|
||||
if (!isValidPassword) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '旧密码错误' })
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 12)
|
||||
await ctx.db.user.update({
|
||||
where: { id: userId },
|
||||
data: { password: hashedPassword }
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
// 获取当前用户的完整资料
|
||||
getCurrentUserProfile: permissionRequiredProcedure('')
|
||||
.query(async ({ ctx }) => {
|
||||
// 获取当前用户ID
|
||||
const userId = ctx.session?.user?.id
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: '未登录' })
|
||||
}
|
||||
|
||||
// 获取用户完整信息
|
||||
const user = await ctx.db.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: {
|
||||
roles: { include: { permissions: true } },
|
||||
dept: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: '用户不存在' })
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
status: user.status,
|
||||
deptCode: user.deptCode,
|
||||
deptName: user.dept?.name || null,
|
||||
deptFullName: user.dept?.fullName || null,
|
||||
isSuperAdmin: user.isSuperAdmin,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt,
|
||||
roles: user.roles.map(role => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
})),
|
||||
permissions: Array.from(
|
||||
new Set(user.roles.flatMap(role =>
|
||||
role.permissions.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
}))
|
||||
))
|
||||
).sort((a, b) => a.id - b.id),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export type UsersRouter = typeof usersRouter;
|
||||
export type User = inferProcedureOutput<UsersRouter['list']>['data'][number];
|
||||
export type UserProfile = inferProcedureOutput<UsersRouter['getCurrentUserProfile']>;
|
||||
Reference in New Issue
Block a user