注册支持用户名/手机号/邮箱 + 镜像站部署脚本 + AI 助手 Tool Calling 重构
- Prisma User 模型新增 username 字段(唯一索引) - 注册先查重复再创建,返回友好中文提示(非 500) - 登录支持用户名/手机号/邮箱三种方式 - 前端注册表单增加用户名输入框,预校验 2-20 位格式 - 新增 scripts/deploy.sh:一键构建并部署主站+镜像站+重启后端+重载 Nginx - 镜像站 www.yuzhiran.com.cn Nginx 配置与主站同步 - AI 助手架构升级:用户端/管理后台均采用完整 Tool Calling 架构 - 新增 UserAiAssistantService(18 工具)+ AiAssistantController - admin 助手新增 search + mark-all-notifications-read 工具 - 修复注册 500 错误:catch Prisma P2002 → BadRequestException - Baidu Analytics Script 注入 root layout
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { Controller, Post, Body, Req, UseGuards, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AIGatewayService } from '../ai/ai-gateway.service';
|
||||
import { UserAiAssistantService } from './ai-assistant.service';
|
||||
|
||||
@ApiTags('AI 助手')
|
||||
@Controller('ai-assistant')
|
||||
export class AiAssistantController {
|
||||
constructor(
|
||||
private aiGateway: AIGatewayService,
|
||||
private aiAssistant: UserAiAssistantService,
|
||||
) {}
|
||||
|
||||
@Post('chat')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
async chat(
|
||||
@Req() req: any,
|
||||
@Body() body: { messages: { role: string; content: string }[]; pageContext?: string },
|
||||
) {
|
||||
try {
|
||||
const { messages, pageContext } = body;
|
||||
const toolsDesc = this.aiAssistant.getToolsDescription();
|
||||
|
||||
const systemContent = `你是宇之然 AI 学习与实践平台的智能助手,帮助用户了解和使用平台的所有功能。
|
||||
你可以回答用户的问题,也可以调用工具来执行操作(如搜索内容、查看学习分析、管理通知等)。
|
||||
|
||||
${pageContext ? `当前页面:${pageContext}\n\n` : ''}可用工具列表(需要执行操作时,返回 JSON:{"tool":"工具名","params":{...},"description":"简述"}):
|
||||
|
||||
${toolsDesc}
|
||||
|
||||
注意:
|
||||
1. 当用户请求执行具体操作(如搜索、查看数据、管理通知)时,返回一个 JSON 工具调用。
|
||||
2. 普通对话问题直接文字回答,不需要返回 JSON。
|
||||
3. 每次只需要返回一个 JSON 工具调用,不要包含多余文字。
|
||||
4. 对于 navigate 工具,前端会自动跳转,你只需要返回 JSON 即可。`;
|
||||
|
||||
const apiMessages = [
|
||||
{ role: 'system' as const, content: systemContent },
|
||||
...messages.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content })),
|
||||
];
|
||||
|
||||
const reply = await this.aiGateway.chat('general', apiMessages, { temperature: 0.7, max_tokens: 2000 });
|
||||
return { reply };
|
||||
} catch (err: any) {
|
||||
throw new HttpException(err.message || 'AI 助手请求失败', HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('action')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ApiBearerAuth()
|
||||
async action(
|
||||
@Req() req: any,
|
||||
@Body() body: { tool: string; params: Record<string, any>; messages: { role: string; content: string }[] },
|
||||
) {
|
||||
const userId = req.user.userId;
|
||||
const result = await this.aiAssistant.execute({ tool: body.tool, params: body.params }, userId);
|
||||
|
||||
try {
|
||||
const apiMessages = [
|
||||
{ role: 'system' as const, content: '你是一个AI助手,下面是用户请求的执行结果,请用自然语言简洁总结给用户。' },
|
||||
...body.messages.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content })),
|
||||
{ role: 'assistant' as const, content: `【工具执行结果】\n工具: ${body.tool}\n参数: ${JSON.stringify(body.params)}\n结果: ${result.summary}\n数据: ${JSON.stringify(result.data)}` },
|
||||
];
|
||||
const reply = await this.aiGateway.chat('general', apiMessages, { temperature: 0.3, max_tokens: 1000 });
|
||||
return { reply, result };
|
||||
} catch {
|
||||
return { reply: result.summary, result };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AiAssistantController } from './ai-assistant.controller';
|
||||
import { UserAiAssistantService } from './ai-assistant.service';
|
||||
import { SkillsModule } from '../skills/skills.module';
|
||||
import { AIModule } from '../ai/ai.module';
|
||||
|
||||
@Module({
|
||||
imports: [SkillsModule, AIModule],
|
||||
controllers: [AiAssistantController],
|
||||
providers: [UserAiAssistantService],
|
||||
exports: [UserAiAssistantService],
|
||||
})
|
||||
export class AiAssistantModule {}
|
||||
@@ -0,0 +1,431 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { SkillsService } from '../skills/skills.service';
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
tool: string;
|
||||
params: Record<string, any>;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
success: boolean;
|
||||
data: any;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
const KNOWLEDGE_DOMAINS = [
|
||||
{ id: 'ai-basics', name: 'AI 基础知识', keywords: ['AI', '人工智能', '大模型', 'chatgpt', 'gpt', '大语言模型', 'llm', '深度学习', '神经网络', 'machine learning', '机器学习'] },
|
||||
{ id: 'prompt-engineering', name: '提示词工程', keywords: ['提示词', 'prompt', 'system prompt', 'role', 'few-shot', 'chain-of-thought', 'cot'] },
|
||||
{ id: 'programming', name: '编程开发', keywords: ['python', 'javascript', 'typescript', 'java', '代码', '函数', '算法', 'debug', 'bug', '编程', '开发', 'react', 'vue', 'node'] },
|
||||
{ id: 'writing', name: '写作创作', keywords: ['写作', '文章', '文案', '润色', '作文', '创作', '故事', '小说', '博客'] },
|
||||
{ id: 'english', name: '英语学习', keywords: ['英语', 'english', '翻译', '语法', 'grammar', 'vocabulary', '口语', '写作', '阅读'] },
|
||||
{ id: 'data-science', name: '数据分析', keywords: ['数据', '分析', '统计', '图表', '可视化', 'sql', 'excel', 'pandas', 'numpy', '数据分析'] },
|
||||
{ id: 'office', name: '办公效率', keywords: ['ppt', 'excel', 'word', '办公', '邮件', '报告', '文档', '会议', '总结'] },
|
||||
{ id: 'career', name: '职业发展', keywords: ['简历', '面试', '求职', '职业', '工作', '升职', '薪资'] },
|
||||
];
|
||||
|
||||
const LEARNING_PATH_STAGES = [
|
||||
{
|
||||
id: 'basics', title: '认识大模型',
|
||||
tasks: [
|
||||
{ label: '了解 AI 基本概念', keyword: '人工智能' },
|
||||
{ label: '认识大语言模型', keyword: '大模型' },
|
||||
{ label: '体验 AI 对话', keyword: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'prompt', title: '提示词工程',
|
||||
tasks: [
|
||||
{ label: '了解提示词基础', keyword: '提示词' },
|
||||
{ label: '练习提示词编写', keyword: '' },
|
||||
{ label: '保存优质提示词', keyword: '' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'advanced', title: '模型微调与高级应用',
|
||||
tasks: [
|
||||
{ label: '了解模型微调', keyword: '微调' },
|
||||
{ label: '了解 RAG', keyword: 'rag' },
|
||||
{ label: '了解 Function Calling', keyword: 'function calling' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'agent', title: 'Agent 开发',
|
||||
tasks: [
|
||||
{ label: '了解 AI Agent', keyword: 'agent' },
|
||||
{ label: '学习工具调用', keyword: 'tool' },
|
||||
{ label: '实践项目', keyword: '' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class UserAiAssistantService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private skillsService: SkillsService,
|
||||
) {}
|
||||
|
||||
private userTools(): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: 'navigate',
|
||||
description: '跳转到网站的某个页面',
|
||||
parameters: { path: { type: 'string', description: '页面路径,如 /sandbox、/skills、/my/member、/learning、/models、/prompts、/compare、/community、/courses、/my、/about' } },
|
||||
},
|
||||
{
|
||||
name: 'search',
|
||||
description: '搜索平台上的课程、提示词、工具、文章等内容',
|
||||
parameters: { q: { type: 'string', description: '搜索关键词(必填)' }, type: { type: 'string', enum: ['all', 'courses', 'prompts', 'tools', 'contents'], description: '搜索类型(可选,默认全部)' } },
|
||||
},
|
||||
{
|
||||
name: 'list-skills',
|
||||
description: '浏览技能列表,可按分类、难度、关键词过滤',
|
||||
parameters: { category: { type: 'string', description: '分类(可选,如 basic/technical/creative)' }, difficulty: { type: 'string', description: '难度(可选,如 beginner/intermediate/advanced)' }, search: { type: 'string', description: '搜索关键词(可选)' } },
|
||||
},
|
||||
{
|
||||
name: 'get-skill',
|
||||
description: '查看某个技能的详细信息',
|
||||
parameters: { id: { type: 'string', description: '技能ID(必填),如 coding、writing、english、data-analysis' } },
|
||||
},
|
||||
{
|
||||
name: 'get-skill-categories',
|
||||
description: '查看所有技能分类',
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
name: 'get-learning-analytics',
|
||||
description: '查看学情分析(知识领域掌握度),需要登录',
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
name: 'get-learning-path',
|
||||
description: '查看学习路径进度,需要登录',
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
name: 'list-notifications',
|
||||
description: '查看我的通知列表,需要登录',
|
||||
parameters: { page: { type: 'number', description: '页码(可选)' }, pageSize: { type: 'number', description: '每页数量(可选)' } },
|
||||
},
|
||||
{
|
||||
name: 'get-unread-count',
|
||||
description: '查看未读通知数量,需要登录',
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
name: 'mark-notification-read',
|
||||
description: '标记某条通知为已读,需要登录',
|
||||
parameters: { id: { type: 'number', description: '通知ID(必填)' } },
|
||||
},
|
||||
{
|
||||
name: 'mark-all-read',
|
||||
description: '将所有通知标记为已读,需要登录',
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
name: 'get-profile',
|
||||
description: '查看我的个人信息(昵称、手机号、会员状态等),需要登录',
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
name: 'list-models',
|
||||
description: '查看平台支持的AI模型列表',
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
name: 'list-prompts',
|
||||
description: '浏览提示词列表',
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
name: 'list-courses',
|
||||
description: '浏览课程列表',
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
name: 'list-user-orders',
|
||||
description: '查看我的订单记录,需要登录',
|
||||
parameters: {},
|
||||
},
|
||||
{
|
||||
name: 'get-current-subscription',
|
||||
description: '查看当前会员订阅信息,需要登录',
|
||||
parameters: {},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
getAvailableTools(): ToolDefinition[] {
|
||||
return this.userTools();
|
||||
}
|
||||
|
||||
getToolsDescription(): string {
|
||||
return this.getAvailableTools().map(t => {
|
||||
const params = Object.entries(t.parameters)
|
||||
.map(([k, v]: any) => ` - ${k}: ${v.type}${v.description ? ' (' + v.description + ')' : ''}${v.enum ? ' [' + v.enum.join('|') + ']' : ''}`)
|
||||
.join('\n');
|
||||
return `- **${t.name}**: ${t.description}\n${params}`;
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
async execute(call: ToolCall, userId?: number): Promise<ToolResult> {
|
||||
try {
|
||||
switch (call.tool) {
|
||||
case 'navigate':
|
||||
return { success: true, data: { path: call.params.path }, summary: `跳转到 ${call.params.path}` };
|
||||
|
||||
case 'search': {
|
||||
const { q, type } = call.params;
|
||||
if (!q) throw new Error('请输入搜索关键词');
|
||||
const results = await this.search(q, type || 'all');
|
||||
return { success: true, data: results, summary: `找到 ${results.total} 条结果:${results.results.slice(0, 5).map(r => r.title).join('、')}` };
|
||||
}
|
||||
|
||||
case 'list-skills': {
|
||||
const skills = await this.skillsService.findAll(call.params);
|
||||
return { success: true, data: skills, summary: `共 ${skills.total} 个技能:${skills.items.map(s => s.name).join('、')}` };
|
||||
}
|
||||
|
||||
case 'get-skill': {
|
||||
const skill = await this.skillsService.findById(call.params.id);
|
||||
if (!skill) throw new Error(`技能 ${call.params.id} 不存在`);
|
||||
return { success: true, data: skill, summary: `技能「${skill.name}」(${skill.category} - ${skill.difficulty}):${skill.description}` };
|
||||
}
|
||||
|
||||
case 'get-skill-categories': {
|
||||
const cats = await this.skillsService.getCategories();
|
||||
return { success: true, data: { categories: cats }, summary: `共 ${cats.length} 个分类:${cats.map(c => c.name).join('、')}` };
|
||||
}
|
||||
|
||||
case 'get-learning-analytics': {
|
||||
if (!userId) throw new Error('需要登录');
|
||||
const analytics = await this.getLearningAnalytics(userId);
|
||||
return { success: true, data: analytics, summary: `总对话 ${analytics.totalSessions} 次,${analytics.domains.filter(d => d.weak).length} 个薄弱领域,建议从 ${analytics.recommendations[0]?.title || '探索更多'} 开始` };
|
||||
}
|
||||
|
||||
case 'get-learning-path': {
|
||||
if (!userId) throw new Error('需要登录');
|
||||
const path = await this.getLearningPath(userId);
|
||||
return { success: true, data: path, summary: `学习路径共 ${path.length} 个阶段:${path.map(s => `${s.title}(${s.progress}%)`).join('、')}` };
|
||||
}
|
||||
|
||||
case 'list-notifications': {
|
||||
if (!userId) throw new Error('需要登录');
|
||||
const page = call.params.page || 1;
|
||||
const pageSize = call.params.pageSize || 20;
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.notification.findMany({
|
||||
where: { userId }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize,
|
||||
}),
|
||||
this.prisma.notification.count({ where: { userId } }),
|
||||
]);
|
||||
return { success: true, data: { items, total, page, pageSize }, summary: `共 ${total} 条通知` };
|
||||
}
|
||||
|
||||
case 'get-unread-count': {
|
||||
if (!userId) throw new Error('需要登录');
|
||||
const count = await this.prisma.notification.count({ where: { userId, isRead: false } });
|
||||
return { success: true, data: { count }, summary: `有 ${count} 条未读通知` };
|
||||
}
|
||||
|
||||
case 'mark-notification-read': {
|
||||
if (!userId) throw new Error('需要登录');
|
||||
await this.prisma.notification.updateMany({ where: { id: call.params.id, userId }, data: { isRead: true } });
|
||||
return { success: true, data: {}, summary: `通知 ${call.params.id} 已标记为已读` };
|
||||
}
|
||||
|
||||
case 'mark-all-read': {
|
||||
if (!userId) throw new Error('需要登录');
|
||||
await this.prisma.notification.updateMany({ where: { userId, isRead: false }, data: { isRead: true } });
|
||||
return { success: true, data: {}, summary: '所有通知已标记为已读' };
|
||||
}
|
||||
|
||||
case 'get-profile': {
|
||||
if (!userId) throw new Error('需要登录');
|
||||
const profile = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, phone: true, email: true, nickname: true, avatar: true, status: true, memberPlan: true, memberExpire: true, sandboxDaily: true, createdAt: true },
|
||||
});
|
||||
if (!profile) throw new Error('用户不存在');
|
||||
return { success: true, data: profile, summary: `用户 ${profile.nickname || profile.phone || profile.email}${profile.memberPlan !== 'FREE' ? ',会员: ' + profile.memberPlan : ',当前为免费用户'}${profile.memberExpire ? ',到期: ' + profile.memberExpire.toISOString().slice(0, 10) : ''}` };
|
||||
}
|
||||
|
||||
case 'list-models': {
|
||||
const models = await this.prisma.aiModel.findMany({ where: { status: 'ACTIVE' }, orderBy: { sortOrder: 'asc' } });
|
||||
if (models.length === 0) {
|
||||
return { success: true, data: { models: [] }, summary: '目前没有可用的AI模型,请稍后再试' };
|
||||
}
|
||||
return { success: true, data: { models }, summary: `共 ${models.length} 个模型:${models.map(m => m.name).join('、')}` };
|
||||
}
|
||||
|
||||
case 'list-prompts': {
|
||||
const items = await this.prisma.prompt.findMany({
|
||||
where: { deletedAt: null, status: 'PUBLISHED' },
|
||||
take: 50, orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, title: true, description: true, tags: true },
|
||||
});
|
||||
return { success: true, data: { items, total: items.length }, summary: `共 ${items.length} 个提示词:${items.map(p => p.title).join('、')}` };
|
||||
}
|
||||
|
||||
case 'list-courses': {
|
||||
const items = await this.prisma.course.findMany({
|
||||
where: { deletedAt: null, status: 'PUBLISHED' },
|
||||
take: 50, orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, title: true, description: true, price: true, isFree: true },
|
||||
});
|
||||
const freeCount = items.filter(c => c.isFree).length;
|
||||
return { success: true, data: { items, total: items.length }, summary: `共 ${items.length} 个课程(${freeCount} 个免费):${items.map(c => c.title).join('、')}` };
|
||||
}
|
||||
|
||||
case 'list-user-orders': {
|
||||
if (!userId) throw new Error('需要登录');
|
||||
const items = await this.prisma.order.findMany({
|
||||
where: { userId },
|
||||
take: 50, orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const paidAmount = items.filter(o => o.status === 'PAID').reduce((s, o) => s + o.amount, 0);
|
||||
return { success: true, data: { items, total: items.length, paidAmount }, summary: `共 ${items.length} 笔订单,已支付总额 ¥${paidAmount}` };
|
||||
}
|
||||
|
||||
case 'get-current-subscription': {
|
||||
if (!userId) throw new Error('需要登录');
|
||||
const now = new Date();
|
||||
const sub = await this.prisma.subscription.findFirst({
|
||||
where: { userId, status: 'ACTIVE', endDate: { gt: now } },
|
||||
orderBy: { endDate: 'desc' },
|
||||
});
|
||||
if (!sub) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { memberPlan: true, memberExpire: true } });
|
||||
if (!user) throw new Error('用户不存在');
|
||||
return { success: true, data: null, summary: user.memberPlan !== 'FREE' ? `会员状态: ${user.memberPlan},到期 ${user.memberExpire?.toISOString().slice(0, 10) || '未知'}` : '当前为免费用户,暂无有效订阅' };
|
||||
}
|
||||
return { success: true, data: sub, summary: `当前订阅: ${sub.plan},到期 ${sub.endDate.toISOString().slice(0, 10)},剩余 ${Math.ceil((sub.endDate.getTime() - Date.now()) / 86400000)} 天` };
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`未知工具: ${call.tool}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
return { success: false, data: null, summary: `执行失败: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
private async getLearningAnalytics(userId: number) {
|
||||
const sessions = await this.prisma.sandboxSession.findMany({
|
||||
where: { userId }, orderBy: { createdAt: 'desc' }, take: 100,
|
||||
});
|
||||
|
||||
const domainCounts: Record<string, number> = {};
|
||||
const domainDates: Record<string, string> = {};
|
||||
const totalSessions = sessions.length;
|
||||
|
||||
for (const d of KNOWLEDGE_DOMAINS) domainCounts[d.id] = 0;
|
||||
|
||||
for (const session of sessions) {
|
||||
const searchText = `${session.title} ${session.messages || ''}`.toLowerCase();
|
||||
for (const domain of KNOWLEDGE_DOMAINS) {
|
||||
if (domain.keywords.some(kw => searchText.includes(kw))) {
|
||||
domainCounts[domain.id] = (domainCounts[domain.id] || 0) + 1;
|
||||
if (!domainDates[domain.id] || session.createdAt.toISOString() > domainDates[domain.id]) {
|
||||
domainDates[domain.id] = session.createdAt.toISOString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const domains = KNOWLEDGE_DOMAINS.map(d => {
|
||||
const count = domainCounts[d.id] || 0;
|
||||
const mastery = Math.min(Math.round((count / Math.max(totalSessions * 0.3, 1)) * 100), 100);
|
||||
return { id: d.id, name: d.name, sessionCount: count, mastery, lastActive: domainDates[d.id] || null, weak: mastery < 30 };
|
||||
});
|
||||
|
||||
const weakDomains = domains.filter(d => d.weak);
|
||||
const recommendations = weakDomains.length > 0
|
||||
? weakDomains.slice(0, 3).flatMap(d => [
|
||||
{ title: `了解 ${d.name}`, url: '/sandbox' },
|
||||
])
|
||||
: [{ title: '探索更多知识领域', url: '/sandbox' }];
|
||||
|
||||
return { domains, totalSessions, weakDomains: weakDomains.map(d => d.name), recommendations };
|
||||
}
|
||||
|
||||
private async getLearningPath(userId: number) {
|
||||
const sessions = await this.prisma.sandboxSession.findMany({
|
||||
where: { userId }, select: { title: true, messages: true },
|
||||
});
|
||||
const allText = sessions.map(s => `${s.title} ${s.messages || ''}`.toLowerCase()).join(' ');
|
||||
|
||||
return LEARNING_PATH_STAGES.map(stage => {
|
||||
const completedCount = stage.tasks.filter(t => !t.keyword || allText.includes(t.keyword)).length;
|
||||
const progress = stage.tasks.length > 0 ? Math.round((completedCount / stage.tasks.length) * 100) : 0;
|
||||
return { ...stage, completedCount, totalTasks: stage.tasks.length, progress, unlocked: true };
|
||||
});
|
||||
}
|
||||
|
||||
private async search(q: string, type: string) {
|
||||
const results: any[] = [];
|
||||
let total = 0;
|
||||
|
||||
if (type === 'all' || type === 'courses') {
|
||||
const [items, count] = await Promise.all([
|
||||
this.prisma.course.findMany({
|
||||
where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { description: { contains: q } }] },
|
||||
select: { id: true, title: true, description: true, isFree: true },
|
||||
take: type === 'courses' ? 20 : 5,
|
||||
}),
|
||||
this.prisma.course.count({ where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { description: { contains: q } }] } }),
|
||||
]);
|
||||
results.push(...items.map(i => ({ ...i, _type: 'course' })));
|
||||
total += count;
|
||||
}
|
||||
|
||||
if (type === 'all' || type === 'prompts') {
|
||||
const [items, count] = await Promise.all([
|
||||
this.prisma.prompt.findMany({
|
||||
where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { description: { contains: q } }, { content: { contains: q } }] },
|
||||
select: { id: true, title: true, description: true },
|
||||
take: type === 'prompts' ? 20 : 5,
|
||||
}),
|
||||
this.prisma.prompt.count({ where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { description: { contains: q } }, { content: { contains: q } }] } }),
|
||||
]);
|
||||
results.push(...items.map(i => ({ ...i, _type: 'prompt' })));
|
||||
total += count;
|
||||
}
|
||||
|
||||
if (type === 'all' || type === 'tools') {
|
||||
const [items, count] = await Promise.all([
|
||||
this.prisma.tool.findMany({
|
||||
where: { deletedAt: null, OR: [{ name: { contains: q } }, { description: { contains: q } }] },
|
||||
select: { id: true, name: true, description: true },
|
||||
take: type === 'tools' ? 20 : 5,
|
||||
}),
|
||||
this.prisma.tool.count({ where: { deletedAt: null, OR: [{ name: { contains: q } }, { description: { contains: q } }] } }),
|
||||
]);
|
||||
results.push(...items.map(i => ({ ...i, _type: 'tool' })));
|
||||
total += count;
|
||||
}
|
||||
|
||||
if (type === 'all' || type === 'contents') {
|
||||
const [items, count] = await Promise.all([
|
||||
this.prisma.content.findMany({
|
||||
where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { summary: { contains: q } }] },
|
||||
select: { id: true, title: true, summary: true },
|
||||
take: type === 'contents' ? 20 : 5,
|
||||
}),
|
||||
this.prisma.content.count({ where: { deletedAt: null, status: 'PUBLISHED', OR: [{ title: { contains: q } }, { summary: { contains: q } }] } }),
|
||||
]);
|
||||
results.push(...items.map(i => ({ ...i, _type: 'content' })));
|
||||
total += count;
|
||||
}
|
||||
|
||||
return { results, total };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user