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
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# 复制 Nginx 配置(可选)
|
# 复制 Nginx 配置
|
||||||
# COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
# 暴露端口
|
# 暴露端口
|
||||||
EXPOSE 80
|
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:
|
services:
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
@@ -9,6 +7,8 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8801:80"
|
- "8801:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
|
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -18,6 +18,29 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- tianxuan-network
|
- 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:
|
networks:
|
||||||
tianxuan-network:
|
tianxuan-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
@@ -7,10 +7,14 @@
|
|||||||
# 1. 拉取代码
|
# 1. 拉取代码
|
||||||
git pull
|
git pull
|
||||||
|
|
||||||
# 2. 构建并启动容器
|
# 2. 配置环境变量
|
||||||
|
cp .env.example .env
|
||||||
|
# 编辑 .env,至少填入 DEEPSEEK_API_KEY
|
||||||
|
|
||||||
|
# 3. 构建并启动容器
|
||||||
docker-compose up -d --build
|
docker-compose up -d --build
|
||||||
|
|
||||||
# 3. 查看日志
|
# 4. 查看日志
|
||||||
docker-compose logs -f
|
docker-compose logs -f
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -44,9 +48,15 @@ docker-compose logs -f
|
|||||||
docker stats
|
docker stats
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 服务结构
|
||||||
|
|
||||||
|
- `web`: Nginx 托管前端静态资源,并把 `/api/*` 转发到 backend
|
||||||
|
- `backend`: Node + Express 服务,负责调用 DeepSeek
|
||||||
|
|
||||||
## 端口配置
|
## 端口配置
|
||||||
|
|
||||||
- 容器内:80 (Nginx)
|
- web 容器内:80 (Nginx)
|
||||||
|
- backend 容器内:3000 (Node)
|
||||||
- 宿主机映射:8801
|
- 宿主机映射:8801
|
||||||
|
|
||||||
## 1Panel 反向代理配置
|
## 1Panel 反向代理配置
|
||||||
@@ -55,6 +65,6 @@ docker stats
|
|||||||
|
|
||||||
## 环境说明
|
## 环境说明
|
||||||
|
|
||||||
- Node 版本:20-alpine (构建阶段)
|
- 前端:Node 20-alpine 构建 + Nginx alpine 运行
|
||||||
- Nginx 版本:alpine (运行阶段)
|
- 后端:Node 20-alpine 运行
|
||||||
- 构建产物直接打包进容器,无需在宿主机安装 Node.js
|
- 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": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"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": {
|
"dependencies": {
|
||||||
"react": "^19.1.1",
|
"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';
|
export { analyzeFortune } from '../../shared/fortune';
|
||||||
|
|
||||||
// 简化的八字五行计算 - 基于出生年份
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { UserInput, FortuneResult } from '../types';
|
import type { UserInput, FortuneResult } from '../types';
|
||||||
import { analyzeFortune } from '../lib/fortune';
|
import { requestFortuneAnalysis } from '../services/fortune';
|
||||||
|
|
||||||
interface FormPageProps {
|
interface FormPageProps {
|
||||||
onSubmit: (input: UserInput, result: FortuneResult) => void;
|
onSubmit: (input: UserInput, result: FortuneResult) => void;
|
||||||
@@ -13,24 +13,32 @@ export function FormPage({ onSubmit, onBack }: FormPageProps) {
|
|||||||
const [gender, setGender] = useState<'male' | 'female'>('male');
|
const [gender, setGender] = useState<'male' | 'female'>('male');
|
||||||
const [birthPlace, setBirthPlace] = useState('');
|
const [birthPlace, setBirthPlace] = useState('');
|
||||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!birthDate || !birthTime || !birthPlace) return;
|
if (!birthDate || !birthTime || !birthPlace) return;
|
||||||
|
|
||||||
setIsAnalyzing(true);
|
setIsAnalyzing(true);
|
||||||
|
setErrorMessage('');
|
||||||
|
|
||||||
// 模拟分析过程
|
|
||||||
setTimeout(() => {
|
|
||||||
const input: UserInput = {
|
const input: UserInput = {
|
||||||
birthDate,
|
birthDate,
|
||||||
birthTime,
|
birthTime,
|
||||||
gender,
|
gender,
|
||||||
birthPlace
|
birthPlace: birthPlace.trim(),
|
||||||
};
|
};
|
||||||
const result = analyzeFortune(input);
|
|
||||||
|
try {
|
||||||
|
const result = await requestFortuneAnalysis(input);
|
||||||
onSubmit(input, result);
|
onSubmit(input, result);
|
||||||
}, 1500);
|
} catch (error) {
|
||||||
|
setErrorMessage(
|
||||||
|
error instanceof Error ? error.message : '分析服务暂时不可用,请稍后重试。',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsAnalyzing(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isValid = birthDate && birthTime && birthPlace;
|
const isValid = birthDate && birthTime && birthPlace;
|
||||||
@@ -49,6 +57,12 @@ export function FormPage({ onSubmit, onBack }: FormPageProps) {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form className="form-card" onSubmit={handleSubmit}>
|
<form className="form-card" onSubmit={handleSubmit}>
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="form-alert" role="alert">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="birthDate" className="form-label">
|
<label htmlFor="birthDate" className="form-label">
|
||||||
出生日期 <span className="required">*</span>
|
出生日期 <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;
|
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 {
|
.loading-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,47 +1,10 @@
|
|||||||
export interface UserInput {
|
export type {
|
||||||
birthDate: string; // YYYY-MM-DD
|
UserInput,
|
||||||
birthTime: string; // HH:mm
|
FortuneProfile,
|
||||||
gender: 'male' | 'female';
|
CompanyRecommendation,
|
||||||
birthPlace: string;
|
JobRecommendation,
|
||||||
}
|
StageAdvice,
|
||||||
|
FortuneResult,
|
||||||
export interface FortuneProfile {
|
FortuneAnalyzeMeta,
|
||||||
wuxing: 'wood' | 'fire' | 'earth' | 'metal' | 'water';
|
FortuneAnalyzeResponse,
|
||||||
wuxingLabel: string;
|
} from '../../shared/types';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,5 +17,5 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "shared/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,12 @@ import react from '@vitejs/plugin-react';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user