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:
11
.env.example
11
.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
|
||||
|
||||
@@ -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
15
backend/Dockerfile
Normal 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
18
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
88
backend/src/routes/fortune.ts
Normal file
88
backend/src/routes/fortune.ts
Normal 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
18
backend/src/server.ts
Normal 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}`);
|
||||
});
|
||||
|
||||
105
backend/src/services/deepseek.ts
Normal file
105
backend/src/services/deepseek.ts
Normal 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/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);
|
||||
}
|
||||
}
|
||||
|
||||
171
backend/src/services/fortune-mapper.ts
Normal file
171
backend/src/services/fortune-mapper.ts
Normal 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
14
backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
20
nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
262
shared/fortune.ts
Normal 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
59
shared/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
28
src/services/fortune.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -17,5 +17,5 @@
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "shared/**/*.ts"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user