feat: 集成 DeepSeek AI 服务

- 新增 backend 服务 (Express + TypeScript)
- 前端表单提交改为调用后端 API
- DeepSeek 增强分析结果,本地规则作为 fallback
- Docker 双容器部署:web (Nginx) + backend
- Nginx 反向代理 /api 到后端服务
- 本地开发支持 Vite 代理 + backend:dev 脚本

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jowe
2026-03-28 01:37:52 +08:00
parent 7fe1586dc0
commit 3c0fbd5059
22 changed files with 909 additions and 352 deletions

View File

@@ -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

View File

@@ -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

15
backend/Dockerfile Normal file
View File

@@ -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"]

18
backend/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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<string, unknown>;
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,
};
}

18
backend/src/server.ts Normal file
View File

@@ -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}`);
});

View File

@@ -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<unknown> {
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/accumulationwuxing 只能是 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);
}
}

View File

@@ -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<string, unknown>;
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<T>(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<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
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<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
return typeof value === 'string' && allowed.includes(value as T) ? (value as T) : fallback;
}

14
backend/tsconfig.json Normal file
View File

@@ -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"]
}

View File

@@ -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

View File

@@ -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
- 前端:Node 20-alpine 构建 + Nginx alpine 运行
- 后端Node 20-alpine 运行
- DeepSeek 密钥仅放在 `.env`,不要写入前端代码

20
nginx.conf Normal file
View File

@@ -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;
}
}

View File

@@ -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",

262
shared/fortune.ts Normal file
View File

@@ -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<number, string> = {
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<string, string> = {
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<string, Partial<FortuneProfile>> = {
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<string, StageAdvice> = {
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,
};
}

59
shared/types.ts Normal file
View File

@@ -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;
}

View File

@@ -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<number, string> = {
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<string, string> = {
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<string, Partial<FortuneProfile>> = {
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<string, StageAdvice> = {
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
};
}
export { analyzeFortune } from '../../shared/fortune';

View File

@@ -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
birthPlace: birthPlace.trim(),
};
const result = analyzeFortune(input);
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) {
</header>
<form className="form-card" onSubmit={handleSubmit}>
{errorMessage && (
<div className="form-alert" role="alert">
{errorMessage}
</div>
)}
<div className="form-group">
<label htmlFor="birthDate" className="form-label">
<span className="required">*</span>

28
src/services/fortune.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { FortuneAnalyzeResponse, FortuneResult, UserInput } from '../types';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL?.trim() || '';
export async function requestFortuneAnalysis(input: UserInput): Promise<FortuneResult> {
const response = await fetch(`${API_BASE_URL}/api/fortune/analyze`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
});
let payload: FortuneAnalyzeResponse | { error?: { message?: string } } | null = null;
try {
payload = await response.json();
} catch {
payload = null;
}
if (!response.ok || !payload || !('success' in payload) || !payload.success) {
const message = payload && 'error' in payload ? payload.error?.message : undefined;
throw new Error(message || '分析服务暂时不可用,请稍后重试。');
}
return payload.data;
}

View File

@@ -402,6 +402,16 @@ body::before {
font-size: 13px;
}
.form-alert {
padding: 12px 14px;
border-radius: var(--radius-md);
border: 1px solid rgba(214, 180, 122, 0.24);
background: rgba(166, 124, 61, 0.12);
color: var(--color-gold-light);
font-size: 13px;
line-height: 1.6;
}
/* 加载状态 */
.loading-state {
display: flex;

View File

@@ -1,47 +1,10 @@
export interface UserInput {
birthDate: string; // YYYY-MM-DD
birthTime: string; // HH:mm
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 type {
UserInput,
FortuneProfile,
CompanyRecommendation,
JobRecommendation,
StageAdvice,
FortuneResult,
FortuneAnalyzeMeta,
FortuneAnalyzeResponse,
} from '../../shared/types';

View File

@@ -17,5 +17,5 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": ["src", "shared/**/*.ts"]
}

View File

@@ -3,4 +3,12 @@ import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:3000',
changeOrigin: true,
},
},
},
});