From 3c0fbd50597191088c7dae2f92796544e5e2c760 Mon Sep 17 00:00:00 2001 From: Jowe <123822645+Selei1983@users.noreply.github.com> Date: Sat, 28 Mar 2026 01:37:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=20DeepSeek=20AI=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 backend 服务 (Express + TypeScript) - 前端表单提交改为调用后端 API - DeepSeek 增强分析结果,本地规则作为 fallback - Docker 双容器部署:web (Nginx) + backend - Nginx 反向代理 /api 到后端服务 - 本地开发支持 Vite 代理 + backend:dev 脚本 Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 11 +- Dockerfile | 4 +- backend/Dockerfile | 15 ++ backend/package.json | 18 ++ backend/src/routes/fortune.ts | 88 ++++++++ backend/src/server.ts | 18 ++ backend/src/services/deepseek.ts | 105 ++++++++++ backend/src/services/fortune-mapper.ts | 171 +++++++++++++++ backend/tsconfig.json | 14 ++ docker-compose.yml | 27 ++- docker-deploy.md | 22 +- nginx.conf | 20 ++ package.json | 4 +- shared/fortune.ts | 262 +++++++++++++++++++++++ shared/types.ts | 59 ++++++ src/lib/fortune.ts | 280 +------------------------ src/pages/FormPage.tsx | 38 ++-- src/services/fortune.ts | 28 +++ src/styles/index.css | 10 + src/types/index.ts | 57 +---- tsconfig.app.json | 2 +- vite.config.ts | 8 + 22 files changed, 909 insertions(+), 352 deletions(-) create mode 100644 backend/Dockerfile create mode 100644 backend/package.json create mode 100644 backend/src/routes/fortune.ts create mode 100644 backend/src/server.ts create mode 100644 backend/src/services/deepseek.ts create mode 100644 backend/src/services/fortune-mapper.ts create mode 100644 backend/tsconfig.json create mode 100644 nginx.conf create mode 100644 shared/fortune.ts create mode 100644 shared/types.ts create mode 100644 src/services/fortune.ts diff --git a/.env.example b/.env.example index 6293bf6..0f82f07 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,9 @@ -# 预留后续环境变量 -# VITE_API_BASE_URL= +# 前端(生产同域部署时可留空) +VITE_API_BASE_URL= + +# 后端 +PORT=3000 +DEEPSEEK_API_KEY= +DEEPSEEK_BASE_URL=https://api.deepseek.com +DEEPSEEK_MODEL=deepseek-chat +REQUEST_TIMEOUT_MS=20000 diff --git a/Dockerfile b/Dockerfile index f2682dc..8942691 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,8 +23,8 @@ FROM nginx:alpine # 从构建阶段复制产物 COPY --from=builder /app/dist /usr/share/nginx/html -# 复制 Nginx 配置(可选) -# COPY nginx.conf /etc/nginx/conf.d/default.conf +# 复制 Nginx 配置 +COPY nginx.conf /etc/nginx/conf.d/default.conf # 暴露端口 EXPOSE 80 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..ec4158f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY backend/package*.json ./backend/ +RUN npm install --prefix backend + +COPY shared ./shared +COPY backend ./backend + +WORKDIR /app/backend + +EXPOSE 3000 + +CMD ["npm", "run", "start"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..4ddecf7 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,18 @@ +{ + "name": "tianxuan-company-backend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "start": "node --import tsx src/server.ts" + }, + "dependencies": { + "express": "^5.1.0" + }, + "devDependencies": { + "@types/express": "^5.0.3", + "tsx": "^4.20.6", + "typescript": "^5.9.2" + } +} diff --git a/backend/src/routes/fortune.ts b/backend/src/routes/fortune.ts new file mode 100644 index 0000000..5758349 --- /dev/null +++ b/backend/src/routes/fortune.ts @@ -0,0 +1,88 @@ +import type { Request, Response } from 'express'; +import { Router } from 'express'; +import type { FortuneAnalyzeResponse, UserInput } from '../../../shared/types.js'; +import { createDeepSeekServiceFromEnv } from '../services/deepseek.js'; +import { createBaselineResult, normalizeFortuneResult } from '../services/fortune-mapper.js'; + +const router = Router(); + +router.post('/analyze', async (req: Request, res: Response) => { + const input = validateUserInput(req.body); + + if (!input) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_INPUT', + message: '请输入完整且有效的出生信息。', + }, + }); + } + + const baseline = createBaselineResult(input); + const model = process.env.DEEPSEEK_MODEL?.trim() || 'deepseek-chat'; + + try { + const service = createDeepSeekServiceFromEnv(); + const raw = await service.enhanceFortune(input, baseline); + const data = normalizeFortuneResult(input, raw, baseline); + + const payload: FortuneAnalyzeResponse = { + success: true, + data, + meta: { + provider: 'deepseek', + model, + fallbackUsed: false, + }, + }; + + return res.json(payload); + } catch { + const payload: FortuneAnalyzeResponse = { + success: true, + data: baseline, + meta: { + provider: 'deepseek', + model, + fallbackUsed: true, + }, + }; + + return res.json(payload); + } +}); + +export { router as fortuneRouter }; + +function validateUserInput(value: unknown): UserInput | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + const input = value as Record; + const birthDate = typeof input.birthDate === 'string' ? input.birthDate.trim() : ''; + const birthTime = typeof input.birthTime === 'string' ? input.birthTime.trim() : ''; + const gender = input.gender === 'male' || input.gender === 'female' ? input.gender : null; + const birthPlace = typeof input.birthPlace === 'string' ? input.birthPlace.trim() : ''; + + if (!/^\d{4}-\d{2}-\d{2}$/.test(birthDate)) { + return null; + } + + if (!/^\d{2}:\d{2}$/.test(birthTime)) { + return null; + } + + if (!gender || !birthPlace || birthPlace.length > 40) { + return null; + } + + return { + birthDate, + birthTime, + gender, + birthPlace, + }; +} + diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..72a3578 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,18 @@ +import express from 'express'; +import { fortuneRouter } from './routes/fortune.js'; + +const app = express(); +const port = Number.parseInt(process.env.PORT || '3000', 10); + +app.use(express.json({ limit: '1mb' })); + +app.get('/api/health', (_req, res) => { + res.json({ ok: true }); +}); + +app.use('/api/fortune', fortuneRouter); + +app.listen(port, () => { + console.log(`tianxuan-company backend listening on ${port}`); +}); + diff --git a/backend/src/services/deepseek.ts b/backend/src/services/deepseek.ts new file mode 100644 index 0000000..0f12438 --- /dev/null +++ b/backend/src/services/deepseek.ts @@ -0,0 +1,105 @@ +import type { FortuneResult, UserInput } from '../../../shared/types.js'; + +interface DeepSeekConfig { + apiKey: string; + baseUrl: string; + model: string; + timeoutMs: number; +} + +interface DeepSeekMessage { + role: 'system' | 'user'; + content: string; +} + +interface DeepSeekResponse { + choices?: Array<{ + message?: { + content?: string; + }; + }>; +} + +export class DeepSeekService { + constructor(private readonly config: DeepSeekConfig) {} + + async enhanceFortune(input: UserInput, baseline: FortuneResult): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.config.timeoutMs); + + try { + const response = await fetch(`${this.config.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.apiKey}`, + }, + body: JSON.stringify({ + model: this.config.model, + temperature: 0.7, + response_format: { type: 'json_object' }, + messages: buildMessages(input, baseline), + }), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`DeepSeek 请求失败: ${response.status}`); + } + + const data = (await response.json()) as DeepSeekResponse; + const content = data.choices?.[0]?.message?.content; + + if (!content) { + throw new Error('DeepSeek 未返回有效内容'); + } + + return safeJsonParse(content); + } finally { + clearTimeout(timer); + } + } +} + +export function createDeepSeekServiceFromEnv(): DeepSeekService { + const apiKey = process.env.DEEPSEEK_API_KEY?.trim(); + + if (!apiKey) { + throw new Error('缺少 DEEPSEEK_API_KEY 环境变量'); + } + + return new DeepSeekService({ + apiKey, + baseUrl: process.env.DEEPSEEK_BASE_URL?.trim() || 'https://api.deepseek.com', + model: process.env.DEEPSEEK_MODEL?.trim() || 'deepseek-chat', + timeoutMs: Number.parseInt(process.env.REQUEST_TIMEOUT_MS || '20000', 10), + }); +} + +function buildMessages(input: UserInput, baseline: FortuneResult): DeepSeekMessage[] { + return [ + { + role: 'system', + content: + '你是职业命理分析助手。你必须只返回 JSON,不要输出任何解释、Markdown 或代码块。请严格返回字段:profile、companyRecommendations、jobRecommendations、stageAdvice。保留现有结构,数组字段必须是数组,matchScore 必须是数字,developmentRhythm 只能是 stable/growth/breakthrough/accumulation,wuxing 只能是 wood/fire/earth/metal/water。内容风格要求:专业、克制、带东方命理气质,但不要夸张神化。', + }, + { + role: 'user', + content: JSON.stringify({ + task: '请在 baseline 基础上增强文案质量和推荐理由,输出适合前端直接渲染的 JSON。若你认为 baseline 某些字段已经足够合理,也可以保留。', + input, + baseline, + }), + }, + ]; +} + +function safeJsonParse(content: string): unknown { + try { + return JSON.parse(content); + } catch { + const cleaned = content.replace(/^```json\s*/i, '').replace(/```$/i, '').trim(); + return JSON.parse(cleaned); + } +} + diff --git a/backend/src/services/fortune-mapper.ts b/backend/src/services/fortune-mapper.ts new file mode 100644 index 0000000..d7747e8 --- /dev/null +++ b/backend/src/services/fortune-mapper.ts @@ -0,0 +1,171 @@ +import { analyzeFortune } from '../../../shared/fortune.js'; +import type { + CompanyRecommendation, + FortuneProfile, + FortuneResult, + JobRecommendation, + StageAdvice, + UserInput, +} from '../../../shared/types.js'; + +type PartialArrayValue = unknown[] | string | undefined; + +export function createBaselineResult(input: UserInput): FortuneResult { + return analyzeFortune(input); +} + +export function normalizeFortuneResult(input: UserInput, raw: unknown, baseline: FortuneResult): FortuneResult { + if (!raw || typeof raw !== 'object') { + return baseline; + } + + const source = raw as Record; + + return { + profile: normalizeProfile(source.profile, baseline.profile), + companyRecommendations: normalizeCompanyRecommendations( + source.companyRecommendations, + baseline.companyRecommendations, + ), + jobRecommendations: normalizeJobRecommendations(source.jobRecommendations, baseline.jobRecommendations), + stageAdvice: normalizeStageAdvice(source.stageAdvice, baseline.stageAdvice, input), + }; +} + +function normalizeProfile(value: unknown, baseline: FortuneProfile): FortuneProfile { + const source = asRecord(value); + + return { + wuxing: pickEnum(source?.wuxing, ['wood', 'fire', 'earth', 'metal', 'water'], baseline.wuxing), + wuxingLabel: pickString(source?.wuxingLabel, baseline.wuxingLabel), + personality: pickStringArray(source?.personality, baseline.personality, 3), + strengths: pickStringArray(source?.strengths, baseline.strengths, 3), + weaknesses: pickStringArray(source?.weaknesses, baseline.weaknesses, 2), + developmentRhythm: pickEnum( + source?.developmentRhythm, + ['stable', 'growth', 'breakthrough', 'accumulation'], + baseline.developmentRhythm, + ), + }; +} + +function normalizeCompanyRecommendations(value: unknown, baseline: CompanyRecommendation[]): CompanyRecommendation[] { + const items = Array.isArray(value) ? value : []; + const normalized = items + .slice(0, 4) + .map((item, index) => { + const source = asRecord(item); + const fallback = baseline[index] || baseline[0]; + + return { + type: pickString(source?.type, fallback.type), + matchScore: pickScore(source?.matchScore, fallback.matchScore), + reasons: pickStringArray(source?.reasons, fallback.reasons, 2), + suitableFor: pickStringArray(source?.suitableFor, fallback.suitableFor, 1), + notSuitableFor: pickStringArray(source?.notSuitableFor, fallback.notSuitableFor, 1), + stageAdvice: pickString(source?.stageAdvice, fallback.stageAdvice), + }; + }) + .filter((item) => item.type); + + return fillRecommendations(normalized, baseline, 4); +} + +function normalizeJobRecommendations(value: unknown, baseline: JobRecommendation[]): JobRecommendation[] { + const items = Array.isArray(value) ? value : []; + const normalized = items + .slice(0, 4) + .map((item, index) => { + const source = asRecord(item); + const fallback = baseline[index] || baseline[0]; + + return { + category: pickString(source?.category, fallback.category), + matchScore: pickScore(source?.matchScore, fallback.matchScore), + reasons: pickStringArray(source?.reasons, fallback.reasons, 2), + suitableWorkStyle: pickStringArray(source?.suitableWorkStyle, fallback.suitableWorkStyle, 1), + notSuitableFor: pickStringArray(source?.notSuitableFor, fallback.notSuitableFor, 1), + }; + }) + .filter((item) => item.category); + + return fillRecommendations(normalized, baseline, 4); +} + +function normalizeStageAdvice(value: unknown, baseline: StageAdvice, input: UserInput): StageAdvice { + const source = asRecord(value); + const locationSuffix = input.birthPlace ? `结合你在${input.birthPlace}出生的地域气场参考,` : ''; + + return { + overall: pickString(source?.overall, `${locationSuffix}${baseline.overall}`), + careerDirection: pickString(source?.careerDirection, baseline.careerDirection), + jobChangeTiming: pickString(source?.jobChangeTiming, baseline.jobChangeTiming), + platformPreference: pickString(source?.platformPreference, baseline.platformPreference), + keyObservations: pickStringArray(source?.keyObservations, baseline.keyObservations, 3), + }; +} + +function fillRecommendations(items: T[], baseline: T[], size: number): T[] { + const result = [...items]; + + for (const fallback of baseline) { + if (result.length >= size) { + break; + } + + result.push(fallback); + } + + return result.slice(0, size); +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + return value as Record; +} + +function pickString(value: unknown, fallback: string): string { + if (typeof value !== 'string') { + return fallback; + } + + const normalized = value.trim(); + return normalized || fallback; +} + +function pickStringArray(value: PartialArrayValue, fallback: string[], minLength: number): string[] { + const items = Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter(Boolean) + : typeof value === 'string' + ? value.split(/[,,、\n]/).map((item) => item.trim()).filter(Boolean) + : []; + + if (items.length >= minLength) { + return items; + } + + return fallback; +} + +function pickScore(value: unknown, fallback: number): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.max(50, Math.min(99, Math.round(value))); + } + + if (typeof value === 'string') { + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed)) { + return Math.max(50, Math.min(99, parsed)); + } + } + + return fallback; +} + +function pickEnum(value: unknown, allowed: readonly T[], fallback: T): T { + return typeof value === 'string' && allowed.includes(value as T) ? (value as T) : fallback; +} + diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..bdcfd11 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "../shared/**/*.ts"] +} diff --git a/docker-compose.yml b/docker-compose.yml index 86cf32d..254dd50 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: web: build: @@ -9,6 +7,8 @@ services: ports: - "8801:80" restart: unless-stopped + depends_on: + - backend healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost/"] interval: 30s @@ -18,6 +18,29 @@ services: networks: - tianxuan-network + backend: + build: + context: . + dockerfile: backend/Dockerfile + container_name: tianxuan-company-backend + restart: unless-stopped + env_file: + - .env + environment: + PORT: 3000 + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY} + DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com} + DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat} + REQUEST_TIMEOUT_MS: ${REQUEST_TIMEOUT_MS:-20000} + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then((res) => { if (!res.ok) process.exit(1); }).catch(() => process.exit(1))"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + networks: + - tianxuan-network + networks: tianxuan-network: driver: bridge \ No newline at end of file diff --git a/docker-deploy.md b/docker-deploy.md index 8f6c7e4..85208b2 100644 --- a/docker-deploy.md +++ b/docker-deploy.md @@ -7,10 +7,14 @@ # 1. 拉取代码 git pull -# 2. 构建并启动容器 +# 2. 配置环境变量 +cp .env.example .env +# 编辑 .env,至少填入 DEEPSEEK_API_KEY + +# 3. 构建并启动容器 docker-compose up -d --build -# 3. 查看日志 +# 4. 查看日志 docker-compose logs -f ``` @@ -44,9 +48,15 @@ docker-compose logs -f docker stats ``` +## 服务结构 + +- `web`: Nginx 托管前端静态资源,并把 `/api/*` 转发到 backend +- `backend`: Node + Express 服务,负责调用 DeepSeek + ## 端口配置 -- 容器内:80 (Nginx) +- web 容器内:80 (Nginx) +- backend 容器内:3000 (Node) - 宿主机映射:8801 ## 1Panel 反向代理配置 @@ -55,6 +65,6 @@ docker stats ## 环境说明 -- Node 版本:20-alpine (构建阶段) -- Nginx 版本:alpine (运行阶段) -- 构建产物直接打包进容器,无需在宿主机安装 Node.js \ No newline at end of file +- 前端:Node 20-alpine 构建 + Nginx alpine 运行 +- 后端:Node 20-alpine 运行 +- DeepSeek 密钥仅放在 `.env`,不要写入前端代码 \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..cafdc35 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:3000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/package.json b/package.json index 0d0a4c6..cb652d5 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "backend:dev": "npm --prefix backend run dev", + "backend:start": "npm --prefix backend run start" }, "dependencies": { "react": "^19.1.1", diff --git a/shared/fortune.ts b/shared/fortune.ts new file mode 100644 index 0000000..5400507 --- /dev/null +++ b/shared/fortune.ts @@ -0,0 +1,262 @@ +import type { + UserInput, + FortuneResult, + FortuneProfile, + CompanyRecommendation, + JobRecommendation, + StageAdvice, +} from './types'; + +function calculateWuxing(birthDate: string): { wuxing: FortuneProfile['wuxing']; wuxingLabel: string } { + const date = new Date(birthDate); + const year = date.getFullYear(); + + const tiangan = year % 10; + const tianganMap: Record = { + 0: 'metal', + 1: 'metal', + 2: 'water', + 3: 'water', + 4: 'wood', + 5: 'wood', + 6: 'fire', + 7: 'fire', + 8: 'earth', + 9: 'earth', + }; + + const wuxingKey = tianganMap[tiangan] || 'earth'; + const labelMap: Record = { + wood: '木', + fire: '火', + earth: '土', + metal: '金', + water: '水', + }; + + return { + wuxing: wuxingKey as FortuneProfile['wuxing'], + wuxingLabel: labelMap[wuxingKey] || '土', + }; +} + +function generateProfile(wuxing: FortuneProfile['wuxing'], wuxingLabel: string): FortuneProfile { + const profiles: Record> = { + wood: { + personality: ['有生发之力', '善于规划与成长', '追求目标坚定'], + strengths: ['领导力', '规划能力', '适应力'], + weaknesses: ['有时过于刚硬', '易忽略细节'], + developmentRhythm: 'growth', + }, + fire: { + personality: ['热情洋溢', '执行力强', '喜欢表达'], + strengths: ['创造力', '感染力', '行动力'], + weaknesses: ['易急躁', '缺乏耐心'], + developmentRhythm: 'breakthrough', + }, + earth: { + personality: ['稳重务实', '注重规则', '善于协调'], + strengths: ['稳定性', '执行力', '资源整合'], + weaknesses: ['过于保守', '创新不足'], + developmentRhythm: 'stable', + }, + metal: { + personality: ['刚毅果断', '追求效率', '原则性强'], + strengths: ['决断力', '专业度', '抗压能力'], + weaknesses: ['过于严厉', '不够灵活'], + developmentRhythm: 'accumulation', + }, + water: { + personality: ['灵活变通', '善于沟通', '适应力强'], + strengths: ['沟通力', '学习力', '洞察力'], + weaknesses: ['易动摇', '缺乏坚持'], + developmentRhythm: 'growth', + }, + }; + + const profile = profiles[wuxing] || profiles.earth; + + return { + wuxing, + wuxingLabel, + personality: profile.personality || [], + strengths: profile.strengths || [], + weaknesses: profile.weaknesses || [], + developmentRhythm: profile.developmentRhythm || 'stable', + }; +} + +function generateCompanyRecommendations(profile: FortuneProfile): CompanyRecommendation[] { + const { developmentRhythm } = profile; + + const companyTypes: CompanyRecommendation[] = [ + { + type: '一线大厂', + matchScore: 85, + reasons: ['资源丰富,平台稳定', '制度完善,适合长期发展', '能发挥你的专业能力'], + suitableFor: ['追求稳定发展', '希望积累大平台经验'], + notSuitableFor: ['渴望快速晋升', '喜欢灵活工作方式'], + stageAdvice: developmentRhythm === 'stable' ? '适合进入积累期' : '可考虑内部晋升机会', + }, + { + type: '独角兽公司', + matchScore: 78, + reasons: ['成长空间大', '能接触核心业务', '晋升机会多'], + suitableFor: ['渴望快速成长', '愿意承担风险'], + notSuitableFor: ['追求极度稳定', '不喜欢不确定性'], + stageAdvice: developmentRhythm === 'growth' ? '非常适合当前阶段' : '需评估团队稳定性', + }, + { + type: '高增长创业公司', + matchScore: 72, + reasons: ['决策灵活', '成长速度快', '能全面锻炼能力'], + suitableFor: ['敢于冒险', '希望快速晋升'], + notSuitableFor: ['喜欢按部就班', '需要明确指引'], + stageAdvice: '适合寻求突破的年轻人', + }, + { + type: '强技术驱动型公司', + matchScore: 80, + reasons: ['重视技术能力', '氛围开放', '创新机会多'], + suitableFor: ['技术导向', '喜欢钻研'], + notSuitableFor: ['销售导向', '不喜欢深度工作'], + stageAdvice: '技术路线发展的理想选择', + }, + { + type: '强产品驱动型公司', + matchScore: 76, + reasons: ['用户体验为核心', '重视创新', '成长空间大'], + suitableFor: ['产品思维', '用户导向'], + notSuitableFor: ['纯执行导向', '不喜欢思考'], + stageAdvice: '产品路线发展的好平台', + }, + { + type: '稳定流程型组织', + matchScore: 68, + reasons: ['工作稳定', '流程规范', '压力适中'], + suitableFor: ['追求工作生活平衡', '喜欢清晰职责'], + notSuitableFor: ['渴望快速变化', '喜欢创新'], + stageAdvice: developmentRhythm === 'stable' ? '适合当前阶段' : '可能限制发展', + }, + ]; + + return companyTypes.sort((a, b) => b.matchScore - a.matchScore).slice(0, 4); +} + +function generateJobRecommendations(profile: FortuneProfile): JobRecommendation[] { + const jobTypes: JobRecommendation[] = [ + { + category: '技术研发', + matchScore: 88, + reasons: ['你的特质适合深耕技术', '需要专业能力和持续学习'], + suitableWorkStyle: ['独立钻研', '解决复杂问题'], + notSuitableFor: ['频繁社交', '简单重复工作'], + }, + { + category: '产品经理', + matchScore: 82, + reasons: ['需要平衡多方需求', '注重规划和沟通'], + suitableWorkStyle: ['协调资源', '用户洞察'], + notSuitableFor: ['纯技术执行', '不喜欢沟通'], + }, + { + category: '运营', + matchScore: 79, + reasons: ['需要数据分析能力', '注重用户增长'], + suitableWorkStyle: ['数据分析', '活动策划'], + notSuitableFor: ['极度内向', '不喜欢变化'], + }, + { + category: '销售商务', + matchScore: 75, + reasons: ['需要人际沟通能力', '结果导向'], + suitableWorkStyle: ['人际交往', '资源整合'], + notSuitableFor: ['不喜欢竞争', '害怕被拒绝'], + }, + { + category: '设计', + matchScore: 77, + reasons: ['需要审美和创造力', '注重用户体验'], + suitableWorkStyle: ['视觉表达', '创意工作'], + notSuitableFor: ['不喜欢抽象思维', '追求确定答案'], + }, + { + category: '管理岗', + matchScore: 73, + reasons: ['需要领导力和规划能力', '注重团队发展'], + suitableWorkStyle: ['团队管理', '战略规划'], + notSuitableFor: ['喜欢独立工作', '不喜欢处理人事'], + }, + ]; + + if (profile.wuxing === 'wood') { + jobTypes.find((j) => j.category === '管理岗')!.matchScore += 5; + jobTypes.find((j) => j.category === '产品经理')!.matchScore += 3; + } else if (profile.wuxing === 'fire') { + jobTypes.find((j) => j.category === '销售商务')!.matchScore += 5; + jobTypes.find((j) => j.category === '运营')!.matchScore += 3; + } else if (profile.wuxing === 'water') { + jobTypes.find((j) => j.category === '运营')!.matchScore += 5; + jobTypes.find((j) => j.category === '产品经理')!.matchScore += 3; + } else if (profile.wuxing === 'metal') { + jobTypes.find((j) => j.category === '技术研发')!.matchScore += 5; + jobTypes.find((j) => j.category === '管理岗')!.matchScore += 3; + } else if (profile.wuxing === 'earth') { + jobTypes.find((j) => j.category === '运营')!.matchScore += 3; + jobTypes.find((j) => j.category === '管理岗')!.matchScore += 5; + } + + return jobTypes.sort((a, b) => b.matchScore - a.matchScore).slice(0, 4); +} + +function generateStageAdvice(profile: FortuneProfile): StageAdvice { + const { developmentRhythm, wuxingLabel } = profile; + + const adviceMap: Record = { + stable: { + overall: `你目前处于稳定积累期,五行属${wuxingLabel},适合稳扎稳打。`, + careerDirection: '建议在现有岗位上深耕,积累专业能力。', + jobChangeTiming: '当前不建议频繁变动,保持稳定是更好的选择。', + platformPreference: '优先选择成熟稳定的大平台。', + keyObservations: ['关注岗位的成长空间而非只看薪资', '建立专业壁垒是关键', '可以开始培养管理能力'], + }, + growth: { + overall: `你目前处于成长期,五行属${wuxingLabel},有较强的上升动力。`, + careerDirection: '适合寻求晋升机会或承担更大责任。', + jobChangeTiming: '可以开始关注外部机会,但不要急于变动。', + platformPreference: '独角兽或高速成长公司可能更适合你。', + keyObservations: ['主动争取项目机会', '扩大影响力范围', '开始建立个人品牌'], + }, + breakthrough: { + overall: `你目前处于突破期,五行属${wuxingLabel},适合做出重大改变。`, + careerDirection: '可以考虑转型或创业的机会。', + jobChangeTiming: '现在是尝试新方向的好时机。', + platformPreference: '创业公司或新业务部门可能更适合你。', + keyObservations: ['勇敢尝试新领域', '不要害怕失败', '整合资源是关键'], + }, + accumulation: { + overall: `你目前处于沉淀期,五行属${wuxingLabel},需要耐心积累。`, + careerDirection: '适合深耕现有领域,等待时机。', + jobChangeTiming: '建议先完成当前阶段的积累。', + platformPreference: '稳定平台更适合当前状态。', + keyObservations: ['专注提升专业深度', '建立行业人脉', '等待合适的机会'], + }, + }; + + return adviceMap[developmentRhythm] || adviceMap.stable; +} + +export function analyzeFortune(input: UserInput): FortuneResult { + const { wuxing, wuxingLabel } = calculateWuxing(input.birthDate); + const profile = generateProfile(wuxing, wuxingLabel); + const companyRecommendations = generateCompanyRecommendations(profile); + const jobRecommendations = generateJobRecommendations(profile); + const stageAdvice = generateStageAdvice(profile); + + return { + profile, + companyRecommendations, + jobRecommendations, + stageAdvice, + }; +} diff --git a/shared/types.ts b/shared/types.ts new file mode 100644 index 0000000..b5ada4f --- /dev/null +++ b/shared/types.ts @@ -0,0 +1,59 @@ +export interface UserInput { + birthDate: string; + birthTime: string; + gender: 'male' | 'female'; + birthPlace: string; +} + +export interface FortuneProfile { + wuxing: 'wood' | 'fire' | 'earth' | 'metal' | 'water'; + wuxingLabel: string; + personality: string[]; + strengths: string[]; + weaknesses: string[]; + developmentRhythm: 'stable' | 'growth' | 'breakthrough' | 'accumulation'; +} + +export interface CompanyRecommendation { + type: string; + matchScore: number; + reasons: string[]; + suitableFor: string[]; + notSuitableFor: string[]; + stageAdvice: string; +} + +export interface JobRecommendation { + category: string; + matchScore: number; + reasons: string[]; + suitableWorkStyle: string[]; + notSuitableFor: string[]; +} + +export interface StageAdvice { + overall: string; + careerDirection: string; + jobChangeTiming: string; + platformPreference: string; + keyObservations: string[]; +} + +export interface FortuneResult { + profile: FortuneProfile; + companyRecommendations: CompanyRecommendation[]; + jobRecommendations: JobRecommendation[]; + stageAdvice: StageAdvice; +} + +export interface FortuneAnalyzeMeta { + provider: 'deepseek'; + model: string; + fallbackUsed: boolean; +} + +export interface FortuneAnalyzeResponse { + success: boolean; + data: FortuneResult; + meta: FortuneAnalyzeMeta; +} diff --git a/src/lib/fortune.ts b/src/lib/fortune.ts index 01f2a48..a9ff7a3 100644 --- a/src/lib/fortune.ts +++ b/src/lib/fortune.ts @@ -1,279 +1 @@ -import type { UserInput, FortuneResult, FortuneProfile, CompanyRecommendation, JobRecommendation, StageAdvice } from '../types'; - -// 简化的八字五行计算 - 基于出生年份 -function calculateWuxing(birthDate: string): { wuxing: FortuneProfile['wuxing'], wuxingLabel: string } { - const date = new Date(birthDate); - const year = date.getFullYear(); - - // 简化版:根据年份天干映射五行 - const tiangan = year % 10; - const tianganMap: Record = { - 0: 'metal', 1: 'metal', - 2: 'water', 3: 'water', - 4: 'wood', 5: 'wood', - 6: 'fire', 7: 'fire', - 8: 'earth', 9: 'earth' - }; - - const wuxingKey = tianganMap[tiangan] || 'earth'; - const labelMap: Record = { - wood: '木', - fire: '火', - earth: '土', - metal: '金', - water: '水' - }; - - return { - wuxing: wuxingKey as FortuneProfile['wuxing'], - wuxingLabel: labelMap[wuxingKey] || '土' - }; -} - -// 根据五行生成命理画像 -function generateProfile(wuxing: FortuneProfile['wuxing'], wuxingLabel: string): FortuneProfile { - const profiles: Record> = { - wood: { - personality: ['有生发之力', '善于规划与成长', '追求目标坚定'], - strengths: ['领导力', '规划能力', '适应力'], - weaknesses: ['有时过于刚硬', '易忽略细节'], - developmentRhythm: 'growth' - }, - fire: { - personality: ['热情洋溢', '执行力强', '喜欢表达'], - strengths: ['创造力', '感染力', '行动力'], - weaknesses: ['易急躁', '缺乏耐心'], - developmentRhythm: 'breakthrough' - }, - earth: { - personality: ['稳重务实', '注重规则', '善于协调'], - strengths: ['稳定性', '执行力', '资源整合'], - weaknesses: ['过于保守', '创新不足'], - developmentRhythm: 'stable' - }, - metal: { - personality: ['刚毅果断', '追求效率', '原则性强'], - strengths: ['决断力', '专业度', '抗压能力'], - weaknesses: ['过于严厉', '不够灵活'], - developmentRhythm: 'accumulation' - }, - water: { - personality: ['灵活变通', '善于沟通', '适应力强'], - strengths: ['沟通力', '学习力', '洞察力'], - weaknesses: ['易动摇', '缺乏坚持'], - developmentRhythm: 'growth' - } - }; - - const profile = profiles[wuxing] || profiles.earth; - - return { - wuxing, - wuxingLabel, - personality: profile.personality || [], - strengths: profile.strengths || [], - weaknesses: profile.weaknesses || [], - developmentRhythm: profile.developmentRhythm || 'stable', - }; -} - -// 公司类型推荐 -function generateCompanyRecommendations(profile: FortuneProfile): CompanyRecommendation[] { - const { developmentRhythm } = profile; - - const companyTypes: CompanyRecommendation[] = [ - { - type: '一线大厂', - matchScore: 85, - reasons: ['资源丰富,平台稳定', '制度完善,适合长期发展', '能发挥你的专业能力'], - suitableFor: ['追求稳定发展', '希望积累大平台经验'], - notSuitableFor: ['渴望快速晋升', '喜欢灵活工作方式'], - stageAdvice: developmentRhythm === 'stable' ? '适合进入积累期' : '可考虑内部晋升机会' - }, - { - type: '独角兽公司', - matchScore: 78, - reasons: ['成长空间大', '能接触核心业务', '晋升机会多'], - suitableFor: ['渴望快速成长', '愿意承担风险'], - notSuitableFor: ['追求极度稳定', '不喜欢不确定性'], - stageAdvice: developmentRhythm === 'growth' ? '非常适合当前阶段' : '需评估团队稳定性' - }, - { - type: '高增长创业公司', - matchScore: 72, - reasons: ['决策灵活', '成长速度快', '能全面锻炼能力'], - suitableFor: ['敢于冒险', '希望快速晋升'], - notSuitableFor: ['喜欢按部就班', '需要明确指引'], - stageAdvice: '适合寻求突破的年轻人' - }, - { - type: '强技术驱动型公司', - matchScore: 80, - reasons: ['重视技术能力', '氛围开放', '创新机会多'], - suitableFor: ['技术导向', '喜欢钻研'], - notSuitableFor: ['销售导向', '不喜欢深度工作'], - stageAdvice: '技术路线发展的理想选择' - }, - { - type: '强产品驱动型公司', - matchScore: 76, - reasons: ['用户体验为核心', '重视创新', '成长空间大'], - suitableFor: ['产品思维', '用户导向'], - notSuitableFor: ['纯执行导向', '不喜欢思考'], - stageAdvice: '产品路线发展的好平台' - }, - { - type: '稳定流程型组织', - matchScore: 68, - reasons: ['工作稳定', '流程规范', '压力适中'], - suitableFor: ['追求工作生活平衡', '喜欢清晰职责'], - notSuitableFor: ['渴望快速变化', '喜欢创新'], - stageAdvice: developmentRhythm === 'stable' ? '适合当前阶段' : '可能限制发展' - } - ]; - - return companyTypes - .sort((a, b) => b.matchScore - a.matchScore) - .slice(0, 4); -} - -// 岗位类型推荐 -function generateJobRecommendations(profile: FortuneProfile): JobRecommendation[] { - - const jobTypes: JobRecommendation[] = [ - { - category: '技术研发', - matchScore: 88, - reasons: ['你的特质适合深耕技术', '需要专业能力和持续学习'], - suitableWorkStyle: ['独立钻研', '解决复杂问题'], - notSuitableFor: ['频繁社交', '简单重复工作'] - }, - { - category: '产品经理', - matchScore: 82, - reasons: ['需要平衡多方需求', '注重规划和沟通'], - suitableWorkStyle: ['协调资源', '用户洞察'], - notSuitableFor: ['纯技术执行', '不喜欢沟通'] - }, - { - category: '运营', - matchScore: 79, - reasons: ['需要数据分析能力', '注重用户增长'], - suitableWorkStyle: ['数据分析', '活动策划'], - notSuitableFor: ['极度内向', '不喜欢变化'] - }, - { - category: '销售商务', - matchScore: 75, - reasons: ['需要人际沟通能力', '结果导向'], - suitableWorkStyle: ['人际交往', '资源整合'], - notSuitableFor: ['不喜欢竞争', '害怕被拒绝'] - }, - { - category: '设计', - matchScore: 77, - reasons: ['需要审美和创造力', '注重用户体验'], - suitableWorkStyle: ['视觉表达', '创意工作'], - notSuitableFor: ['不喜欢抽象思维', '追求确定答案'] - }, - { - category: '管理岗', - matchScore: 73, - reasons: ['需要领导力和规划能力', '注重团队发展'], - suitableWorkStyle: ['团队管理', '战略规划'], - notSuitableFor: ['喜欢独立工作', '不喜欢处理人事'] - } - ]; - - // 根据五行微调分数 - if (profile.wuxing === 'wood') { - jobTypes.find(j => j.category === '管理岗')!.matchScore += 5; - jobTypes.find(j => j.category === '产品经理')!.matchScore += 3; - } else if (profile.wuxing === 'fire') { - jobTypes.find(j => j.category === '销售商务')!.matchScore += 5; - jobTypes.find(j => j.category === '运营')!.matchScore += 3; - } else if (profile.wuxing === 'water') { - jobTypes.find(j => j.category === '运营')!.matchScore += 5; - jobTypes.find(j => j.category === '产品经理')!.matchScore += 3; - } else if (profile.wuxing === 'metal') { - jobTypes.find(j => j.category === '技术研发')!.matchScore += 5; - jobTypes.find(j => j.category === '管理岗')!.matchScore += 3; - } else if (profile.wuxing === 'earth') { - jobTypes.find(j => j.category === '运营')!.matchScore += 3; - jobTypes.find(j => j.category === '管理岗')!.matchScore += 5; - } - - return jobTypes - .sort((a, b) => b.matchScore - a.matchScore) - .slice(0, 4); -} - -// 生成当前阶段建议 -function generateStageAdvice(profile: FortuneProfile): StageAdvice { - const { developmentRhythm, wuxingLabel } = profile; - - const adviceMap: Record = { - stable: { - overall: `你目前处于稳定积累期,五行属${wuxingLabel},适合稳扎稳打。`, - careerDirection: '建议在现有岗位上深耕,积累专业能力。', - jobChangeTiming: '当前不建议频繁变动,保持稳定是更好的选择。', - platformPreference: '优先选择成熟稳定的大平台。', - keyObservations: [ - '关注岗位的成长空间而非只看薪资', - '建立专业壁垒是关键', - '可以开始培养管理能力' - ] - }, - growth: { - overall: `你目前处于成长期,五行属${wuxingLabel},有较强的上升动力。`, - careerDirection: '适合寻求晋升机会或承担更大责任。', - jobChangeTiming: '可以开始关注外部机会,但不要急于变动。', - platformPreference: '独角兽或高速成长公司可能更适合你。', - keyObservations: [ - '主动争取项目机会', - '扩大影响力范围', - '开始建立个人品牌' - ] - }, - breakthrough: { - overall: `你目前处于突破期,五行属${wuxingLabel},适合做出重大改变。`, - careerDirection: '可以考虑转型或创业的机会。', - jobChangeTiming: '现在是尝试新方向的好时机。', - platformPreference: '创业公司或新业务部门可能更适合你。', - keyObservations: [ - '勇敢尝试新领域', - '不要害怕失败', - '整合资源是关键' - ] - }, - accumulation: { - overall: `你目前处于沉淀期,五行属${wuxingLabel},需要耐心积累。`, - careerDirection: '适合深耕现有领域,等待时机。', - jobChangeTiming: '建议先完成当前阶段的积累。', - platformPreference: '稳定平台更适合当前状态。', - keyObservations: [ - '专注提升专业深度', - '建立行业人脉', - '等待合适的机会' - ] - } - }; - - return adviceMap[developmentRhythm] || adviceMap.stable; -} - -// 主分析函数 -export function analyzeFortune(input: UserInput): FortuneResult { - const { wuxing, wuxingLabel } = calculateWuxing(input.birthDate); - const profile = generateProfile(wuxing, wuxingLabel); - const companyRecommendations = generateCompanyRecommendations(profile); - const jobRecommendations = generateJobRecommendations(profile); - const stageAdvice = generateStageAdvice(profile); - - return { - profile, - companyRecommendations, - jobRecommendations, - stageAdvice - }; -} \ No newline at end of file +export { analyzeFortune } from '../../shared/fortune'; diff --git a/src/pages/FormPage.tsx b/src/pages/FormPage.tsx index 4112087..010a6b8 100644 --- a/src/pages/FormPage.tsx +++ b/src/pages/FormPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import type { UserInput, FortuneResult } from '../types'; -import { analyzeFortune } from '../lib/fortune'; +import { requestFortuneAnalysis } from '../services/fortune'; interface FormPageProps { onSubmit: (input: UserInput, result: FortuneResult) => void; @@ -13,24 +13,32 @@ export function FormPage({ onSubmit, onBack }: FormPageProps) { const [gender, setGender] = useState<'male' | 'female'>('male'); const [birthPlace, setBirthPlace] = useState(''); const [isAnalyzing, setIsAnalyzing] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!birthDate || !birthTime || !birthPlace) return; setIsAnalyzing(true); + setErrorMessage(''); - // 模拟分析过程 - setTimeout(() => { - const input: UserInput = { - birthDate, - birthTime, - gender, - birthPlace - }; - const result = analyzeFortune(input); + const input: UserInput = { + birthDate, + birthTime, + gender, + birthPlace: birthPlace.trim(), + }; + + try { + const result = await requestFortuneAnalysis(input); onSubmit(input, result); - }, 1500); + } catch (error) { + setErrorMessage( + error instanceof Error ? error.message : '分析服务暂时不可用,请稍后重试。', + ); + } finally { + setIsAnalyzing(false); + } }; const isValid = birthDate && birthTime && birthPlace; @@ -49,6 +57,12 @@ export function FormPage({ onSubmit, onBack }: FormPageProps) {
+ {errorMessage && ( +
+ {errorMessage} +
+ )} +