diff --git a/app/jobs/weight-settings.tsx b/app/jobs/weight-settings.tsx index b82660d..1305610 100644 --- a/app/jobs/weight-settings.tsx +++ b/app/jobs/weight-settings.tsx @@ -1,11 +1,13 @@ /** - * 评分权重设置页:调整九大评价维度权重,支持套用预设模板。 + * 评分权重设置页:调整评价维度权重,支持套用预设模板。 * 数据通过 useWeightSettings 获取,页面只负责交互与渲染。 + * 维度集合、模板、权重上下限均以服务端下发为准(不再固定「九大维度」)。 */ import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { WEIGHT_TOTAL } from '../../src/constants'; import { useWeightSettings } from '../../src/hooks'; import {colors, spacing} from '../../src/theme'; @@ -40,7 +42,8 @@ export default function WeightSettingsScreen() { save, } = useWeightSettings(); - const isValid = totalWeight === 100; + // 后端强校验权重总和必须等于 100,不等直接 400,故此处同步置灰保存按钮 + const isValid = totalWeight === WEIGHT_TOTAL; // 错误态 if (error) { diff --git a/src/__tests__/backend-bridge-remaining.test.ts b/src/__tests__/backend-bridge-remaining.test.ts index 521b01c..a44df14 100644 --- a/src/__tests__/backend-bridge-remaining.test.ts +++ b/src/__tests__/backend-bridge-remaining.test.ts @@ -146,7 +146,7 @@ const FUNCS: Entry[] = [ [fetchBadges, [], '/user/badges', true], [fetchOfferCompareData, [undefined], '/offers/compare', true], [fetchPendingOfferCount, [], '/offers/pending/count', true], - [fetchWeightSettings, [], '/jobs/weight-settings', true], + [fetchWeightSettings, [], '/profile/me/review-weights', false], [fetchSalaryRecords, [], '/salary/records', true], [fetchAIInterviewData, [], '/interview/config', true], [fetchHREvaluation, [], '/evaluation/hr', true], @@ -196,6 +196,8 @@ const REAL_FUNCS: Array<[(...a: any[]) => Promise, any[], string]> = [ [fetchInterviewScore, ['1'], '/interviews/1/score'], [fetchInterviewScoreDraft, ['1'], '/interviews/1/score'], [fetchOfferCompareData, [undefined], '/offers/compare'], + // 权重设置已接真实后端:并行打「我的权重」与「模板列表」两个端点 + [fetchWeightSettings, [], '/profile/me/review-weights'], ]; describe('后端桥接 - 真实态路径断言(已对齐函数)', () => { @@ -220,8 +222,8 @@ describe('后端桥接 - 真实态路径断言(已对齐函数)', () => { (global as any).fetch = originalFetch; }); - it('已对齐函数数量正确(5 个)', () => { - expect(REAL_FUNCS.length).toBe(5); + it('已对齐函数数量正确(6 个)', () => { + expect(REAL_FUNCS.length).toBe(6); }); REAL_FUNCS.forEach(([fn, args, path], i) => { @@ -229,10 +231,19 @@ describe('后端桥接 - 真实态路径断言(已对齐函数)', () => { await fn(...args); const calls = ((global as any).fetch as jest.Mock).mock.calls; expect(calls.length).toBeGreaterThan(0); - const calledUrl: string = calls[calls.length - 1][0]; - expect(calledUrl).toContain(path); + // 单个函数可能并行打多个端点(如 fetchWeightSettings), + // 故断言「本次调用的 URL 里存在该路径」,而不是只看最后一个请求。 + const urls = calls.map((c) => String(c[0])); + expect(urls.some((u) => u.includes(path))).toBe(true); }); }); + + it('fetchWeightSettings 同时拉取权重与模板列表', async () => { + await fetchWeightSettings(); + const urls = ((global as any).fetch as jest.Mock).mock.calls.map((c) => String(c[0])); + expect(urls.some((u) => u.includes('/profile/me/review-weights'))).toBe(true); + expect(urls.some((u) => u.includes('/review-weight-templates'))).toBe(true); + }); }); describe('后端桥接 - mock 态冒烟(无回归)', () => { diff --git a/src/__tests__/weight-settings.test.ts b/src/__tests__/weight-settings.test.ts new file mode 100644 index 0000000..89c4066 --- /dev/null +++ b/src/__tests__/weight-settings.test.ts @@ -0,0 +1,250 @@ +/** + * 评分权重设置 ↔ 后端契约测试 + * + * 锁住三件容易回归的事: + * 1. 后端 configuredWeights(英文 token 对象)→ 前端维度数组的映射:顺序、中文名、未登记 token 兜底; + * 2. 保存时「套用模板」与「自定义权重」两种**互斥**写入模式的 body 选择 + * (实网实测:同时提交 templateCode 与 weights → 400 code=1001); + * 3. 模板权重的键是 token,不是中文展示名(改回中文名会让套用模板静默失效)。 + */ +jest.mock('expo-constants', () => ({ expoConfig: { extra: {} } })); + +jest.mock('../lib/token-store', () => { + let mem: { accessToken: string; refreshToken: string; expiresAt?: number } | null = null; + return { + getAccessToken: jest.fn(() => mem?.accessToken ?? null), + getTokens: jest.fn(() => mem), + setTokens: jest.fn((a: string, r: string, e?: number) => { + mem = { accessToken: a, refreshToken: r, expiresAt: e }; + }), + clearTokens: jest.fn(() => { + mem = null; + }), + hydrateTokens: jest.fn(async () => undefined), + }; +}); + +jest.mock('../lib/storage', () => { + const m = new Map(); + return { + storageGet: jest.fn(async (k: string) => m.get(k) ?? null), + storageSet: jest.fn(async (k: string, v: string) => { + m.set(k, v); + }), + storageRemove: jest.fn(async (k: string) => { + m.delete(k); + }), + }; +}); + +import { setMockMode } from '../api/http'; +import { fetchWeightSettings, matchWeightTemplate, saveWeightSettings } from '../api/api'; +import type { WeightDimension, WeightTemplate } from '../types'; + +function jsonResponse(data: unknown): unknown { + const envelope = { code: 200, message: 'ok', data, requestId: 'r', timestamp: '' }; + return { + ok: true, + status: 200, + headers: { get: () => 'x' }, + text: async () => JSON.stringify(envelope), + json: async () => envelope, + }; +} + +/** 后端实际下发的八个维度(顺序刻意打乱,验证前端不依赖对象键序) */ +const BACKEND_WEIGHTS = { + JD_CLARITY: 12.5, + BENEFITS: 12.5, + SALARY_TRANSPARENCY: 12.5, + AUTHENTICITY: 12.5, + GROWTH: 12.5, + PROMOTION: 12.5, + TEAM_ATMOSPHERE: 12.5, + WORK_LIFE_BALANCE: 12.5, +}; + +const BACKEND_TEMPLATES = [ + { + code: 'BALANCED', + name: '均衡权重', + description: '八个维度平均分配', + weights: BACKEND_WEIGHTS, + }, + { + code: 'SALARY_FIRST', + name: '薪资优先', + description: '重点关注薪资透明度与基础保障', + weights: { + BENEFITS: 20, WORK_LIFE_BALANCE: 5, GROWTH: 10, TEAM_ATMOSPHERE: 5, + PROMOTION: 5, AUTHENTICITY: 15, JD_CLARITY: 10, SALARY_TRANSPARENCY: 30, + }, + }, +]; + +function mockFetchByPath(map: Record): jest.Mock { + const fn = jest.fn(async (url: string) => { + const hit = Object.keys(map).find((p) => String(url).includes(p)); + if (!hit) throw new Error(`未预期的请求: ${url}`); + return jsonResponse(map[hit]); + }); + (global as any).fetch = fn; + return fn; +} + +afterEach(() => { + setMockMode(true); +}); + +describe('fetchWeightSettings:后端 token 对象 → 维度数组', () => { + beforeEach(() => { + setMockMode(false); + mockFetchByPath({ + '/profile/me/review-weights': { + source: 'SYSTEM_DEFAULT', + configuredWeights: BACKEND_WEIGHTS, + }, + '/review-weight-templates': BACKEND_TEMPLATES, + }); + }); + + test('按 constants 的展示顺序输出,且映射出中文名', async () => { + const settings = await fetchWeightSettings(); + expect(settings.dimensions.map((d) => d.id)).toEqual([ + 'SALARY_TRANSPARENCY', + 'GROWTH', + 'BENEFITS', + 'WORK_LIFE_BALANCE', + 'PROMOTION', + 'TEAM_ATMOSPHERE', + 'AUTHENTICITY', + 'JD_CLARITY', + ]); + expect(settings.dimensions[0].name).toBe('薪资透明度'); + expect(settings.dimensions.map((d) => d.weight)).toEqual(Array(8).fill(12.5)); + expect(settings.source).toBe('SYSTEM_DEFAULT'); + }); + + test('模板 code → id,权重键保持 token', async () => { + const settings = await fetchWeightSettings(); + expect(settings.templates.map((t) => t.id)).toEqual(['BALANCED', 'SALARY_FIRST']); + expect(settings.templates[1].weights.SALARY_TRANSPARENCY).toBe(30); + expect(settings.templates[1].weights['薪资透明度']).toBeUndefined(); + }); + + test('未登记的新维度追加在末尾并原样显示 token(不吞数据)', async () => { + mockFetchByPath({ + '/profile/me/review-weights': { + source: 'CUSTOM', + configuredWeights: { ...BACKEND_WEIGHTS, NEW_DIMENSION_X: 3 }, + }, + '/review-weight-templates': [], + }); + const settings = await fetchWeightSettings(); + const last = settings.dimensions[settings.dimensions.length - 1]; + expect(last.id).toBe('NEW_DIMENSION_X'); + expect(last.name).toBe('NEW_DIMENSION_X'); + expect(last.weight).toBe(3); + }); + + test('source=TEMPLATE 时回传 templateCode 供展示', async () => { + mockFetchByPath({ + '/profile/me/review-weights': { + source: 'TEMPLATE', + templateCode: 'SALARY_FIRST', + configuredWeights: BACKEND_TEMPLATES[1].weights, + }, + '/review-weight-templates': BACKEND_TEMPLATES, + }); + const settings = await fetchWeightSettings(); + expect(settings.source).toBe('TEMPLATE'); + expect(settings.templateCode).toBe('SALARY_FIRST'); + }); +}); + +describe('matchWeightTemplate:决定写入模式', () => { + const templates: WeightTemplate[] = [ + { id: 'BALANCED', name: '均衡权重', description: '', weights: BACKEND_WEIGHTS }, + { + id: 'SALARY_FIRST', + name: '薪资优先', + description: '', + weights: BACKEND_TEMPLATES[1].weights, + }, + ]; + const toDims = (weights: Record): WeightDimension[] => + Object.entries(weights).map(([id, weight]) => ({ id, name: id, weight })); + + test('与模板完全一致 → 返回该模板 code', () => { + expect(matchWeightTemplate(toDims(BACKEND_WEIGHTS), templates)).toBe('BALANCED'); + }); + + test('有一项不同 → null(走自定义模式)', () => { + const tweaked = { ...BACKEND_WEIGHTS, GROWTH: 13.5, BENEFITS: 11.5 }; + expect(matchWeightTemplate(toDims(tweaked), templates)).toBeNull(); + }); + + test('维度数量不同 → null(避免把残缺配置误判成模板)', () => { + const partial = { ...BACKEND_WEIGHTS } as Record; + delete partial.GROWTH; + expect(matchWeightTemplate(toDims(partial), templates)).toBeNull(); + }); +}); + +describe('saveWeightSettings:两种互斥写入模式', () => { + const dims: WeightDimension[] = Object.entries(BACKEND_WEIGHTS).map(([id, weight]) => ({ + id, + name: id, + weight, + })); + + beforeEach(() => { + setMockMode(false); + }); + + test('传入 templateCode → body 只有 templateCode,不带 weights', async () => { + const fn = mockFetchByPath({ + '/profile/me/review-weights': { + source: 'TEMPLATE', + templateCode: 'BALANCED', + configuredWeights: BACKEND_WEIGHTS, + }, + }); + const saved = await saveWeightSettings(dims, 'BALANCED'); + const [url, opts] = fn.mock.calls[0]; + expect(String(url)).toContain('/profile/me/review-weights'); + expect(opts.method).toBe('PUT'); + // 同时传 templateCode 与 weights 会被后端拒(400 code=1001) + expect(JSON.parse(opts.body)).toEqual({ templateCode: 'BALANCED' }); + expect(saved).toHaveLength(8); + }); + + test('不传 templateCode → body 只有 weights(八个 token 全给)', async () => { + const fn = mockFetchByPath({ + '/profile/me/review-weights': { source: 'CUSTOM', configuredWeights: BACKEND_WEIGHTS }, + }); + await saveWeightSettings(dims); + const opts = fn.mock.calls[0][1]; + const body = JSON.parse(opts.body); + expect(Object.keys(body)).toEqual(['weights']); + expect(Object.keys(body.weights)).toHaveLength(8); + expect(body.weights.SALARY_TRANSPARENCY).toBe(12.5); + }); +}); + +describe('mock 态不触网', () => { + beforeEach(() => setMockMode(true)); + + test('返回与真实同构的八维 + 四个模板,且不与常量同引用', async () => { + const fn = jest.fn(); + (global as any).fetch = fn; + const settings = await fetchWeightSettings(); + expect(fn).not.toHaveBeenCalled(); + expect(settings.dimensions).toHaveLength(8); + expect(settings.templates).toHaveLength(4); + // 改动返回值不应污染后续调用 + settings.dimensions[0].weight = 999; + const again = await fetchWeightSettings(); + expect(again.dimensions[0].weight).toBe(12.5); + }); +}); diff --git a/src/api/api.ts b/src/api/api.ts index b2b1e5c..91422f1 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -25,6 +25,8 @@ import type { BackendAssessmentType, BackendJobItem, BackendJobReviewSummary, + BackendReviewWeight, + BackendWeightTemplate, AITool, Article, Badge, @@ -192,6 +194,7 @@ import type { ApplicationDetail, ApplicationItem, ApplicationMatch, + MatchRule, ApplicationStatus, ApplicationTimelineEvent, BackendApplicationTimelineEvent, @@ -227,6 +230,9 @@ import { SALARY_RANGES, SOCIAL_SECURITY_CITY_PROFILES, TOKEN_TTL_MS, + WEIGHT_DIMENSION_LABEL, + WEIGHT_DIMENSION_ORDER, + WEIGHT_REVERSED_CODES, } from '../constants'; import { buildResumeExportHtml, isValidPhone, isValidSmsCode } from '../utils'; import { colors } from '../theme'; @@ -2250,6 +2256,37 @@ export async function submitHrSuitabilityScore(id: string, score: number): Promi }); } +/** + * 获取学生匹配阈值(未配置返回默认 60) + * @method GET + * @url /profile/me/match-rule + * @returns Promise + */ +export async function fetchMatchRule(): Promise { + if (isMockMode()) { + return mockRequest({ studentUserId: 0, requiredHrScore: 60 }); + } + return request('/profile/me/match-rule', { method: 'GET', auth: true }); +} + +/** + * 更新学生匹配阈值(requiredHrScore 0-100;首次更新创建配置) + * @method PUT + * @url /profile/me/match-rule + * @param requiredHrScore 要求企业 HR 适配分达到的最低分 + * @returns Promise + */ +export async function updateMatchRule(requiredHrScore: number): Promise { + if (isMockMode()) { + return mockRequest({ studentUserId: 0, requiredHrScore }); + } + return request('/profile/me/match-rule', { + method: 'PUT', + auth: true, + body: { requiredHrScore }, + }); +} + /** * 撤回投递 * @method POST @@ -3711,101 +3748,172 @@ export async function fetchPendingOfferCount(): Promise { /* ===================== 评分权重设置 ===================== */ -/** 默认权重维度(9 个,总和 100%) */ -const MOCK_WEIGHT_DIMENSIONS: WeightDimension[] = [ - { id: 'salary', name: '薪资竞争力', weight: 20 }, - { id: 'growth', name: '发展空间', weight: 15 }, - { id: 'atmosphere', name: '工作氛围', weight: 12 }, - { id: 'benefit', name: '福利待遇', weight: 12 }, - { id: 'team', name: '团队水平', weight: 10 }, - { id: 'culture', name: '企业文化', weight: 10 }, - { id: 'intensity', name: '工作强度', weight: 8, isReversed: true }, - { id: 'location', name: '地理位置', weight: 8 }, - { id: 'authenticity', name: '岗位真实性', weight: 5 }, -]; +/* + * 已接通真实后端(2026-09-30 实网验证): + * 读:GET /profile/me/review-weights → configuredWeights(八个英文 token → 权重) + * GET /review-weight-templates → 4 个预设模板(code/name/description/weights) + * 写:PUT /profile/me/review-weights,**两种互斥模式**: + * · 套用模板:{ templateCode } + * · 自定义: { weights }(八个维度全给、总和 100) + * 同时传两者 / 未知 templateCode / 缺维度 / 总和 ≠ 100 → 一律 400 code=1001。 + * 详见《联调问题清单-致后端》§1.12(接口文档里的 body 示例是错的)。 + * + * mock 形态与真实同构(同样八维 token + 同 4 个模板),避免两套口径分叉。 + */ + +/** 八个维度的默认均衡权重(12.5 × 8 = 100) */ +const MOCK_WEIGHT_DIMENSIONS: WeightDimension[] = WEIGHT_DIMENSION_ORDER.map((code) => ({ + id: code, + name: WEIGHT_DIMENSION_LABEL[code] ?? code, + weight: 12.5, + ...(WEIGHT_REVERSED_CODES.includes(code) ? { isReversed: true } : {}), +})); -/** 预设权重模板(3 个,各维度权重按维度名称匹配) */ +/** 预设权重模板(与后端 /review-weight-templates 的四个模板一致) */ const MOCK_WEIGHT_TEMPLATES: WeightTemplate[] = [ { - id: 'salary-first', + id: 'BALANCED', + name: '均衡权重', + description: '八个维度平均分配', + weights: { + BENEFITS: 12.5, WORK_LIFE_BALANCE: 12.5, GROWTH: 12.5, TEAM_ATMOSPHERE: 12.5, + PROMOTION: 12.5, AUTHENTICITY: 12.5, JD_CLARITY: 12.5, SALARY_TRANSPARENCY: 12.5, + }, + }, + { + id: 'SALARY_FIRST', name: '薪资优先', - description: '薪资30% · 发展15%', + description: '重点关注薪资透明度与基础保障', weights: { - 薪资竞争力: 30, - 发展空间: 15, - 工作氛围: 10, - 福利待遇: 12, - 团队水平: 10, - 企业文化: 8, - 工作强度: 5, - 地理位置: 5, - 岗位真实性: 5, + BENEFITS: 20, WORK_LIFE_BALANCE: 5, GROWTH: 10, TEAM_ATMOSPHERE: 5, + PROMOTION: 5, AUTHENTICITY: 15, JD_CLARITY: 10, SALARY_TRANSPARENCY: 30, }, }, { - id: 'growth-first', + id: 'GROWTH_FIRST', name: '成长优先', - description: '发展30% · 团队15%', + description: '重点关注成长空间与晋升机制', weights: { - 薪资竞争力: 12, - 发展空间: 30, - 工作氛围: 10, - 福利待遇: 10, - 团队水平: 15, - 企业文化: 8, - 工作强度: 5, - 地理位置: 5, - 岗位真实性: 5, + BENEFITS: 5, WORK_LIFE_BALANCE: 5, GROWTH: 30, TEAM_ATMOSPHERE: 10, + PROMOTION: 20, AUTHENTICITY: 10, JD_CLARITY: 15, SALARY_TRANSPARENCY: 5, }, }, { - id: 'stable-first', + id: 'STABILITY_FIRST', name: '稳定优先', - description: '福利25%', + description: '重点关注工作生活平衡与基础保障', weights: { - 薪资竞争力: 12, - 发展空间: 12, - 工作氛围: 12, - 福利待遇: 25, - 团队水平: 8, - 企业文化: 10, - 工作强度: 6, - 地理位置: 10, - 岗位真实性: 5, + BENEFITS: 25, WORK_LIFE_BALANCE: 30, GROWTH: 5, TEAM_ATMOSPHERE: 5, + PROMOTION: 5, AUTHENTICITY: 10, JD_CLARITY: 5, SALARY_TRANSPARENCY: 15, }, }, ]; /** - * 获取评分权重设置(含维度 + 预设模板) + * 后端 `configuredWeights` / 模板 `weights` → 维度数组。 + * + * 顺序以 WEIGHT_DIMENSION_ORDER 为准(后端返回的是对象,键序不可依赖); + * 未登记的 token 追加在末尾并原样显示 token 本身,不静默吞掉后端新增的维度。 + */ +function toWeightDimensions(weights: Record | undefined): WeightDimension[] { + const map = weights ?? {}; + const codes = Object.keys(map); + const ordered = [ + ...WEIGHT_DIMENSION_ORDER.filter((code) => codes.includes(code)), + ...codes.filter((code) => !WEIGHT_DIMENSION_ORDER.includes(code)), + ]; + return ordered.map((code) => ({ + id: code, + name: WEIGHT_DIMENSION_LABEL[code] ?? code, + weight: map[code], + ...(WEIGHT_REVERSED_CODES.includes(code) ? { isReversed: true } : {}), + })); +} + +/** 后端模板数组 → 前端模板视图模型(`code` → `id`);非数组(异常响应解包为空对象)时退化为空列表 */ +function toWeightTemplates(list: BackendWeightTemplate[] | undefined): WeightTemplate[] { + if (!Array.isArray(list)) return []; + return list.map((item) => ({ + id: item.code, + name: item.name, + description: item.description, + weights: item.weights ?? {}, + })); +} + +/** + * 当前权重是否与某个模板完全一致,一致则返回该模板 code。 + * + * 保存时必须据此在「套用模板」与「自定义」两种**互斥**写入模式间二选一: + * 后端不允许同时提交 templateCode 与 weights(400 code=1001)。 + * 放在 API 层是因为这是业务规则,页面与 hook 都不该各判一遍。 + */ +export function matchWeightTemplate( + dimensions: WeightDimension[], + templates: WeightTemplate[], +): string | null { + const isSame = (weights: Record): boolean => { + const codes = Object.keys(weights); + if (codes.length !== dimensions.length) return false; + return dimensions.every((d) => { + const w = weights[d.id]; + return typeof w === 'number' && Math.abs(w - d.weight) < 1e-6; + }); + }; + return templates.find((t) => isSame(t.weights))?.id ?? null; +} + +/** + * 获取评分权重设置(当前权重 + 预设模板) * @method GET - * @url /api/jobs/weight-settings - * @returns Promise 权重设置对象 + * @url /profile/me/review-weights 与 /review-weight-templates(并行) + * @returns Promise 权重设置(含来源与当前模板) */ export async function fetchWeightSettings(): Promise { - // 后端未提供 OpenAPI 契约:保持 mock(翻真实开关也不触网),待后端就绪再接真实分支 - // TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/weight-settings`, { method: 'GET' }); return res.json(); - return mockRequest({ - dimensions: MOCK_WEIGHT_DIMENSIONS.map((d) => ({ ...d })), - templates: MOCK_WEIGHT_TEMPLATES, - }); - // 真实路径 - + if (isMockMode()) { + return mockRequest({ + dimensions: MOCK_WEIGHT_DIMENSIONS.map((d) => ({ ...d })), + templates: MOCK_WEIGHT_TEMPLATES.map((t) => ({ ...t, weights: { ...t.weights } })), + source: 'SYSTEM_DEFAULT' as const, + }); + } + const [mine, templates] = await Promise.all([ + request('/profile/me/review-weights', { method: 'GET', auth: true }), + request('/review-weight-templates', { method: 'GET', auth: true }), + ]); + return { + dimensions: toWeightDimensions(mine?.configuredWeights), + templates: toWeightTemplates(templates), + source: mine?.source, + templateCode: mine?.templateCode, + }; } /** - * 保存评分权重设置(各维度权重总和应为 100%) - * @method POST - * @url /api/jobs/weight-settings - * @param dimensions { WeightDimension[] } 完整维度权重列表 - * @returns Promise 保存后的维度列表 + * 保存评分权重设置 + * @method PUT + * @url /profile/me/review-weights + * @param dimensions { WeightDimension[] } 完整维度权重列表(总和须为 100) + * @param templateCode { string } 可选;由 matchWeightTemplate() 判定后传入, + * 传入即按「套用模板」提交,否则按「自定义权重」提交(二者互斥) + * @returns Promise 保存后服务端生效的维度列表 */ export async function saveWeightSettings( dimensions: WeightDimension[], + templateCode?: string, ): Promise { - // @url /weight-settings - - return mockRequest(dimensions.map((d) => ({ ...d }))); + if (isMockMode()) { + return mockRequest(dimensions.map((d) => ({ ...d }))); + } + const body = templateCode + ? { templateCode } + : { weights: Object.fromEntries(dimensions.map((d) => [d.id, d.weight])) }; + const saved = await request('/profile/me/review-weights', { + method: 'PUT', + auth: true, + body, + }); + return toWeightDimensions(saved?.configuredWeights); } /* ===================== 同岗位竞争力排名 ===================== */ diff --git a/src/api/index.ts b/src/api/index.ts index 454d1f0..22c2e50 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -171,6 +171,7 @@ export { fetchUserProfile, fetchUserStats, fetchWeightSettings, + matchWeightTemplate, inviteCandidate, estimateJobQuality, optimizeResume, diff --git a/src/constants/index.ts b/src/constants/index.ts index 45f4d15..f0ce840 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -468,3 +468,47 @@ export const CANDIDATE_FEEDBACK_TAG_SUGGESTIONS: string[] = [ '沟通待改进', '岗位匹配度低', ]; + +/* ===================== 评分权重维度(getMyWeights 契约) ===================== + * 后端 `GET /profile/me/review-weights` 只下发英文维度 token(configuredWeights 的键), + * **不下发中文名**,因此「token → 中文名」只能由前端维护。这里是唯一真相源: + * 映射、排序、模板应用三处都读它,后端新增维度时只需在此补一行。 + * 未登记的 token 会在展示层原样兜底显示 token 本身(不吞掉数据)。 + */ + +/** 维度 token → 中文展示名 */ +export const WEIGHT_DIMENSION_LABEL: Record = { + SALARY_TRANSPARENCY: '薪资透明度', + GROWTH: '成长空间', + BENEFITS: '福利待遇', + WORK_LIFE_BALANCE: '工作生活平衡', + PROMOTION: '晋升机制', + TEAM_ATMOSPHERE: '团队氛围', + AUTHENTICITY: '岗位真实性', + JD_CLARITY: 'JD 清晰度', +}; + +/** 维度的展示顺序(未登记的 token 按后端返回顺序追加在末尾) */ +export const WEIGHT_DIMENSION_ORDER: readonly string[] = [ + 'SALARY_TRANSPARENCY', + 'GROWTH', + 'BENEFITS', + 'WORK_LIFE_BALANCE', + 'PROMOTION', + 'TEAM_ATMOSPHERE', + 'AUTHENTICITY', + 'JD_CLARITY', +]; + +/** + * 反向指标维度(值越低评分越高)。 + * 后端当前八个维度均为正向指标,故为空;保留扩展点,供后续维度接入时登记。 + */ +export const WEIGHT_REVERSED_CODES: readonly string[] = []; + +/** + * 权重总和必须等于该值。 + * 后端强校验:总和不等、缺任一维度、含未知 token 一律 400 `code=1001`, + * 前端必须在提交前置灰保存按钮,不能把校验丢给后端报错。 + */ +export const WEIGHT_TOTAL = 100; diff --git a/src/hooks/useWeightSettings.ts b/src/hooks/useWeightSettings.ts index df95771..75d57b0 100644 --- a/src/hooks/useWeightSettings.ts +++ b/src/hooks/useWeightSettings.ts @@ -3,7 +3,7 @@ * 业务规则(权重总和校验)放在 api 层,本层只负责本地编辑态与远端数据的合并。 */ import { useCallback, useMemo, useState } from 'react'; -import { fetchWeightSettings, saveWeightSettings } from '../api'; +import { fetchWeightSettings, matchWeightTemplate, saveWeightSettings } from '../api'; import type { WeightDimension, WeightSettings } from '../types'; import { useAsyncData } from './useAsyncData'; @@ -63,7 +63,8 @@ export function useWeightSettings(): WeightSettingsState { const base = prev ?? remoteDimensions; return base.map((item) => ({ ...item, - weight: template.weights[item.name] ?? item.weight, + // 模板权重的键是后端维度 token,与维度 id 同源(不是中文展示名) + weight: template.weights[item.id] ?? item.weight, })); }); }, @@ -73,12 +74,17 @@ export function useWeightSettings(): WeightSettingsState { const save = useCallback(async () => { setSaving(true); try { - const saved = await saveWeightSettings(dimensions); + // 与某个预设模板完全一致时按「套用模板」提交,否则按「自定义权重」提交。 + // 后端两种写入模式互斥:同时传 templateCode 与 weights 会 400 code=1001。 + const templateCode = matchWeightTemplate(dimensions, data?.templates ?? []); + const saved = await saveWeightSettings(dimensions, templateCode ?? undefined); + // 用服务端回传的生效值覆盖本地编辑态;不调 refresh(), + // 否则整页会退回 loading 态闪一下(页面不展示 source,无需为它重新取数)。 setLocalDimensions(saved); } finally { setSaving(false); } - }, [dimensions]); + }, [dimensions, data]); return { settings: data ?? null, diff --git a/src/types/index.ts b/src/types/index.ts index 66060d0..e3bc08a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -151,7 +151,14 @@ export type { UserProfile, UserStats, } from './user'; -export type { WeightDimension, WeightSettings, WeightTemplate } from './weight'; +export type { + BackendReviewWeight, + BackendWeightTemplate, + WeightDimension, + WeightSettings, + WeightSource, + WeightTemplate, +} from './weight'; export type { Resume, ResumeAIActionResult, @@ -330,5 +337,6 @@ export type { CompanyApplicationItem, CompanyApplicationQuery, CompanyCandidateDetail, + MatchRule, PageResult, } from './application'; diff --git a/src/types/weight.ts b/src/types/weight.ts index c655e15..e027d72 100644 --- a/src/types/weight.ts +++ b/src/types/weight.ts @@ -2,30 +2,70 @@ * 权重设置相关类型定义 */ -/** 权重模板 */ +/** 权重模板(前端视图模型;由后端 /review-weight-templates 映射而来) */ export interface WeightTemplate { + /** 模板标识,取值同后端 templateCode(如 "SALARY_FIRST") */ id: string; /** 模板名称,如 "薪资优先" */ name: string; /** 模板描述,如 "薪资30% · 发展15%" */ description: string; - /** 模板各维度权重,键为维度名称,如 { "薪资竞争力": 30, "发展空间": 15 } */ + /** + * 模板各维度权重,键为**后端维度 token**(如 { "SALARY_TRANSPARENCY": 30 })。 + * 后端不下发维度中文名,展示名由前端 constants 的 WEIGHT_DIMENSION_LABEL 提供。 + */ weights: Record; } /** 权重维度 */ export interface WeightDimension { + /** 维度标识,取值同后端 token(如 "GROWTH") */ id: string; - /** 维度名称,如 "薪资竞争力" */ + /** 维度展示名,如 "成长空间" */ name: string; - /** 权重百分比,如 20 */ + /** 权重百分比,如 12.5 */ weight: number; /** true 表示反向指标(值越低评分越高,如工作强度) */ isReversed?: boolean; } -/** 权重设置 */ +/** 权重来源:系统默认 / 套用模板 / 用户自定义 */ +export type WeightSource = 'SYSTEM_DEFAULT' | 'TEMPLATE' | 'CUSTOM'; + +/** 权重设置(页面消费的视图模型) */ export interface WeightSettings { dimensions: WeightDimension[]; templates: WeightTemplate[]; -} \ No newline at end of file + /** 当前配置来源;仅真实后端下发时存在 */ + source?: WeightSource; + /** 当前生效的模板标识;source === 'TEMPLATE' 时存在 */ + templateCode?: string; +} + +/* ---------------- 后端原始结构(getMyWeights / saveWeights / getTemplates) ---------------- */ + +/** + * `GET|PUT /profile/me/review-weights` 的 data。 + * + * 注意 PUT 的两种**互斥**写入模式(实网 2026-09-30 实测,见《联调问题清单-致后端》§1.12): + * - 套用模板:body 只传 `{ templateCode }` → 响应 `source: "TEMPLATE"` + * - 自定义: body 只传 `{ weights }`(八个维度全给且总和 100)→ 响应 `source: "CUSTOM"` + * 两者同时传、或只传未知 templateCode、或权重不合法,后端一律 400 `code=1001`。 + */ +export interface BackendReviewWeight { + source: WeightSource; + /** 仅 source === 'TEMPLATE' 时下发 */ + templateCode?: string; + /** 维度 token → 权重;八个维度之和为 100 */ + configuredWeights: Record; + /** 仅在用户保存过之后下发(系统默认态无此字段) */ + updatedAt?: string; +} + +/** `GET /review-weight-templates` 的元素 */ +export interface BackendWeightTemplate { + code: string; + name: string; + description: string; + weights: Record; +}