# GEO 分析系统 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 构建一个内部 GEO 分析工具,支持多公司管理、关键字/竞品配置、大模型 API 管理、分析任务调度和结果对比看板。 **Architecture:** NestJS 后端(REST API + Bull 队列 Worker)+ React 前端(Ant Design + Recharts)+ PostgreSQL + Redis。分析引擎以关键字×模型为维度并发调用 AI API,解析回答提取品牌提及率/情感/竞品出现次数,结果持久化后通过看板聚合展示。 **Tech Stack:** NestJS 10, TypeORM, PostgreSQL 15, Redis 7, Bull, React 18, Vite, Ant Design 5, Recharts, Docker Compose --- ## File Map ### Backend ``` backend/ ├── src/ │ ├── main.ts │ ├── app.module.ts │ ├── common/ │ │ └── crypto.util.ts # AES-256 加密/解密 │ ├── companies/ │ │ ├── entities/ │ │ │ ├── company.entity.ts │ │ │ ├── keyword.entity.ts │ │ │ └── competitor.entity.ts │ │ ├── dto/ │ │ │ ├── create-company.dto.ts │ │ │ ├── create-keyword.dto.ts │ │ │ └── create-competitor.dto.ts │ │ ├── companies.module.ts │ │ ├── companies.controller.ts │ │ ├── companies.service.ts │ │ ├── keywords.controller.ts │ │ ├── keywords.service.ts │ │ ├── competitors.controller.ts │ │ └── competitors.service.ts │ ├── ai-models/ │ │ ├── entities/ │ │ │ └── ai-model.entity.ts │ │ ├── dto/ │ │ │ └── create-ai-model.dto.ts │ │ ├── ai-models.module.ts │ │ ├── ai-models.controller.ts │ │ └── ai-models.service.ts │ ├── tasks/ │ │ ├── entities/ │ │ │ ├── analysis-task.entity.ts │ │ │ └── analysis-result.entity.ts │ │ ├── dto/ │ │ │ └── create-task.dto.ts │ │ ├── tasks.module.ts │ │ ├── tasks.controller.ts │ │ └── tasks.service.ts │ ├── analysis/ │ │ ├── adapters/ │ │ │ ├── base.adapter.ts │ │ │ ├── openai-compatible.adapter.ts # OpenAI/DeepSeek/Kimi/Qwen │ │ │ ├── anthropic.adapter.ts │ │ │ └── gemini.adapter.ts │ │ ├── analysis.module.ts │ │ ├── analysis.service.ts # Prompt 构造 + 回答解析 │ │ └── response-parser.ts # 品牌提及/情感/竞品提取 │ ├── queue/ │ │ ├── queue.module.ts │ │ └── analysis.processor.ts # Bull worker │ └── dashboard/ │ ├── dashboard.module.ts │ ├── dashboard.controller.ts │ └── dashboard.service.ts ├── test/ │ ├── companies.service.spec.ts │ ├── ai-models.service.spec.ts │ ├── response-parser.spec.ts │ └── dashboard.service.spec.ts ├── .env.example ├── package.json ├── tsconfig.json └── Dockerfile ``` ### Frontend ``` frontend/ ├── src/ │ ├── main.tsx │ ├── App.tsx # Router + Layout │ ├── types/index.ts # 共享 TS 类型 │ ├── api/ │ │ ├── client.ts # axios 实例 │ │ ├── companies.ts │ │ ├── ai-models.ts │ │ ├── tasks.ts │ │ └── dashboard.ts │ ├── components/ │ │ └── AppLayout.tsx # 侧边栏导航 │ └── pages/ │ ├── companies/ │ │ ├── CompanyListPage.tsx │ │ └── CompanyDetailPage.tsx # Tab: 关键字/竞品/任务 │ ├── ai-models/ │ │ └── AIModelsPage.tsx │ ├── tasks/ │ │ ├── TaskListPage.tsx │ │ └── TaskResultsPage.tsx │ └── dashboard/ │ └── DashboardPage.tsx ├── package.json ├── vite.config.ts ├── tsconfig.json └── Dockerfile ``` ### Root ``` docker-compose.yml .env.example nginx/default.conf ``` --- ## Task 1: 项目脚手架 + Docker Compose **Files:** - Create: `docker-compose.yml` - Create: `.env.example` - Create: `backend/package.json` - Create: `backend/tsconfig.json` - Create: `backend/src/main.ts` - Create: `backend/src/app.module.ts` - Create: `backend/Dockerfile` - Create: `frontend/package.json` - Create: `frontend/vite.config.ts` - Create: `frontend/tsconfig.json` - Create: `frontend/index.html` - Create: `frontend/src/main.tsx` - Create: `frontend/Dockerfile` - [ ] **Step 1: 创建 docker-compose.yml** ```yaml # docker-compose.yml version: '3.9' services: postgres: image: postgres:15-alpine environment: POSTGRES_DB: geo POSTGRES_USER: geo POSTGRES_PASSWORD: geo123 ports: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data redis: image: redis:7-alpine ports: - "6379:6379" backend: build: ./backend ports: - "3000:3000" environment: DATABASE_URL: postgresql://geo:geo123@postgres:5432/geo REDIS_URL: redis://redis:6379 CRYPTO_KEY: change-this-32-char-secret-key!! ANALYSIS_CONCURRENCY: 3 depends_on: - postgres - redis volumes: - ./backend:/app - /app/node_modules frontend: build: ./frontend ports: - "5173:5173" volumes: - ./frontend:/app - /app/node_modules environment: VITE_API_URL: http://localhost:3000 volumes: postgres_data: ``` - [ ] **Step 2: 创建 .env.example** ```bash # .env.example DATABASE_URL=postgresql://geo:geo123@localhost:5432/geo REDIS_URL=redis://localhost:6379 CRYPTO_KEY=change-this-32-char-secret-key!! ANALYSIS_CONCURRENCY=3 PORT=3000 ``` - [ ] **Step 3: 初始化 NestJS 后端** ```bash cd /d/geo2 mkdir -p backend/src/common backend/src/companies/entities backend/src/companies/dto \ backend/src/ai-models/entities backend/src/ai-models/dto \ backend/src/tasks/entities backend/src/tasks/dto \ backend/src/analysis/adapters backend/src/queue backend/src/dashboard \ backend/test ``` - [ ] **Step 4: 创建 backend/package.json** ```json { "name": "geo-backend", "version": "1.0.0", "scripts": { "start:dev": "nest start --watch", "build": "nest build", "test": "jest --passWithNoTests" }, "dependencies": { "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/typeorm": "^10.0.0", "@nestjs/config": "^3.0.0", "@nestjs/bull": "^10.0.0", "@nestjs/schedule": "^4.0.0", "typeorm": "^0.3.17", "pg": "^8.11.0", "bull": "^4.12.0", "ioredis": "^5.3.0", "class-validator": "^0.14.0", "class-transformer": "^0.5.1", "openai": "^4.20.0", "@anthropic-ai/sdk": "^0.20.0", "@google/generative-ai": "^0.7.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0" }, "devDependencies": { "@nestjs/cli": "^10.0.0", "@nestjs/testing": "^10.0.0", "@types/node": "^20.0.0", "@types/jest": "^29.0.0", "jest": "^29.0.0", "ts-jest": "^29.0.0", "typescript": "^5.0.0" }, "jest": { "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, "testEnvironment": "node" } } ``` - [ ] **Step 5: 创建 backend/tsconfig.json** ```json { "compilerOptions": { "module": "commonjs", "declaration": true, "removeComments": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "allowSyntheticDefaultImports": true, "target": "ES2021", "sourceMap": true, "outDir": "./dist", "baseUrl": "./", "strict": false, "skipLibCheck": true, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false } } ``` - [ ] **Step 6: 创建 backend/src/main.ts** ```typescript import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); app.enableCors(); await app.listen(process.env.PORT ?? 3000); console.log(`Backend running on port ${process.env.PORT ?? 3000}`); } bootstrap(); ``` - [ ] **Step 7: 创建 backend/src/app.module.ts (空骨架,后续各 Task 会补充)** ```typescript import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BullModule } from '@nestjs/bull'; import { ScheduleModule } from '@nestjs/schedule'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), TypeOrmModule.forRoot({ type: 'postgres', url: process.env.DATABASE_URL, autoLoadEntities: true, synchronize: true, // dev only }), BullModule.forRoot({ redis: process.env.REDIS_URL ?? 'redis://localhost:6379', }), ScheduleModule.forRoot(), ], }) export class AppModule {} ``` - [ ] **Step 8: 创建 backend/Dockerfile** ```dockerfile FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm install COPY . . CMD ["npm", "run", "start:dev"] ``` - [ ] **Step 9: 初始化前端项目** ```bash cd /d/geo2 mkdir -p frontend/src/api frontend/src/components frontend/src/types \ frontend/src/pages/companies frontend/src/pages/ai-models \ frontend/src/pages/tasks frontend/src/pages/dashboard ``` - [ ] **Step 10: 创建 frontend/package.json** ```json { "name": "geo-frontend", "version": "1.0.0", "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview" }, "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.20.0", "antd": "^5.12.0", "@ant-design/icons": "^5.2.0", "recharts": "^2.10.0", "axios": "^1.6.0", "dayjs": "^1.11.0" }, "devDependencies": { "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", "typescript": "^5.0.0", "vite": "^5.0.0" } } ``` - [ ] **Step 11: 创建 frontend/vite.config.ts** ```typescript import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: process.env.VITE_API_URL ?? 'http://localhost:3000', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, ''), }, }, }, }); ``` - [ ] **Step 12: 创建 frontend/tsconfig.json** ```json { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "strict": false }, "include": ["src"] } ``` - [ ] **Step 13: 创建 frontend/index.html** ```html GEO 分析系统
``` - [ ] **Step 14: 创建 frontend/Dockerfile** ```dockerfile FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm install COPY . . CMD ["npm", "run", "dev"] ``` - [ ] **Step 15: 安装依赖并验证** ```bash cd /d/geo2/backend && npm install cd /d/geo2/frontend && npm install ``` Expected: 无报错,node_modules 创建成功。 - [ ] **Step 16: Commit** ```bash cd /d/geo2 git init git add . git commit -m "feat: project scaffold - NestJS backend + React frontend + Docker Compose" ``` --- ## Task 2: 公共工具 + 数据库实体 **Files:** - Create: `backend/src/common/crypto.util.ts` - Create: `backend/src/companies/entities/company.entity.ts` - Create: `backend/src/companies/entities/keyword.entity.ts` - Create: `backend/src/companies/entities/competitor.entity.ts` - Create: `backend/src/ai-models/entities/ai-model.entity.ts` - Create: `backend/src/tasks/entities/analysis-task.entity.ts` - Create: `backend/src/tasks/entities/analysis-result.entity.ts` - Test: `backend/test/crypto.spec.ts` - [ ] **Step 1: 编写 crypto.util 失败测试** ```typescript // backend/test/crypto.spec.ts import { encrypt, decrypt } from '../src/common/crypto.util'; describe('crypto.util', () => { it('should encrypt and decrypt a string', () => { const original = 'sk-test-api-key-12345'; const encrypted = encrypt(original); expect(encrypted).not.toBe(original); const decrypted = decrypt(encrypted); expect(decrypted).toBe(original); }); it('should produce different ciphertext each time (IV randomization)', () => { const text = 'same-key'; expect(encrypt(text)).not.toBe(encrypt(text)); }); }); ``` - [ ] **Step 2: 运行测试,确认失败** ```bash cd /d/geo2/backend && npx jest test/crypto.spec.ts --no-coverage ``` Expected: FAIL — "Cannot find module '../src/common/crypto.util'" - [ ] **Step 3: 实现 crypto.util.ts** ```typescript // backend/src/common/crypto.util.ts import * as crypto from 'crypto'; const ALGORITHM = 'aes-256-cbc'; const KEY = Buffer.from( (process.env.CRYPTO_KEY ?? 'default-32-char-key-do-not-use!!').slice(0, 32), ); export function encrypt(text: string): string { const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv); const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]); return iv.toString('hex') + ':' + encrypted.toString('hex'); } export function decrypt(ciphertext: string): string { const [ivHex, encryptedHex] = ciphertext.split(':'); const iv = Buffer.from(ivHex, 'hex'); const encrypted = Buffer.from(encryptedHex, 'hex'); const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv); return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8'); } ``` - [ ] **Step 4: 运行测试,确认通过** ```bash cd /d/geo2/backend && npx jest test/crypto.spec.ts --no-coverage ``` Expected: PASS (2 tests) - [ ] **Step 5: 创建 company.entity.ts** ```typescript // backend/src/companies/entities/company.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany } from 'typeorm'; import { Keyword } from './keyword.entity'; import { Competitor } from './competitor.entity'; @Entity('companies') export class Company { @PrimaryGeneratedColumn('uuid') id: string; @Column() name: string; @Column({ nullable: true }) industry: string; @Column({ nullable: true, type: 'text' }) description: string; @CreateDateColumn() createdAt: Date; @OneToMany(() => Keyword, (k) => k.company, { cascade: true }) keywords: Keyword[]; @OneToMany(() => Competitor, (c) => c.company, { cascade: true }) competitors: Competitor[]; } ``` - [ ] **Step 6: 创建 keyword.entity.ts** ```typescript // backend/src/companies/entities/keyword.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; import { Company } from './company.entity'; @Entity('keywords') export class Keyword { @PrimaryGeneratedColumn('uuid') id: string; @Column() companyId: string; @Column() text: string; @Column({ nullable: true }) category: string; @CreateDateColumn() createdAt: Date; @ManyToOne(() => Company, (c) => c.keywords, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'companyId' }) company: Company; } ``` - [ ] **Step 7: 创建 competitor.entity.ts** ```typescript // backend/src/companies/entities/competitor.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; import { Company } from './company.entity'; @Entity('competitors') export class Competitor { @PrimaryGeneratedColumn('uuid') id: string; @Column() companyId: string; @Column() name: string; @Column({ type: 'simple-array', nullable: true }) aliases: string[]; @CreateDateColumn() createdAt: Date; @ManyToOne(() => Company, (c) => c.competitors, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'companyId' }) company: Company; } ``` - [ ] **Step 8: 创建 ai-model.entity.ts** ```typescript // backend/src/ai-models/entities/ai-model.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; export type ModelProvider = 'openai' | 'anthropic' | 'gemini' | 'deepseek' | 'kimi' | 'qwen' | 'ernie'; @Entity('ai_models') export class AiModel { @PrimaryGeneratedColumn('uuid') id: string; @Column() name: string; @Column() provider: string; @Column({ type: 'text' }) apiKeyEncrypted: string; @Column({ nullable: true }) baseUrl: string; @Column() modelId: string; @Column({ default: true }) isEnabled: boolean; @Column({ nullable: true, type: 'timestamp' }) lastTestedAt: Date; @CreateDateColumn() createdAt: Date; } ``` - [ ] **Step 9: 创建 analysis-task.entity.ts** ```typescript // backend/src/tasks/entities/analysis-task.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; import { Company } from '../../companies/entities/company.entity'; export type TaskStatus = 'pending' | 'running' | 'done' | 'failed'; @Entity('analysis_tasks') export class AnalysisTask { @PrimaryGeneratedColumn('uuid') id: string; @Column() companyId: string; @Column() name: string; @Column({ type: 'simple-array' }) keywordIds: string[]; @Column({ type: 'simple-array' }) competitorIds: string[]; @Column({ type: 'simple-array' }) modelIds: string[]; @Column({ nullable: true }) schedule: string; // cron expression or null @Column({ default: 'pending' }) status: TaskStatus; @Column({ nullable: true, type: 'timestamp' }) lastRunAt: Date; @CreateDateColumn() createdAt: Date; @ManyToOne(() => Company) @JoinColumn({ name: 'companyId' }) company: Company; } ``` - [ ] **Step 10: 创建 analysis-result.entity.ts** ```typescript // backend/src/tasks/entities/analysis-result.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; import { AnalysisTask } from './analysis-task.entity'; import { AiModel } from '../../ai-models/entities/ai-model.entity'; export type Sentiment = 'positive' | 'neutral' | 'negative'; @Entity('analysis_results') export class AnalysisResult { @PrimaryGeneratedColumn('uuid') id: string; @Column() taskId: string; @Column() modelId: string; @Column() keyword: string; @Column({ type: 'text' }) prompt: string; @Column({ type: 'text' }) rawResponse: string; @Column({ default: false }) brandMentioned: boolean; @Column({ default: 0 }) mentionCount: number; @Column({ default: 'neutral' }) sentiment: Sentiment; @Column({ type: 'jsonb', default: '[]' }) competitorMentions: Array<{ name: string; count: number }>; @Column({ type: 'simple-array', nullable: true }) sources: string[]; @Column({ nullable: true, type: 'text' }) errorMessage: string; @CreateDateColumn() createdAt: Date; @ManyToOne(() => AnalysisTask) @JoinColumn({ name: 'taskId' }) task: AnalysisTask; @ManyToOne(() => AiModel) @JoinColumn({ name: 'modelId' }) model: AiModel; } ``` - [ ] **Step 11: Commit** ```bash cd /d/geo2 git add backend/src/common backend/src/companies/entities backend/src/ai-models/entities backend/src/tasks/entities backend/test/crypto.spec.ts git commit -m "feat: crypto util + all TypeORM entities" ``` --- ## Task 3: 公司模块 (Companies CRUD) **Files:** - Create: `backend/src/companies/dto/create-company.dto.ts` - Create: `backend/src/companies/companies.service.ts` - Create: `backend/src/companies/companies.controller.ts` - Create: `backend/src/companies/companies.module.ts` - Modify: `backend/src/app.module.ts` - Test: `backend/test/companies.service.spec.ts` - [ ] **Step 1: 编写 CompaniesService 失败测试** ```typescript // backend/test/companies.service.spec.ts import { Test } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { CompaniesService } from '../src/companies/companies.service'; import { Company } from '../src/companies/entities/company.entity'; const mockRepo = { find: jest.fn(), findOne: jest.fn(), create: jest.fn(), save: jest.fn(), update: jest.fn(), delete: jest.fn(), }; describe('CompaniesService', () => { let service: CompaniesService; beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ CompaniesService, { provide: getRepositoryToken(Company), useValue: mockRepo }, ], }).compile(); service = module.get(CompaniesService); jest.clearAllMocks(); }); it('should return all companies', async () => { const companies = [{ id: '1', name: '蓝卓' }]; mockRepo.find.mockResolvedValue(companies); expect(await service.findAll()).toEqual(companies); expect(mockRepo.find).toHaveBeenCalledWith({ relations: ['keywords', 'competitors'] }); }); it('should create a company', async () => { const dto = { name: '测试公司', industry: 'IT' }; mockRepo.create.mockReturnValue(dto); mockRepo.save.mockResolvedValue({ id: 'uuid', ...dto }); const result = await service.create(dto as any); expect(result.name).toBe('测试公司'); }); }); ``` - [ ] **Step 2: 运行测试,确认失败** ```bash cd /d/geo2/backend && npx jest test/companies.service.spec.ts --no-coverage ``` Expected: FAIL — "Cannot find module '../src/companies/companies.service'" - [ ] **Step 3: 创建 create-company.dto.ts** ```typescript // backend/src/companies/dto/create-company.dto.ts import { IsString, IsOptional } from 'class-validator'; export class CreateCompanyDto { @IsString() name: string; @IsOptional() @IsString() industry?: string; @IsOptional() @IsString() description?: string; } ``` - [ ] **Step 4: 创建 companies.service.ts** ```typescript // backend/src/companies/companies.service.ts import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Company } from './entities/company.entity'; import { CreateCompanyDto } from './dto/create-company.dto'; @Injectable() export class CompaniesService { constructor( @InjectRepository(Company) private readonly repo: Repository, ) {} findAll(): Promise { return this.repo.find({ relations: ['keywords', 'competitors'] }); } async findOne(id: string): Promise { const company = await this.repo.findOne({ where: { id }, relations: ['keywords', 'competitors'], }); if (!company) throw new NotFoundException(`Company ${id} not found`); return company; } create(dto: CreateCompanyDto): Promise { return this.repo.save(this.repo.create(dto)); } async update(id: string, dto: Partial): Promise { await this.findOne(id); // throws if not found await this.repo.update(id, dto); return this.findOne(id); } async remove(id: string): Promise { await this.findOne(id); await this.repo.delete(id); } } ``` - [ ] **Step 5: 创建 companies.controller.ts** ```typescript // backend/src/companies/companies.controller.ts import { Controller, Get, Post, Patch, Delete, Body, Param } from '@nestjs/common'; import { CompaniesService } from './companies.service'; import { CreateCompanyDto } from './dto/create-company.dto'; @Controller('companies') export class CompaniesController { constructor(private readonly service: CompaniesService) {} @Get() findAll() { return this.service.findAll(); } @Get(':id') findOne(@Param('id') id: string) { return this.service.findOne(id); } @Post() create(@Body() dto: CreateCompanyDto) { return this.service.create(dto); } @Patch(':id') update(@Param('id') id: string, @Body() dto: Partial) { return this.service.update(id, dto); } @Delete(':id') remove(@Param('id') id: string) { return this.service.remove(id); } } ``` - [ ] **Step 6: 创建 companies.module.ts** ```typescript // backend/src/companies/companies.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Company } from './entities/company.entity'; import { Keyword } from './entities/keyword.entity'; import { Competitor } from './entities/competitor.entity'; import { CompaniesController } from './companies.controller'; import { CompaniesService } from './companies.service'; import { KeywordsController } from './keywords.controller'; import { KeywordsService } from './keywords.service'; import { CompetitorsController } from './competitors.controller'; import { CompetitorsService } from './competitors.service'; @Module({ imports: [TypeOrmModule.forFeature([Company, Keyword, Competitor])], controllers: [CompaniesController, KeywordsController, CompetitorsController], providers: [CompaniesService, KeywordsService, CompetitorsService], exports: [CompaniesService, KeywordsService, CompetitorsService], }) export class CompaniesModule {} ``` - [ ] **Step 7: 运行测试,确认通过** ```bash cd /d/geo2/backend && npx jest test/companies.service.spec.ts --no-coverage ``` Expected: PASS (2 tests) - [ ] **Step 8: 创建关键字和竞品的 DTOs、Services、Controllers** ```typescript // backend/src/companies/dto/create-keyword.dto.ts import { IsString, IsOptional } from 'class-validator'; export class CreateKeywordDto { @IsString() text: string; @IsOptional() @IsString() category?: string; } // backend/src/companies/dto/create-competitor.dto.ts import { IsString, IsOptional, IsArray } from 'class-validator'; export class CreateCompetitorDto { @IsString() name: string; @IsOptional() @IsArray() aliases?: string[]; } ``` ```typescript // backend/src/companies/keywords.service.ts import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Keyword } from './entities/keyword.entity'; import { CreateKeywordDto } from './dto/create-keyword.dto'; @Injectable() export class KeywordsService { constructor(@InjectRepository(Keyword) private readonly repo: Repository) {} findByCompany(companyId: string): Promise { return this.repo.find({ where: { companyId } }); } create(companyId: string, dto: CreateKeywordDto): Promise { return this.repo.save(this.repo.create({ ...dto, companyId })); } async remove(companyId: string, id: string): Promise { const kw = await this.repo.findOne({ where: { id, companyId } }); if (!kw) throw new NotFoundException(`Keyword ${id} not found`); await this.repo.delete(id); } } ``` ```typescript // backend/src/companies/keywords.controller.ts import { Controller, Get, Post, Delete, Body, Param } from '@nestjs/common'; import { KeywordsService } from './keywords.service'; import { CreateKeywordDto } from './dto/create-keyword.dto'; @Controller('companies/:companyId/keywords') export class KeywordsController { constructor(private readonly service: KeywordsService) {} @Get() findAll(@Param('companyId') companyId: string) { return this.service.findByCompany(companyId); } @Post() create(@Param('companyId') companyId: string, @Body() dto: CreateKeywordDto) { return this.service.create(companyId, dto); } @Delete(':id') remove(@Param('companyId') companyId: string, @Param('id') id: string) { return this.service.remove(companyId, id); } } ``` ```typescript // backend/src/companies/competitors.service.ts import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Competitor } from './entities/competitor.entity'; import { CreateCompetitorDto } from './dto/create-competitor.dto'; @Injectable() export class CompetitorsService { constructor(@InjectRepository(Competitor) private readonly repo: Repository) {} findByCompany(companyId: string): Promise { return this.repo.find({ where: { companyId } }); } create(companyId: string, dto: CreateCompetitorDto): Promise { return this.repo.save(this.repo.create({ ...dto, companyId })); } async remove(companyId: string, id: string): Promise { const comp = await this.repo.findOne({ where: { id, companyId } }); if (!comp) throw new NotFoundException(`Competitor ${id} not found`); await this.repo.delete(id); } } ``` ```typescript // backend/src/companies/competitors.controller.ts import { Controller, Get, Post, Delete, Body, Param } from '@nestjs/common'; import { CompetitorsService } from './competitors.service'; import { CreateCompetitorDto } from './dto/create-competitor.dto'; @Controller('companies/:companyId/competitors') export class CompetitorsController { constructor(private readonly service: CompetitorsService) {} @Get() findAll(@Param('companyId') companyId: string) { return this.service.findByCompany(companyId); } @Post() create(@Param('companyId') companyId: string, @Body() dto: CreateCompetitorDto) { return this.service.create(companyId, dto); } @Delete(':id') remove(@Param('companyId') companyId: string, @Param('id') id: string) { return this.service.remove(companyId, id); } } ``` - [ ] **Step 9: 注册 CompaniesModule 到 AppModule** 在 `backend/src/app.module.ts` 的 imports 数组中添加: ```typescript import { CompaniesModule } from './companies/companies.module'; // 在 @Module imports 中加入: CompaniesModule, ``` - [ ] **Step 10: Commit** ```bash cd /d/geo2 git add backend/src/companies backend/test/companies.service.spec.ts git commit -m "feat: companies module with keywords and competitors CRUD" ``` --- ## Task 4: AI 模型管理模块 **Files:** - Create: `backend/src/ai-models/dto/create-ai-model.dto.ts` - Create: `backend/src/ai-models/ai-models.service.ts` - Create: `backend/src/ai-models/ai-models.controller.ts` - Create: `backend/src/ai-models/ai-models.module.ts` - Modify: `backend/src/app.module.ts` - Test: `backend/test/ai-models.service.spec.ts` - [ ] **Step 1: 编写 AiModelsService 失败测试** ```typescript // backend/test/ai-models.service.spec.ts import { Test } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { AiModelsService } from '../src/ai-models/ai-models.service'; import { AiModel } from '../src/ai-models/entities/ai-model.entity'; const mockRepo = { find: jest.fn(), findOne: jest.fn(), create: jest.fn(), save: jest.fn(), update: jest.fn(), delete: jest.fn(), }; describe('AiModelsService', () => { let service: AiModelsService; beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ AiModelsService, { provide: getRepositoryToken(AiModel), useValue: mockRepo }, ], }).compile(); service = module.get(AiModelsService); jest.clearAllMocks(); }); it('should mask apiKey when returning models', async () => { mockRepo.find.mockResolvedValue([ { id: '1', name: 'DeepSeek', apiKeyEncrypted: 'encrypted-value' }, ]); const result = await service.findAll(); expect(result[0]).not.toHaveProperty('apiKeyEncrypted'); expect(result[0]).toHaveProperty('apiKeyMasked'); }); it('should store encrypted apiKey on create', async () => { const dto = { name: 'DeepSeek V3', provider: 'deepseek', apiKey: 'sk-real-key', modelId: 'deepseek-chat', baseUrl: 'https://api.deepseek.com' }; mockRepo.create.mockImplementation((data) => data); mockRepo.save.mockImplementation((data) => Promise.resolve({ id: 'uuid', ...data })); await service.create(dto as any); const savedData = mockRepo.save.mock.calls[0][0]; expect(savedData.apiKeyEncrypted).not.toBe('sk-real-key'); expect(savedData).not.toHaveProperty('apiKey'); }); }); ``` - [ ] **Step 2: 运行测试,确认失败** ```bash cd /d/geo2/backend && npx jest test/ai-models.service.spec.ts --no-coverage ``` Expected: FAIL - [ ] **Step 3: 创建 create-ai-model.dto.ts** ```typescript // backend/src/ai-models/dto/create-ai-model.dto.ts import { IsString, IsOptional, IsBoolean } from 'class-validator'; export class CreateAiModelDto { @IsString() name: string; @IsString() provider: string; @IsString() apiKey: string; @IsOptional() @IsString() baseUrl?: string; @IsString() modelId: string; @IsOptional() @IsBoolean() isEnabled?: boolean; } ``` - [ ] **Step 4: 创建 ai-models.service.ts** ```typescript // backend/src/ai-models/ai-models.service.ts import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { AiModel } from './entities/ai-model.entity'; import { CreateAiModelDto } from './dto/create-ai-model.dto'; import { encrypt, decrypt } from '../common/crypto.util'; @Injectable() export class AiModelsService { constructor( @InjectRepository(AiModel) private readonly repo: Repository, ) {} async findAll(): Promise { const models = await this.repo.find(); return models.map((m) => this.maskKey(m)); } async findOne(id: string): Promise { const model = await this.repo.findOne({ where: { id } }); if (!model) throw new NotFoundException(`Model ${id} not found`); return model; } async getDecryptedKey(id: string): Promise { const model = await this.findOne(id); return decrypt(model.apiKeyEncrypted); } async create(dto: CreateAiModelDto): Promise { const { apiKey, ...rest } = dto; const entity = this.repo.create({ ...rest, apiKeyEncrypted: encrypt(apiKey) }); const saved = await this.repo.save(entity); return this.maskKey(saved); } async update(id: string, dto: Partial): Promise { await this.findOne(id); const { apiKey, ...rest } = dto as any; const update: any = { ...rest }; if (apiKey) update.apiKeyEncrypted = encrypt(apiKey); await this.repo.update(id, update); return this.maskKey(await this.findOne(id)); } async remove(id: string): Promise { await this.findOne(id); await this.repo.delete(id); } async testConnection(id: string): Promise<{ success: boolean; message: string }> { const model = await this.findOne(id); const apiKey = decrypt(model.apiKeyEncrypted); try { // 发送一个轻量请求验证 key 是否有效 const response = await fetch(model.baseUrl ?? this.getDefaultBaseUrl(model.provider) + '/models', { headers: { Authorization: `Bearer ${apiKey}` }, }); const success = response.ok || response.status === 200; await this.repo.update(id, { lastTestedAt: new Date() }); return { success, message: success ? 'Connection successful' : `HTTP ${response.status}` }; } catch (e) { return { success: false, message: e.message }; } } private getDefaultBaseUrl(provider: string): string { const map: Record = { openai: 'https://api.openai.com/v1', deepseek: 'https://api.deepseek.com/v1', kimi: 'https://api.moonshot.cn/v1', qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1', ernie: 'https://aip.baidubce.com/rpc/2.0/ai_custom/v1', anthropic: 'https://api.anthropic.com', gemini: 'https://generativelanguage.googleapis.com/v1beta', }; return map[provider] ?? 'https://api.openai.com/v1'; } private maskKey(model: AiModel): any { const { apiKeyEncrypted, ...rest } = model as any; const decrypted = decrypt(apiKeyEncrypted); const apiKeyMasked = '••••••••' + decrypted.slice(-4); return { ...rest, apiKeyMasked }; } } ``` - [ ] **Step 5: 创建 ai-models.controller.ts** ```typescript // backend/src/ai-models/ai-models.controller.ts import { Controller, Get, Post, Patch, Delete, Body, Param } from '@nestjs/common'; import { AiModelsService } from './ai-models.service'; import { CreateAiModelDto } from './dto/create-ai-model.dto'; @Controller('ai-models') export class AiModelsController { constructor(private readonly service: AiModelsService) {} @Get() findAll() { return this.service.findAll(); } @Post() create(@Body() dto: CreateAiModelDto) { return this.service.create(dto); } @Patch(':id') update(@Param('id') id: string, @Body() dto: Partial) { return this.service.update(id, dto); } @Delete(':id') remove(@Param('id') id: string) { return this.service.remove(id); } @Post(':id/test') test(@Param('id') id: string) { return this.service.testConnection(id); } } ``` - [ ] **Step 6: 创建 ai-models.module.ts** ```typescript // backend/src/ai-models/ai-models.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AiModel } from './entities/ai-model.entity'; import { AiModelsController } from './ai-models.controller'; import { AiModelsService } from './ai-models.service'; @Module({ imports: [TypeOrmModule.forFeature([AiModel])], controllers: [AiModelsController], providers: [AiModelsService], exports: [AiModelsService], }) export class AiModelsModule {} ``` - [ ] **Step 7: 注册 AiModelsModule 到 AppModule,运行测试** 在 `backend/src/app.module.ts` imports 中添加 `AiModelsModule`。 ```bash cd /d/geo2/backend && npx jest test/ai-models.service.spec.ts --no-coverage ``` Expected: PASS (2 tests) - [ ] **Step 8: Commit** ```bash cd /d/geo2 git add backend/src/ai-models backend/test/ai-models.service.spec.ts git commit -m "feat: ai-models module with AES-256 key encryption" ``` --- ## Task 5: 分析引擎 — Prompt 构造 + 回答解析 + AI 适配器 **Files:** - Create: `backend/src/analysis/adapters/base.adapter.ts` - Create: `backend/src/analysis/adapters/openai-compatible.adapter.ts` - Create: `backend/src/analysis/adapters/anthropic.adapter.ts` - Create: `backend/src/analysis/adapters/gemini.adapter.ts` - Create: `backend/src/analysis/response-parser.ts` - Create: `backend/src/analysis/analysis.service.ts` - Create: `backend/src/analysis/analysis.module.ts` - Test: `backend/test/response-parser.spec.ts` - [ ] **Step 1: 编写 response-parser 失败测试** ```typescript // backend/test/response-parser.spec.ts import { ResponseParser } from '../src/analysis/response-parser'; describe('ResponseParser', () => { const parser = new ResponseParser(); it('should detect brand mention (case-insensitive)', () => { const result = parser.parse('我推荐蓝卓数字和其他公司。', '蓝卓数字', ['竞品A']); expect(result.brandMentioned).toBe(true); expect(result.mentionCount).toBe(1); }); it('should detect competitor mentions', () => { const result = parser.parse('可以考虑蓝卓数字,也可以考虑竞品A和竞品A。', '蓝卓数字', ['竞品A', '竞品B']); expect(result.competitorMentions).toEqual( expect.arrayContaining([{ name: '竞品A', count: 2 }]) ); expect(result.competitorMentions.find(c => c.name === '竞品B').count).toBe(0); }); it('should classify positive sentiment', () => { const result = parser.parse('蓝卓数字是非常优秀的解决方案,强烈推荐!', '蓝卓数字', []); expect(result.sentiment).toBe('positive'); }); it('should classify negative sentiment', () => { const result = parser.parse('蓝卓数字存在严重问题,不建议使用。', '蓝卓数字', []); expect(result.sentiment).toBe('negative'); }); it('should extract URLs as sources', () => { const result = parser.parse('参考 https://example.com/page 获取更多信息。', '品牌', []); expect(result.sources).toContain('https://example.com/page'); }); }); ``` - [ ] **Step 2: 运行测试,确认失败** ```bash cd /d/geo2/backend && npx jest test/response-parser.spec.ts --no-coverage ``` Expected: FAIL - [ ] **Step 3: 创建 response-parser.ts** ```typescript // backend/src/analysis/response-parser.ts import { Sentiment } from '../tasks/entities/analysis-result.entity'; const POSITIVE_KEYWORDS = ['推荐', '优秀', '出色', '领先', '强烈推荐', '最佳', '专业', '可靠', '优质', 'excellent', 'recommend', 'best', 'great', 'leading']; const NEGATIVE_KEYWORDS = ['不建议', '避免', '问题', '差', '糟糕', '失败', '不推荐', '风险', 'avoid', 'poor', 'not recommended', 'issues', 'problems']; export interface ParseResult { brandMentioned: boolean; mentionCount: number; sentiment: Sentiment; competitorMentions: Array<{ name: string; count: number }>; sources: string[]; } export class ResponseParser { parse(response: string, brandName: string, competitorNames: string[]): ParseResult { const lower = response.toLowerCase(); const brandLower = brandName.toLowerCase(); // Count brand mentions const mentionCount = (lower.match(new RegExp(this.escape(brandLower), 'g')) ?? []).length; // Count competitor mentions const competitorMentions = competitorNames.map((name) => ({ name, count: (lower.match(new RegExp(this.escape(name.toLowerCase()), 'g')) ?? []).length, })); // Sentiment: check context around brand mention const positiveScore = POSITIVE_KEYWORDS.filter((k) => lower.includes(k)).length; const negativeScore = NEGATIVE_KEYWORDS.filter((k) => lower.includes(k)).length; let sentiment: Sentiment = 'neutral'; if (positiveScore > negativeScore) sentiment = 'positive'; else if (negativeScore > positiveScore) sentiment = 'negative'; // Extract URLs const urlRegex = /https?:\/\/[^\s\)]+/g; const sources = response.match(urlRegex) ?? []; return { brandMentioned: mentionCount > 0, mentionCount, sentiment, competitorMentions, sources, }; } private escape(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } } ``` - [ ] **Step 4: 运行测试,确认通过** ```bash cd /d/geo2/backend && npx jest test/response-parser.spec.ts --no-coverage ``` Expected: PASS (5 tests) - [ ] **Step 5: 创建 base.adapter.ts** ```typescript // backend/src/analysis/adapters/base.adapter.ts export interface ChatMessage { role: 'user' | 'assistant'; content: string; } export abstract class BaseAdapter { abstract chat(messages: ChatMessage[], apiKey: string): Promise; } ``` - [ ] **Step 6: 创建 openai-compatible.adapter.ts** 用于 OpenAI / DeepSeek / Kimi(月之暗面)/ 通义千问,它们均兼容 OpenAI Chat Completions API。 ```typescript // backend/src/analysis/adapters/openai-compatible.adapter.ts import { BaseAdapter, ChatMessage } from './base.adapter'; export class OpenAICompatibleAdapter extends BaseAdapter { constructor( private readonly baseUrl: string, private readonly modelId: string, ) { super(); } async chat(messages: ChatMessage[], apiKey: string): Promise { const url = `${this.baseUrl.replace(/\/$/, '')}/chat/completions`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ model: this.modelId, messages, max_tokens: 1024, temperature: 0.7, }), }); if (!response.ok) { const err = await response.text(); throw new Error(`OpenAI-compatible API error ${response.status}: ${err}`); } const data = await response.json(); return data.choices?.[0]?.message?.content ?? ''; } } ``` - [ ] **Step 7: 创建 anthropic.adapter.ts** ```typescript // backend/src/analysis/adapters/anthropic.adapter.ts import { BaseAdapter, ChatMessage } from './base.adapter'; export class AnthropicAdapter extends BaseAdapter { constructor(private readonly modelId: string) { super(); } async chat(messages: ChatMessage[], apiKey: string): Promise { const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01', }, body: JSON.stringify({ model: this.modelId, max_tokens: 1024, messages: messages.filter((m) => m.role === 'user'), }), }); if (!response.ok) { const err = await response.text(); throw new Error(`Anthropic API error ${response.status}: ${err}`); } const data = await response.json(); return data.content?.[0]?.text ?? ''; } } ``` - [ ] **Step 8: 创建 gemini.adapter.ts** ```typescript // backend/src/analysis/adapters/gemini.adapter.ts import { BaseAdapter, ChatMessage } from './base.adapter'; export class GeminiAdapter extends BaseAdapter { constructor(private readonly modelId: string) { super(); } async chat(messages: ChatMessage[], apiKey: string): Promise { const url = `https://generativelanguage.googleapis.com/v1beta/models/${this.modelId}:generateContent?key=${apiKey}`; const parts = messages.map((m) => ({ text: m.content })); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [{ parts }] }), }); if (!response.ok) { const err = await response.text(); throw new Error(`Gemini API error ${response.status}: ${err}`); } const data = await response.json(); return data.candidates?.[0]?.content?.parts?.[0]?.text ?? ''; } } ``` - [ ] **Step 9: 创建 analysis.service.ts** ```typescript // backend/src/analysis/analysis.service.ts import { Injectable } from '@nestjs/common'; import { AiModel } from '../ai-models/entities/ai-model.entity'; import { AiModelsService } from '../ai-models/ai-models.service'; import { ResponseParser, ParseResult } from './response-parser'; import { BaseAdapter } from './adapters/base.adapter'; import { OpenAICompatibleAdapter } from './adapters/openai-compatible.adapter'; import { AnthropicAdapter } from './adapters/anthropic.adapter'; import { GeminiAdapter } from './adapters/gemini.adapter'; const OPENAI_COMPATIBLE_PROVIDERS = ['openai', 'deepseek', 'kimi', 'qwen', 'ernie']; const DEFAULT_BASE_URLS: Record = { openai: 'https://api.openai.com/v1', deepseek: 'https://api.deepseek.com/v1', kimi: 'https://api.moonshot.cn/v1', qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1', }; export interface AnalysisUnit { keyword: string; companyName: string; competitors: Array<{ name: string; aliases: string[] }>; model: AiModel; apiKey: string; } export interface AnalysisUnitResult extends ParseResult { keyword: string; modelId: string; prompt: string; rawResponse: string; errorMessage?: string; } @Injectable() export class AnalysisService { private readonly parser = new ResponseParser(); constructor(private readonly aiModelsService: AiModelsService) {} buildPrompt(keyword: string, companyName: string): string { return `在"${keyword}"这个应用场景下,你会推荐哪些公司、品牌或解决方案?请列举你知道的主要选项,并简要说明各自的特点。`; } private buildAdapter(model: AiModel): BaseAdapter { if (model.provider === 'anthropic') return new AnthropicAdapter(model.modelId); if (model.provider === 'gemini') return new GeminiAdapter(model.modelId); // OpenAI-compatible const baseUrl = model.baseUrl ?? DEFAULT_BASE_URLS[model.provider] ?? 'https://api.openai.com/v1'; return new OpenAICompatibleAdapter(baseUrl, model.modelId); } async runUnit(unit: AnalysisUnit): Promise { const prompt = this.buildPrompt(unit.keyword, unit.companyName); const adapter = this.buildAdapter(unit.model); try { const rawResponse = await adapter.chat([{ role: 'user', content: prompt }], unit.apiKey); const allCompetitorNames = unit.competitors.flatMap((c) => [c.name, ...c.aliases]); const parsed = this.parser.parse(rawResponse, unit.companyName, allCompetitorNames); // Normalize competitor mentions: group by competitor (merge alias counts into parent) const normalizedMentions = unit.competitors.map((comp) => { const names = [comp.name, ...comp.aliases]; const count = parsed.competitorMentions .filter((m) => names.includes(m.name)) .reduce((sum, m) => sum + m.count, 0); return { name: comp.name, count }; }); return { keyword: unit.keyword, modelId: unit.model.id, prompt, rawResponse, ...parsed, competitorMentions: normalizedMentions, }; } catch (error) { return { keyword: unit.keyword, modelId: unit.model.id, prompt, rawResponse: '', brandMentioned: false, mentionCount: 0, sentiment: 'neutral', competitorMentions: unit.competitors.map((c) => ({ name: c.name, count: 0 })), sources: [], errorMessage: error.message, }; } } } ``` - [ ] **Step 10: 创建 analysis.module.ts** ```typescript // backend/src/analysis/analysis.module.ts import { Module } from '@nestjs/common'; import { AnalysisService } from './analysis.service'; import { AiModelsModule } from '../ai-models/ai-models.module'; @Module({ imports: [AiModelsModule], providers: [AnalysisService], exports: [AnalysisService], }) export class AnalysisModule {} ``` - [ ] **Step 11: Commit** ```bash cd /d/geo2 git add backend/src/analysis backend/test/response-parser.spec.ts git commit -m "feat: analysis engine - adapters + response parser + analysis service" ``` --- ## Task 6: 任务模块 + Bull 队列 Worker **Files:** - Create: `backend/src/tasks/dto/create-task.dto.ts` - Create: `backend/src/tasks/tasks.service.ts` - Create: `backend/src/tasks/tasks.controller.ts` - Create: `backend/src/tasks/tasks.module.ts` - Create: `backend/src/queue/queue.module.ts` - Create: `backend/src/queue/analysis.processor.ts` - Modify: `backend/src/app.module.ts` - [ ] **Step 1: 创建 create-task.dto.ts** ```typescript // backend/src/tasks/dto/create-task.dto.ts import { IsString, IsArray, IsOptional } from 'class-validator'; export class CreateTaskDto { @IsString() companyId: string; @IsString() name: string; @IsArray() keywordIds: string[]; @IsArray() competitorIds: string[]; @IsArray() modelIds: string[]; @IsOptional() @IsString() schedule?: string; // cron or null } ``` - [ ] **Step 2: 创建 tasks.service.ts** ```typescript // backend/src/tasks/tasks.service.ts import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { InjectQueue } from '@nestjs/bull'; import { Queue } from 'bull'; import { Repository } from 'typeorm'; import { AnalysisTask, TaskStatus } from './entities/analysis-task.entity'; import { AnalysisResult } from './entities/analysis-result.entity'; import { CreateTaskDto } from './dto/create-task.dto'; @Injectable() export class TasksService { constructor( @InjectRepository(AnalysisTask) private readonly taskRepo: Repository, @InjectRepository(AnalysisResult) private readonly resultRepo: Repository, @InjectQueue('analysis') private readonly analysisQueue: Queue, ) {} findAll(companyId?: string): Promise { const where = companyId ? { companyId } : {}; return this.taskRepo.find({ where, order: { createdAt: 'DESC' } }); } async findOne(id: string): Promise { const task = await this.taskRepo.findOne({ where: { id } }); if (!task) throw new NotFoundException(`Task ${id} not found`); return task; } async create(dto: CreateTaskDto): Promise { return this.taskRepo.save(this.taskRepo.create(dto)); } async updateSchedule(id: string, schedule: string | null): Promise { await this.findOne(id); await this.taskRepo.update(id, { schedule }); return this.findOne(id); } async run(id: string): Promise<{ jobId: string }> { const task = await this.findOne(id); await this.taskRepo.update(id, { status: 'running', lastRunAt: new Date() }); const job = await this.analysisQueue.add('run-task', { taskId: id }, { attempts: 3, backoff: { type: 'exponential', delay: 2000 }, }); return { jobId: String(job.id) }; } async updateStatus(id: string, status: TaskStatus): Promise { await this.taskRepo.update(id, { status }); } getResults(taskId: string): Promise { return this.resultRepo.find({ where: { taskId }, order: { createdAt: 'DESC' } }); } saveResult(result: Partial): Promise { return this.resultRepo.save(this.resultRepo.create(result)); } } ``` - [ ] **Step 3: 创建 analysis.processor.ts (Bull Worker)** ```typescript // backend/src/queue/analysis.processor.ts import { Process, Processor } from '@nestjs/bull'; import { Logger } from '@nestjs/common'; import { Job } from 'bull'; import { TasksService } from '../tasks/tasks.service'; import { CompaniesService } from '../companies/companies.service'; import { AiModelsService } from '../ai-models/ai-models.service'; import { AnalysisService } from '../analysis/analysis.service'; import { KeywordsService } from '../companies/keywords.service'; import { CompetitorsService } from '../companies/competitors.service'; @Processor('analysis') export class AnalysisProcessor { private readonly logger = new Logger(AnalysisProcessor.name); constructor( private readonly tasksService: TasksService, private readonly companiesService: CompaniesService, private readonly aiModelsService: AiModelsService, private readonly analysisService: AnalysisService, private readonly keywordsService: KeywordsService, private readonly competitorsService: CompetitorsService, ) {} @Process('run-task') async runTask(job: Job<{ taskId: string }>) { const { taskId } = job.data; this.logger.log(`Starting analysis task ${taskId}`); try { const task = await this.tasksService.findOne(taskId); const company = await this.companiesService.findOne(task.companyId); // Resolve keywords and competitors from IDs const allKeywords = await this.keywordsService.findByCompany(task.companyId); const allCompetitors = await this.competitorsService.findByCompany(task.companyId); const keywords = task.keywordIds.length ? allKeywords.filter((k) => task.keywordIds.includes(k.id)) : allKeywords; const competitors = task.competitorIds.length ? allCompetitors.filter((c) => task.competitorIds.includes(c.id)) : allCompetitors; // Resolve models const allModels = await this.aiModelsService.findAll(); // findAll returns masked models — we need full entities for API keys const concurrency = parseInt(process.env.ANALYSIS_CONCURRENCY ?? '3', 10); // Build units: keywords × models const units: Array<() => Promise> = []; for (const kw of keywords) { for (const modelId of task.modelIds) { units.push(async () => { const modelEntity = await this.aiModelsService.findOne(modelId); const apiKey = await this.aiModelsService.getDecryptedKey(modelId); const result = await this.analysisService.runUnit({ keyword: kw.text, companyName: company.name, competitors: competitors.map((c) => ({ name: c.name, aliases: c.aliases ?? [] })), model: modelEntity, apiKey, }); await this.tasksService.saveResult({ taskId, modelId, keyword: result.keyword, prompt: result.prompt, rawResponse: result.rawResponse, brandMentioned: result.brandMentioned, mentionCount: result.mentionCount, sentiment: result.sentiment, competitorMentions: result.competitorMentions, sources: result.sources, errorMessage: result.errorMessage, }); }); } } // Execute with concurrency limit for (let i = 0; i < units.length; i += concurrency) { await Promise.all(units.slice(i, i + concurrency).map((fn) => fn())); } await this.tasksService.updateStatus(taskId, 'done'); this.logger.log(`Task ${taskId} completed. ${units.length} units processed.`); } catch (error) { this.logger.error(`Task ${taskId} failed: ${error.message}`); await this.tasksService.updateStatus(taskId, 'failed'); throw error; } } } ``` - [ ] **Step 4: 创建 tasks.controller.ts** ```typescript // backend/src/tasks/tasks.controller.ts import { Controller, Get, Post, Patch, Body, Param, Query } from '@nestjs/common'; import { TasksService } from './tasks.service'; import { CreateTaskDto } from './dto/create-task.dto'; @Controller('tasks') export class TasksController { constructor(private readonly service: TasksService) {} @Get() findAll(@Query('companyId') companyId?: string) { return this.service.findAll(companyId); } @Post() create(@Body() dto: CreateTaskDto) { return this.service.create(dto); } @Post(':id/run') run(@Param('id') id: string) { return this.service.run(id); } @Patch(':id/schedule') updateSchedule(@Param('id') id: string, @Body('schedule') schedule: string | null) { return this.service.updateSchedule(id, schedule); } @Get(':id/results') getResults(@Param('id') id: string) { return this.service.getResults(id); } } ``` - [ ] **Step 5: 创建 queue.module.ts** ```typescript // backend/src/queue/queue.module.ts import { Module } from '@nestjs/common'; import { BullModule } from '@nestjs/bull'; import { AnalysisProcessor } from './analysis.processor'; import { TasksModule } from '../tasks/tasks.module'; import { CompaniesModule } from '../companies/companies.module'; import { AiModelsModule } from '../ai-models/ai-models.module'; import { AnalysisModule } from '../analysis/analysis.module'; @Module({ imports: [ BullModule.registerQueue({ name: 'analysis' }), TasksModule, CompaniesModule, AiModelsModule, AnalysisModule, ], providers: [AnalysisProcessor], }) export class QueueModule {} ``` - [ ] **Step 6: 创建 tasks.module.ts** ```typescript // backend/src/tasks/tasks.module.ts import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BullModule } from '@nestjs/bull'; import { AnalysisTask } from './entities/analysis-task.entity'; import { AnalysisResult } from './entities/analysis-result.entity'; import { TasksController } from './tasks.controller'; import { TasksService } from './tasks.service'; @Module({ imports: [ TypeOrmModule.forFeature([AnalysisTask, AnalysisResult]), BullModule.registerQueue({ name: 'analysis' }), ], controllers: [TasksController], providers: [TasksService], exports: [TasksService], }) export class TasksModule {} ``` - [ ] **Step 7: 更新 app.module.ts 注册所有模块** 完整的 `backend/src/app.module.ts`: ```typescript import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BullModule } from '@nestjs/bull'; import { ScheduleModule } from '@nestjs/schedule'; import { CompaniesModule } from './companies/companies.module'; import { AiModelsModule } from './ai-models/ai-models.module'; import { TasksModule } from './tasks/tasks.module'; import { QueueModule } from './queue/queue.module'; import { AnalysisModule } from './analysis/analysis.module'; import { DashboardModule } from './dashboard/dashboard.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), TypeOrmModule.forRoot({ type: 'postgres', url: process.env.DATABASE_URL, autoLoadEntities: true, synchronize: true, }), BullModule.forRoot({ redis: process.env.REDIS_URL ?? 'redis://localhost:6379', }), ScheduleModule.forRoot(), CompaniesModule, AiModelsModule, TasksModule, QueueModule, AnalysisModule, DashboardModule, ], }) export class AppModule {} ``` - [ ] **Step 8: Commit** ```bash cd /d/geo2 git add backend/src/tasks backend/src/queue backend/src/app.module.ts git commit -m "feat: tasks module + Bull queue worker for analysis execution" ``` --- ## Task 7: Cron 定时调度 **Files:** - Create: `backend/src/tasks/task-scheduler.service.ts` - Modify: `backend/src/tasks/tasks.module.ts` - [ ] **Step 1: 创建 task-scheduler.service.ts** ```typescript // backend/src/tasks/task-scheduler.service.ts import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { SchedulerRegistry } from '@nestjs/schedule'; import { CronJob } from 'cron'; import { AnalysisTask } from './entities/analysis-task.entity'; import { TasksService } from './tasks.service'; @Injectable() export class TaskSchedulerService implements OnModuleInit { private readonly logger = new Logger(TaskSchedulerService.name); constructor( @InjectRepository(AnalysisTask) private readonly taskRepo: Repository, private readonly tasksService: TasksService, private readonly schedulerRegistry: SchedulerRegistry, ) {} async onModuleInit() { // On startup, register all tasks that have a schedule const tasks = await this.taskRepo.find(); for (const task of tasks) { if (task.schedule) { this.registerCron(task.id, task.schedule); } } this.logger.log(`Registered ${tasks.filter((t) => t.schedule).length} cron jobs on startup`); } registerCron(taskId: string, cronExpression: string) { // Remove existing if any this.removeCron(taskId); const job = new CronJob(cronExpression, async () => { this.logger.log(`Cron triggered for task ${taskId}`); await this.tasksService.run(taskId); }); this.schedulerRegistry.addCronJob(`task-${taskId}`, job); job.start(); this.logger.log(`Cron registered for task ${taskId}: ${cronExpression}`); } removeCron(taskId: string) { try { this.schedulerRegistry.deleteCronJob(`task-${taskId}`); } catch (_) { // Job didn't exist, that's fine } } } ``` - [ ] **Step 2: 更新 tasks.module.ts 注册 TaskSchedulerService** ```typescript // backend/src/tasks/tasks.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BullModule } from '@nestjs/bull'; import { AnalysisTask } from './entities/analysis-task.entity'; import { AnalysisResult } from './entities/analysis-result.entity'; import { TasksController } from './tasks.controller'; import { TasksService } from './tasks.service'; import { TaskSchedulerService } from './task-scheduler.service'; @Module({ imports: [ TypeOrmModule.forFeature([AnalysisTask, AnalysisResult]), BullModule.registerQueue({ name: 'analysis' }), ], controllers: [TasksController], providers: [TasksService, TaskSchedulerService], exports: [TasksService, TaskSchedulerService], }) export class TasksModule {} ``` - [ ] **Step 3: 在 TasksController 中接入 scheduler** 在 `tasks.controller.ts` 的 `updateSchedule` 方法中加入 cron 注册逻辑: ```typescript // 在 tasks.controller.ts 中注入 TaskSchedulerService import { TaskSchedulerService } from './task-scheduler.service'; // constructor 中添加: constructor( private readonly service: TasksService, private readonly scheduler: TaskSchedulerService, ) {} // updateSchedule 方法改为: @Patch(':id/schedule') async updateSchedule(@Param('id') id: string, @Body('schedule') schedule: string | null) { const task = await this.service.updateSchedule(id, schedule); if (schedule) { this.scheduler.registerCron(id, schedule); } else { this.scheduler.removeCron(id); } return task; } ``` - [ ] **Step 4: Commit** ```bash cd /d/geo2 git add backend/src/tasks/task-scheduler.service.ts backend/src/tasks/tasks.module.ts backend/src/tasks/tasks.controller.ts git commit -m "feat: cron scheduler - auto-register task schedules on startup" ``` --- ## Task 8: 看板聚合 API (Dashboard) **Files:** - Create: `backend/src/dashboard/dashboard.module.ts` - Create: `backend/src/dashboard/dashboard.controller.ts` - Create: `backend/src/dashboard/dashboard.service.ts` - Test: `backend/test/dashboard.service.spec.ts` - [ ] **Step 1: 编写 DashboardService 失败测试** ```typescript // backend/test/dashboard.service.spec.ts import { Test } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { DashboardService } from '../src/dashboard/dashboard.service'; import { AnalysisResult } from '../src/tasks/entities/analysis-result.entity'; const mockResults = [ { keyword: 'kw1', modelId: 'm1', brandMentioned: true, mentionCount: 2, sentiment: 'positive', competitorMentions: [{ name: '竞品A', count: 1 }], createdAt: new Date('2026-03-01') }, { keyword: 'kw1', modelId: 'm2', brandMentioned: false, mentionCount: 0, sentiment: 'neutral', competitorMentions: [{ name: '竞品A', count: 3 }], createdAt: new Date('2026-03-01') }, ]; const mockRepo = { createQueryBuilder: jest.fn(), find: jest.fn().mockResolvedValue(mockResults), }; describe('DashboardService', () => { let service: DashboardService; beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ DashboardService, { provide: getRepositoryToken(AnalysisResult), useValue: mockRepo }, ], }).compile(); service = module.get(DashboardService); }); it('should compute competitor summary correctly', () => { const summary = service.computeCompetitorSummary(mockResults as any); const compA = summary.find((s) => s.name === '竞品A'); expect(compA.totalMentions).toBe(4); // 1 + 3 }); it('should compute model mention rates correctly', () => { const rates = service.computeModelMentionRates(mockResults as any); expect(rates.find((r) => r.modelId === 'm1').mentionRate).toBe(100); expect(rates.find((r) => r.modelId === 'm2').mentionRate).toBe(0); }); }); ``` - [ ] **Step 2: 运行测试,确认失败** ```bash cd /d/geo2/backend && npx jest test/dashboard.service.spec.ts --no-coverage ``` Expected: FAIL - [ ] **Step 3: 创建 dashboard.service.ts** ```typescript // backend/src/dashboard/dashboard.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Between } from 'typeorm'; import { AnalysisResult } from '../tasks/entities/analysis-result.entity'; export interface TrendPoint { date: string; brandMentionRate: number; competitorRates: Record; } export interface CompetitorSummary { name: string; totalMentions: number; mentionRate: number; } export interface ModelMentionRate { modelId: string; mentionRate: number; totalResults: number; } export interface SentimentSummary { modelId: string; positive: number; neutral: number; negative: number; } @Injectable() export class DashboardService { constructor( @InjectRepository(AnalysisResult) private readonly resultRepo: Repository, ) {} private async queryResults(companyId: string, keyword: string, from: Date, to: Date, modelIds?: string[]): Promise { // We query via task's companyId join const qb = this.resultRepo .createQueryBuilder('r') .innerJoin('r.task', 't') .where('t.companyId = :companyId', { companyId }) .andWhere('r.keyword = :keyword', { keyword }) .andWhere('r.createdAt BETWEEN :from AND :to', { from, to }); if (modelIds?.length) { qb.andWhere('r.modelId IN (:...modelIds)', { modelIds }); } return qb.getMany(); } async getTrend(companyId: string, keyword: string, from: Date, to: Date, modelIds?: string[]): Promise { const results = await this.queryResults(companyId, keyword, from, to, modelIds); // Group by date const byDate = new Map(); for (const r of results) { const date = r.createdAt.toISOString().slice(0, 10); if (!byDate.has(date)) byDate.set(date, []); byDate.get(date).push(r); } return Array.from(byDate.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([date, dayResults]) => { const brandMentionRate = dayResults.length ? Math.round((dayResults.filter((r) => r.brandMentioned).length / dayResults.length) * 100) : 0; // Aggregate competitor rates for the day const allCompetitors = new Set( dayResults.flatMap((r) => r.competitorMentions.map((c) => c.name)), ); const competitorRates: Record = {}; for (const name of allCompetitors) { const total = dayResults.reduce( (sum, r) => sum + (r.competitorMentions.find((c) => c.name === name)?.count ?? 0), 0, ); competitorRates[name] = total; } return { date, brandMentionRate, competitorRates }; }); } computeCompetitorSummary(results: AnalysisResult[]): CompetitorSummary[] { const map = new Map(); for (const r of results) { for (const cm of r.competitorMentions) { map.set(cm.name, (map.get(cm.name) ?? 0) + cm.count); } } const total = results.length; return Array.from(map.entries()) .map(([name, totalMentions]) => ({ name, totalMentions, mentionRate: total ? Math.round((totalMentions / total) * 100) : 0, })) .sort((a, b) => b.totalMentions - a.totalMentions); } computeModelMentionRates(results: AnalysisResult[]): ModelMentionRate[] { const byModel = new Map(); for (const r of results) { if (!byModel.has(r.modelId)) byModel.set(r.modelId, []); byModel.get(r.modelId).push(r); } return Array.from(byModel.entries()).map(([modelId, modelResults]) => ({ modelId, mentionRate: Math.round( (modelResults.filter((r) => r.brandMentioned).length / modelResults.length) * 100, ), totalResults: modelResults.length, })); } async getCompetitors(companyId: string, keyword: string, from: Date, to: Date): Promise { const results = await this.queryResults(companyId, keyword, from, to); // Add brand mention rate for comparison const brandTotal = results.filter((r) => r.brandMentioned).length; const brandRate = results.length ? Math.round((brandTotal / results.length) * 100) : 0; const competitorSummary = this.computeCompetitorSummary(results); return competitorSummary; } async getModels(companyId: string, keyword: string, from: Date, to: Date): Promise { const results = await this.queryResults(companyId, keyword, from, to); return this.computeModelMentionRates(results); } async getSentiment(companyId: string, keyword: string, from: Date, to: Date, modelIds?: string[]): Promise { const results = await this.queryResults(companyId, keyword, from, to, modelIds); const byModel = new Map(); for (const r of results) { if (!byModel.has(r.modelId)) byModel.set(r.modelId, []); byModel.get(r.modelId).push(r); } return Array.from(byModel.entries()).map(([modelId, modelResults]) => ({ modelId, positive: modelResults.filter((r) => r.sentiment === 'positive').length, neutral: modelResults.filter((r) => r.sentiment === 'neutral').length, negative: modelResults.filter((r) => r.sentiment === 'negative').length, })); } } ``` - [ ] **Step 4: 创建 dashboard.controller.ts** ```typescript // backend/src/dashboard/dashboard.controller.ts import { Controller, Get, Query } from '@nestjs/common'; import { DashboardService } from './dashboard.service'; @Controller('dashboard') export class DashboardController { constructor(private readonly service: DashboardService) {} private parseDates(from: string, to: string) { const now = new Date(); const fromDate = from ? new Date(from) : new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const toDate = to ? new Date(to) : now; return { fromDate, toDate }; } @Get('trend') trend( @Query('companyId') companyId: string, @Query('keyword') keyword: string, @Query('from') from: string, @Query('to') to: string, @Query('models') models: string, ) { const { fromDate, toDate } = this.parseDates(from, to); const modelIds = models ? models.split(',') : undefined; return this.service.getTrend(companyId, keyword, fromDate, toDate, modelIds); } @Get('competitors') competitors( @Query('companyId') companyId: string, @Query('keyword') keyword: string, @Query('from') from: string, @Query('to') to: string, ) { const { fromDate, toDate } = this.parseDates(from, to); return this.service.getCompetitors(companyId, keyword, fromDate, toDate); } @Get('models') models( @Query('companyId') companyId: string, @Query('keyword') keyword: string, @Query('from') from: string, @Query('to') to: string, ) { const { fromDate, toDate } = this.parseDates(from, to); return this.service.getModels(companyId, keyword, fromDate, toDate); } @Get('sentiment') sentiment( @Query('companyId') companyId: string, @Query('keyword') keyword: string, @Query('from') from: string, @Query('to') to: string, @Query('models') models: string, ) { const { fromDate, toDate } = this.parseDates(from, to); const modelIds = models ? models.split(',') : undefined; return this.service.getSentiment(companyId, keyword, fromDate, toDate, modelIds); } } ``` - [ ] **Step 5: 创建 dashboard.module.ts** ```typescript // backend/src/dashboard/dashboard.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AnalysisResult } from '../tasks/entities/analysis-result.entity'; import { DashboardController } from './dashboard.controller'; import { DashboardService } from './dashboard.service'; @Module({ imports: [TypeOrmModule.forFeature([AnalysisResult])], controllers: [DashboardController], providers: [DashboardService], }) export class DashboardModule {} ``` - [ ] **Step 6: 运行测试,确认通过** ```bash cd /d/geo2/backend && npx jest test/dashboard.service.spec.ts --no-coverage ``` Expected: PASS (2 tests) - [ ] **Step 7: 运行全部后端测试** ```bash cd /d/geo2/backend && npx jest --no-coverage ``` Expected: PASS (all tests) - [ ] **Step 8: Commit** ```bash cd /d/geo2 git add backend/src/dashboard backend/test/dashboard.service.spec.ts git commit -m "feat: dashboard aggregation API - trend, competitors, models, sentiment" ``` --- ## Task 9: 前端 — 基础框架 + 布局 **Files:** - Create: `frontend/src/main.tsx` - Create: `frontend/src/App.tsx` - Create: `frontend/src/types/index.ts` - Create: `frontend/src/api/client.ts` - Create: `frontend/src/components/AppLayout.tsx` - [ ] **Step 1: 创建 frontend/src/types/index.ts** ```typescript // frontend/src/types/index.ts export interface Company { id: string; name: string; industry?: string; description?: string; keywords: Keyword[]; competitors: Competitor[]; createdAt: string; } export interface Keyword { id: string; companyId: string; text: string; category?: string; createdAt: string; } export interface Competitor { id: string; companyId: string; name: string; aliases: string[]; createdAt: string; } export interface AiModel { id: string; name: string; provider: string; apiKeyMasked: string; baseUrl?: string; modelId: string; isEnabled: boolean; lastTestedAt?: string; createdAt: string; } export interface AnalysisTask { id: string; companyId: string; name: string; keywordIds: string[]; competitorIds: string[]; modelIds: string[]; schedule?: string; status: 'pending' | 'running' | 'done' | 'failed'; lastRunAt?: string; createdAt: string; } export interface AnalysisResult { id: string; taskId: string; modelId: string; keyword: string; prompt: string; rawResponse: string; brandMentioned: boolean; mentionCount: number; sentiment: 'positive' | 'neutral' | 'negative'; competitorMentions: Array<{ name: string; count: number }>; sources: string[]; errorMessage?: string; createdAt: string; } ``` - [ ] **Step 2: 创建 frontend/src/api/client.ts** ```typescript // frontend/src/api/client.ts import axios from 'axios'; const client = axios.create({ baseURL: '/api', timeout: 30000, }); export default client; ``` - [ ] **Step 3: 创建各 API 模块** ```typescript // frontend/src/api/companies.ts import client from './client'; import { Company, Keyword, Competitor } from '../types'; export const companiesApi = { list: () => client.get('/companies').then((r) => r.data), get: (id: string) => client.get(`/companies/${id}`).then((r) => r.data), create: (data: Partial) => client.post('/companies', data).then((r) => r.data), update: (id: string, data: Partial) => client.patch(`/companies/${id}`, data).then((r) => r.data), delete: (id: string) => client.delete(`/companies/${id}`), keywords: { list: (companyId: string) => client.get(`/companies/${companyId}/keywords`).then((r) => r.data), create: (companyId: string, data: { text: string; category?: string }) => client.post(`/companies/${companyId}/keywords`, data).then((r) => r.data), delete: (companyId: string, id: string) => client.delete(`/companies/${companyId}/keywords/${id}`), }, competitors: { list: (companyId: string) => client.get(`/companies/${companyId}/competitors`).then((r) => r.data), create: (companyId: string, data: { name: string; aliases?: string[] }) => client.post(`/companies/${companyId}/competitors`, data).then((r) => r.data), delete: (companyId: string, id: string) => client.delete(`/companies/${companyId}/competitors/${id}`), }, }; ``` ```typescript // frontend/src/api/ai-models.ts import client from './client'; import { AiModel } from '../types'; export const aiModelsApi = { list: () => client.get('/ai-models').then((r) => r.data), create: (data: Partial & { apiKey: string }) => client.post('/ai-models', data).then((r) => r.data), update: (id: string, data: Partial & { apiKey?: string }) => client.patch(`/ai-models/${id}`, data).then((r) => r.data), delete: (id: string) => client.delete(`/ai-models/${id}`), test: (id: string) => client.post<{ success: boolean; message: string }>(`/ai-models/${id}/test`).then((r) => r.data), }; ``` ```typescript // frontend/src/api/tasks.ts import client from './client'; import { AnalysisTask, AnalysisResult } from '../types'; export const tasksApi = { list: (companyId?: string) => client.get('/tasks', { params: { companyId } }).then((r) => r.data), create: (data: Partial) => client.post('/tasks', data).then((r) => r.data), run: (id: string) => client.post<{ jobId: string }>(`/tasks/${id}/run`).then((r) => r.data), updateSchedule: (id: string, schedule: string | null) => client.patch(`/tasks/${id}/schedule`, { schedule }).then((r) => r.data), results: (id: string) => client.get(`/tasks/${id}/results`).then((r) => r.data), }; ``` ```typescript // frontend/src/api/dashboard.ts import client from './client'; export const dashboardApi = { trend: (params: { companyId: string; keyword: string; from?: string; to?: string; models?: string }) => client.get('/dashboard/trend', { params }).then((r) => r.data), competitors: (params: { companyId: string; keyword: string; from?: string; to?: string }) => client.get('/dashboard/competitors', { params }).then((r) => r.data), models: (params: { companyId: string; keyword: string; from?: string; to?: string }) => client.get('/dashboard/models', { params }).then((r) => r.data), sentiment: (params: { companyId: string; keyword: string; from?: string; to?: string; models?: string }) => client.get('/dashboard/sentiment', { params }).then((r) => r.data), }; ``` - [ ] **Step 4: 创建 AppLayout.tsx** ```tsx // frontend/src/components/AppLayout.tsx import { Layout, Menu } from 'antd'; import { BankOutlined, RobotOutlined, OrderedListOutlined, BarChartOutlined } from '@ant-design/icons'; import { useNavigate, useLocation } from 'react-router-dom'; const { Sider, Content } = Layout; const menuItems = [ { key: '/companies', icon: , label: '公司管理' }, { key: '/ai-models', icon: , label: '模型管理' }, { key: '/tasks', icon: , label: '分析任务' }, { key: '/dashboard', icon: , label: '对比看板' }, ]; export default function AppLayout({ children }: { children: React.ReactNode }) { const navigate = useNavigate(); const location = useLocation(); const selectedKey = '/' + location.pathname.split('/')[1]; return (
GEO 分析系统
navigate(key)} /> {children} ); } ``` - [ ] **Step 5: 创建 main.tsx 和 App.tsx** ```tsx // frontend/src/main.tsx import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import App from './App'; import 'antd/dist/reset.css'; ReactDOM.createRoot(document.getElementById('root')!).render( ); ``` ```tsx // frontend/src/App.tsx import { Routes, Route, Navigate } from 'react-router-dom'; import AppLayout from './components/AppLayout'; import CompanyListPage from './pages/companies/CompanyListPage'; import CompanyDetailPage from './pages/companies/CompanyDetailPage'; import AIModelsPage from './pages/ai-models/AIModelsPage'; import TaskListPage from './pages/tasks/TaskListPage'; import TaskResultsPage from './pages/tasks/TaskResultsPage'; import DashboardPage from './pages/dashboard/DashboardPage'; export default function App() { return ( } /> } /> } /> } /> } /> } /> } /> ); } ``` - [ ] **Step 6: Commit** ```bash cd /d/geo2 git add frontend/src/main.tsx frontend/src/App.tsx frontend/src/types frontend/src/api frontend/src/components git commit -m "feat: frontend scaffold - types, API clients, layout, routing" ``` --- ## Task 10: 前端 — 公司管理页面 **Files:** - Create: `frontend/src/pages/companies/CompanyListPage.tsx` - Create: `frontend/src/pages/companies/CompanyDetailPage.tsx` - [ ] **Step 1: 创建 CompanyListPage.tsx** ```tsx // frontend/src/pages/companies/CompanyListPage.tsx import { useState, useEffect } from 'react'; import { Card, Button, Modal, Form, Input, Space, Popconfirm, message, Row, Col, Tag, Typography } from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; import { companiesApi } from '../../api/companies'; import { Company } from '../../types'; const { Title, Text } = Typography; export default function CompanyListPage() { const [companies, setCompanies] = useState([]); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [form] = Form.useForm(); const navigate = useNavigate(); const load = async () => { setLoading(true); try { setCompanies(await companiesApi.list()); } finally { setLoading(false); } }; useEffect(() => { load(); }, []); const openCreate = () => { setEditing(null); form.resetFields(); setModalOpen(true); }; const openEdit = (c: Company, e: React.MouseEvent) => { e.stopPropagation(); setEditing(c); form.setFieldsValue(c); setModalOpen(true); }; const handleSubmit = async (values: any) => { if (editing) { await companiesApi.update(editing.id, values); message.success('更新成功'); } else { await companiesApi.create(values); message.success('创建成功'); } setModalOpen(false); load(); }; const handleDelete = async (id: string) => { await companiesApi.delete(id); message.success('删除成功'); load(); }; return (
公司管理
{companies.map((c) => ( navigate(`/companies/${c.id}`)} actions={[ openEdit(c, e)} />, { e?.stopPropagation(); handleDelete(c.id); }} onPopupClick={(e) => e.stopPropagation()}> , ]} > {c.industry || '未设置行业'}} /> {c.keywords?.length ?? 0} 关键字 {c.competitors?.length ?? 0} 竞品 ))} setModalOpen(false)} onOk={() => form.submit()} destroyOnClose >
); } ``` - [ ] **Step 2: 创建 CompanyDetailPage.tsx** ```tsx // frontend/src/pages/companies/CompanyDetailPage.tsx import { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Tabs, Button, Table, Modal, Form, Input, Space, Popconfirm, message, Typography, Tag } from 'antd'; import { ArrowLeftOutlined, PlusOutlined } from '@ant-design/icons'; import { companiesApi } from '../../api/companies'; import { Company, Keyword, Competitor } from '../../types'; const { Title } = Typography; export default function CompanyDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [company, setCompany] = useState(null); const [keywords, setKeywords] = useState([]); const [competitors, setCompetitors] = useState([]); const [kwModal, setKwModal] = useState(false); const [compModal, setCompModal] = useState(false); const [kwForm] = Form.useForm(); const [compForm] = Form.useForm(); useEffect(() => { companiesApi.get(id!).then(setCompany); companiesApi.keywords.list(id!).then(setKeywords); companiesApi.competitors.list(id!).then(setCompetitors); }, [id]); const addKeyword = async (values: any) => { // Batch import support: split by newlines const texts: string[] = values.text.split('\n').map((t: string) => t.trim()).filter(Boolean); await Promise.all(texts.map((text) => companiesApi.keywords.create(id!, { text, category: values.category }))); message.success(`添加 ${texts.length} 个关键字`); setKwModal(false); kwForm.resetFields(); companiesApi.keywords.list(id!).then(setKeywords); }; const deleteKeyword = async (kwId: string) => { await companiesApi.keywords.delete(id!, kwId); companiesApi.keywords.list(id!).then(setKeywords); }; const addCompetitor = async (values: any) => { const aliases = values.aliases ? values.aliases.split('\n').map((a: string) => a.trim()).filter(Boolean) : []; await companiesApi.competitors.create(id!, { name: values.name, aliases }); message.success('添加竞品成功'); setCompModal(false); compForm.resetFields(); companiesApi.competitors.list(id!).then(setCompetitors); }; const deleteCompetitor = async (cId: string) => { await companiesApi.competitors.delete(id!, cId); companiesApi.competitors.list(id!).then(setCompetitors); }; const kwColumns = [ { title: '关键字', dataIndex: 'text', key: 'text' }, { title: '分类', dataIndex: 'category', key: 'category', render: (v: string) => v ? {v} : '-' }, { title: '操作', key: 'action', render: (_: any, r: Keyword) => ( deleteKeyword(r.id)}> )}, ]; const compColumns = [ { title: '竞品名称', dataIndex: 'name', key: 'name' }, { title: '别名', dataIndex: 'aliases', key: 'aliases', render: (v: string[]) => v?.map((a) => {a}) ?? '-' }, { title: '操作', key: 'action', render: (_: any, r: Competitor) => ( deleteCompetitor(r.id)}> )}, ]; return (
{company?.name} {company?.industry && {company.industry}} ), }, { key: 'competitors', label: `竞品 (${competitors.length})`, children: (
), }, { key: 'tasks', label: '相关任务', children: (
), }, ]} /> setKwModal(false)} onOk={() => kwForm.submit()} destroyOnClose>
setCompModal(false)} onOk={() => compForm.submit()} destroyOnClose>
); } ``` - [ ] **Step 3: Commit** ```bash cd /d/geo2 git add frontend/src/pages/companies git commit -m "feat: company list page + detail page with keywords/competitors tabs" ``` --- ## Task 11: 前端 — 模型管理 + 任务管理页面 **Files:** - Create: `frontend/src/pages/ai-models/AIModelsPage.tsx` - Create: `frontend/src/pages/tasks/TaskListPage.tsx` - Create: `frontend/src/pages/tasks/TaskResultsPage.tsx` - [ ] **Step 1: 创建 AIModelsPage.tsx** ```tsx // frontend/src/pages/ai-models/AIModelsPage.tsx import { useState, useEffect } from 'react'; import { Table, Button, Modal, Form, Input, Select, Switch, Space, Popconfirm, message, Tag, Typography } from 'antd'; import { PlusOutlined, ApiOutlined } from '@ant-design/icons'; import { aiModelsApi } from '../../api/ai-models'; import { AiModel } from '../../types'; const { Title } = Typography; const PROVIDERS = [ { value: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic (Claude)' }, { value: 'gemini', label: 'Google Gemini' }, { value: 'deepseek', label: 'DeepSeek' }, { value: 'kimi', label: 'Kimi (月之暗面)' }, { value: 'qwen', label: '通义千问 (阿里)' }, { value: 'ernie', label: '文心一言 (百度)' }, ]; const DEFAULT_BASE_URLS: Record = { openai: 'https://api.openai.com/v1', deepseek: 'https://api.deepseek.com/v1', kimi: 'https://api.moonshot.cn/v1', qwen: 'https://dashscope.aliyuncs.com/compatible-mode/v1', }; export default function AIModelsPage() { const [models, setModels] = useState([]); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [testingId, setTestingId] = useState(null); const [form] = Form.useForm(); const load = async () => { setLoading(true); try { setModels(await aiModelsApi.list()); } finally { setLoading(false); } }; useEffect(() => { load(); }, []); const openCreate = () => { setEditing(null); form.resetFields(); setModalOpen(true); }; const openEdit = (m: AiModel) => { setEditing(m); form.setFieldsValue({ ...m, apiKey: '' }); setModalOpen(true); }; const handleProviderChange = (provider: string) => { if (DEFAULT_BASE_URLS[provider]) { form.setFieldValue('baseUrl', DEFAULT_BASE_URLS[provider]); } }; const handleSubmit = async (values: any) => { if (editing) { const payload = { ...values }; if (!payload.apiKey) delete payload.apiKey; await aiModelsApi.update(editing.id, payload); message.success('更新成功'); } else { await aiModelsApi.create(values); message.success('创建成功'); } setModalOpen(false); load(); }; const handleTest = async (id: string) => { setTestingId(id); try { const result = await aiModelsApi.test(id); if (result.success) message.success('连接成功'); else message.error(`连接失败:${result.message}`); } finally { setTestingId(null); load(); } }; const columns = [ { title: '名称', dataIndex: 'name', key: 'name' }, { title: '提供商', dataIndex: 'provider', key: 'provider', render: (v: string) => {PROVIDERS.find((p) => p.value === v)?.label ?? v} }, { title: 'Model ID', dataIndex: 'modelId', key: 'modelId' }, { title: 'API Key', dataIndex: 'apiKeyMasked', key: 'key' }, { title: '状态', dataIndex: 'isEnabled', key: 'enabled', render: (v: boolean) => {v ? '启用' : '禁用'} }, { title: '最后测试', dataIndex: 'lastTestedAt', key: 'tested', render: (v: string) => v ? new Date(v).toLocaleString() : '-' }, { title: '操作', key: 'action', render: (_: any, r: AiModel) => ( aiModelsApi.delete(r.id).then(load)}> ), }, ]; return (
模型管理
setModalOpen(false)} onOk={() => form.submit()} destroyOnClose>
); } ``` - [ ] **Step 2: 创建 TaskListPage.tsx** ```tsx // frontend/src/pages/tasks/TaskListPage.tsx import { useState, useEffect } from 'react'; import { Table, Button, Modal, Form, Input, Select, Space, message, Tag, Typography, Steps, Divider } from 'antd'; import { PlusOutlined, PlayCircleOutlined } from '@ant-design/icons'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { tasksApi } from '../../api/tasks'; import { companiesApi } from '../../api/companies'; import { aiModelsApi } from '../../api/ai-models'; import { AnalysisTask, Company, Keyword, Competitor, AiModel } from '../../types'; const { Title } = Typography; const STATUS_COLORS: Record = { pending: 'default', running: 'processing', done: 'success', failed: 'error' }; const STATUS_LABELS: Record = { pending: '待运行', running: '运行中', done: '已完成', failed: '失败' }; const CRON_PRESETS = [ { label: '每天 00:00', value: '0 0 * * *' }, { label: '每天 06:00', value: '0 6 * * *' }, { label: '每周一 09:00', value: '0 9 * * 1' }, { label: '自定义', value: 'custom' }, ]; export default function TaskListPage() { const [tasks, setTasks] = useState([]); const [companies, setCompanies] = useState([]); const [models, setModels] = useState([]); const [modalOpen, setModalOpen] = useState(false); const [keywords, setKeywords] = useState([]); const [competitors, setCompetitors] = useState([]); const [currentStep, setCurrentStep] = useState(0); const [formData, setFormData] = useState({}); const [cronPreset, setCronPreset] = useState(''); const [form] = Form.useForm(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const companyFilter = searchParams.get('companyId') ?? undefined; const load = () => { tasksApi.list(companyFilter).then(setTasks); companiesApi.list().then(setCompanies); aiModelsApi.list().then(setModels); }; useEffect(() => { load(); }, [companyFilter]); const handleCompanySelect = async (companyId: string) => { const [kws, comps] = await Promise.all([ companiesApi.keywords.list(companyId), companiesApi.competitors.list(companyId), ]); setKeywords(kws); setCompetitors(comps); }; const nextStep = async () => { const values = await form.validateFields(); const merged = { ...formData, ...values }; setFormData(merged); if (currentStep === 0) await handleCompanySelect(merged.companyId); setCurrentStep((s) => s + 1); }; const handleCreate = async () => { const values = await form.validateFields(); const merged = { ...formData, ...values }; let schedule: string | undefined; if (merged.enableSchedule) { schedule = merged.cronPreset === 'custom' ? merged.customCron : merged.cronPreset; } await tasksApi.create({ companyId: merged.companyId, name: merged.name, keywordIds: merged.keywordIds ?? [], competitorIds: merged.competitorIds ?? [], modelIds: merged.modelIds, schedule, }); message.success('任务创建成功'); setModalOpen(false); setCurrentStep(0); setFormData({}); form.resetFields(); load(); }; const handleRun = async (id: string) => { await tasksApi.run(id); message.success('任务已触发,正在运行...'); setTimeout(load, 1000); }; const columns = [ { title: '任务名', dataIndex: 'name', key: 'name' }, { title: '公司', key: 'company', render: (_: any, r: AnalysisTask) => companies.find((c) => c.id === r.companyId)?.name ?? r.companyId }, { title: '状态', dataIndex: 'status', key: 'status', render: (v: string) => {STATUS_LABELS[v]} }, { title: '定时', dataIndex: 'schedule', key: 'schedule', render: (v: string) => v ? {v} : '-' }, { title: '上次运行', dataIndex: 'lastRunAt', key: 'lastRun', render: (v: string) => v ? new Date(v).toLocaleString() : '-' }, { title: '操作', key: 'action', render: (_: any, r: AnalysisTask) => ( ), }, ]; const steps = [ { title: '基本信息', content: ( <> ({ value: k.id, label: k.text }))} placeholder="留空则使用全部关键字" /> m.isEnabled).map((m) => ({ value: m.id, label: m.name }))} /> )}, { title: '定时设置', content: ( <> setCronPreset(e.target.checked ? '0 0 * * *' : '')} /> {cronPreset && ( <> )} )} )}, ]; return (
分析任务
{ setModalOpen(false); setCurrentStep(0); form.resetFields(); }} footer={null} width={600} destroyOnClose> ({ title: s.title }))} style={{ marginBottom: 24 }} />
{steps[currentStep].content} {currentStep > 0 && } {currentStep < steps.length - 1 && } {currentStep === steps.length - 1 && }
); } ``` - [ ] **Step 3: 创建 TaskResultsPage.tsx** ```tsx // frontend/src/pages/tasks/TaskResultsPage.tsx import { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Table, Button, Tag, Space, Modal, Typography, Select, Descriptions, Badge } from 'antd'; import { ArrowLeftOutlined } from '@ant-design/icons'; import { tasksApi } from '../../api/tasks'; import { aiModelsApi } from '../../api/ai-models'; import { AnalysisResult, AiModel } from '../../types'; const { Title, Paragraph } = Typography; const SENTIMENT_COLORS = { positive: 'green', neutral: 'default', negative: 'red' }; const SENTIMENT_LABELS = { positive: '正面', neutral: '中性', negative: '负面' }; export default function TaskResultsPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const [results, setResults] = useState([]); const [models, setModels] = useState([]); const [selected, setSelected] = useState(null); const [filterKeyword, setFilterKeyword] = useState(); const [filterModel, setFilterModel] = useState(); useEffect(() => { tasksApi.results(id!).then(setResults); aiModelsApi.list().then(setModels); }, [id]); const keywords = [...new Set(results.map((r) => r.keyword))]; const filtered = results.filter( (r) => (!filterKeyword || r.keyword === filterKeyword) && (!filterModel || r.modelId === filterModel) ); const columns = [ { title: '关键字', dataIndex: 'keyword', key: 'keyword' }, { title: '模型', key: 'model', render: (_: any, r: AnalysisResult) => models.find((m) => m.id === r.modelId)?.name ?? r.modelId }, { title: '品牌提及', dataIndex: 'brandMentioned', key: 'mentioned', render: (v: boolean, r: AnalysisResult) => v ? 是 ({r.mentionCount}次) : }, { title: '情感', dataIndex: 'sentiment', key: 'sentiment', render: (v: string) => {SENTIMENT_LABELS[v as keyof typeof SENTIMENT_LABELS]} }, { title: '竞品提及', key: 'competitors', render: (_: any, r: AnalysisResult) => r.competitorMentions.filter((c) => c.count > 0).map((c) => {c.name}: {c.count}) }, { title: '状态', key: 'status', render: (_: any, r: AnalysisResult) => r.errorMessage ? : }, { title: '操作', key: 'action', render: (_: any, r: AnalysisResult) => }, ]; return (
分析结果 ({ value: m.id, label: m.name }))} onChange={setFilterModel} />
setSelected(null)} footer={null} width={700} > {selected && ( <> {selected.keyword} {selected.prompt}
                {selected.rawResponse || selected.errorMessage || '(无内容)'}
              
)}
); } ``` - [ ] **Step 4: Commit** ```bash cd /d/geo2 git add frontend/src/pages/ai-models frontend/src/pages/tasks git commit -m "feat: AI models page + task list page + task results page" ``` --- ## Task 12: 前端 — 对比看板 **Files:** - Create: `frontend/src/pages/dashboard/DashboardPage.tsx` - [ ] **Step 1: 创建 DashboardPage.tsx** ```tsx // frontend/src/pages/dashboard/DashboardPage.tsx import { useState, useEffect, useCallback } from 'react'; import { Row, Col, Card, Select, DatePicker, Typography, Empty, Spin, Tag } from 'antd'; import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell } from 'recharts'; import dayjs from 'dayjs'; import { companiesApi } from '../../api/companies'; import { aiModelsApi } from '../../api/ai-models'; import { dashboardApi } from '../../api/dashboard'; import { Company, Keyword, AiModel } from '../../types'; const { Title } = Typography; const { RangePicker } = DatePicker; const SENTIMENT_COLORS = { positive: '#52c41a', neutral: '#d9d9d9', negative: '#ff4d4f' }; export default function DashboardPage() { const [companies, setCompanies] = useState([]); const [allModels, setAllModels] = useState([]); const [keywords, setKeywords] = useState([]); const [companyId, setCompanyId] = useState(); const [keyword, setKeyword] = useState(); const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>([dayjs().subtract(30, 'day'), dayjs()]); const [selectedModels, setSelectedModels] = useState([]); const [trendData, setTrendData] = useState([]); const [competitorData, setCompetitorData] = useState([]); const [modelData, setModelData] = useState([]); const [sentimentData, setSentimentData] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { companiesApi.list().then(setCompanies); aiModelsApi.list().then(setAllModels); }, []); const handleCompanyChange = async (id: string) => { setCompanyId(id); setKeyword(undefined); const kws = await companiesApi.keywords.list(id); setKeywords(kws); }; const loadData = useCallback(async () => { if (!companyId || !keyword) return; setLoading(true); try { const params = { companyId, keyword, from: dateRange[0].toISOString(), to: dateRange[1].toISOString(), models: selectedModels.length ? selectedModels.join(',') : undefined, }; const [trend, competitors, models, sentiment] = await Promise.all([ dashboardApi.trend(params), dashboardApi.competitors({ companyId, keyword, from: params.from, to: params.to }), dashboardApi.models({ companyId, keyword, from: params.from, to: params.to }), dashboardApi.sentiment(params), ]); setTrendData(trend); // Add brand to competitor data for comparison const brandRate = trend.length ? Math.round(trend.reduce((sum: number, d: any) => sum + d.brandMentionRate, 0) / trend.length) : 0; const company = companies.find((c) => c.id === companyId); setCompetitorData([ { name: company?.name ?? '我方品牌', rate: brandRate, isBrand: true }, ...competitors.map((c: any) => ({ name: c.name, rate: c.mentionRate, isBrand: false })), ]); setModelData(models.map((m: any) => ({ ...m, name: allModels.find((am) => am.id === m.modelId)?.name ?? m.modelId, }))); setSentimentData(sentiment.map((s: any) => ({ ...s, name: allModels.find((am) => am.id === s.modelId)?.name ?? s.modelId, total: s.positive + s.neutral + s.negative, }))); } finally { setLoading(false); } }, [companyId, keyword, dateRange, selectedModels, companies, allModels]); useEffect(() => { loadData(); }, [loadData]); const getMentionRateColor = (rate: number) => rate >= 60 ? '#52c41a' : rate >= 30 ? '#faad14' : '#ff4d4f'; return (
对比看板 {/* Filters */}
({ value: k.text, label: k.text }))} onChange={setKeyword} disabled={!companyId} /> r && setDateRange(r as any)} />