Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑

This commit is contained in:
2025-11-13 15:24:54 +08:00
commit 42be39b343
249 changed files with 38843 additions and 0 deletions

View 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

View 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' } })
),
})

View 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];

View 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']>;

View 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;

View 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

View 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
View 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),
})

View 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个避免数据量过大
});
// 返回一个易于查找的Mapkey为optionIdvalue为评估的选项优先级暂时用频率
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;

View 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
View 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']>;