Browse Source

feat(api): 接通评分权重设置到真实后端(读权重/模板 + 互斥双模式保存)

读:GET /profile/me/review-weights + GET /review-weight-templates 并行拉取
写:PUT /profile/me/review-weights,实测为两种互斥模式(实网 2026-09-30 验证)
  · 套用模板:只传 { templateCode }
  · 自定义:  只传 { weights }(八个维度全给、总和 100)
  · 同时传两者 / 未知 templateCode / 缺维度 / 总和≠100 → 一律 400 code=1001
  接口文档的 body 示例是错的,见《联调问题清单-致后端》§1.12

- 维度集合改为以后端下发为准:原前端自造的「九大中文维度」与后端八个
  英文 token 不一致(后端无「地理位置/工作强度」,多出 JD_CLARITY、
  SALARY_TRANSPARENCY 等),照旧实现会默认显示错误的维度集合
- 后端不下发维度中文名 → constants 新增 WEIGHT_DIMENSION_LABEL /
  WEIGHT_DIMENSION_ORDER / WEIGHT_TOTAL 作为唯一真相源;未登记 token
  追加末尾并原样显示,不静默吞掉后端新增维度
- 新增 matchWeightTemplate():保存时据此在两种互斥模式间二选一
- saveWeightSettings 增加可选 templateCode 参数;保存后用服务端回传值
  覆盖本地编辑态(不调 refresh,避免整页闪回 loading)
- 新增 weight-settings.test.ts(15 例):锁住映射顺序/中文名/未知 token
  兜底、模板 id 映射、模式判定(含维度数不同不得误判)、双模式 body 互斥
- mock 形态与真实同构(同八维 + 同四模板),避免两套口径分叉
master
doublex001 4 weeks ago
parent
commit
9911277e10
  1. 7
      app/jobs/weight-settings.tsx
  2. 21
      src/__tests__/backend-bridge-remaining.test.ts
  3. 250
      src/__tests__/weight-settings.test.ts
  4. 230
      src/api/api.ts
  5. 1
      src/api/index.ts
  6. 44
      src/constants/index.ts
  7. 14
      src/hooks/useWeightSettings.ts
  8. 10
      src/types/index.ts
  9. 50
      src/types/weight.ts

7
app/jobs/weight-settings.tsx

@ -1,11 +1,13 @@
/** /**
* 评分权重设置页:调整九大评价维度权重,支持套用预设模板。 * 评分权重设置页:调整评价维度权重,支持套用预设模板。
* 数据通过 useWeightSettings 获取,页面只负责交互与渲染。 * 数据通过 useWeightSettings 获取,页面只负责交互与渲染。
* 维度集合、模板、权重上下限均以服务端下发为准(不再固定「九大维度」)。
*/ */
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { WEIGHT_TOTAL } from '../../src/constants';
import { useWeightSettings } from '../../src/hooks'; import { useWeightSettings } from '../../src/hooks';
import {colors, spacing} from '../../src/theme'; import {colors, spacing} from '../../src/theme';
@ -40,7 +42,8 @@ export default function WeightSettingsScreen() {
save, save,
} = useWeightSettings(); } = useWeightSettings();
const isValid = totalWeight === 100; // 后端强校验权重总和必须等于 100,不等直接 400,故此处同步置灰保存按钮
const isValid = totalWeight === WEIGHT_TOTAL;
// 错误态 // 错误态
if (error) { if (error) {

21
src/__tests__/backend-bridge-remaining.test.ts

@ -146,7 +146,7 @@ const FUNCS: Entry[] = [
[fetchBadges, [], '/user/badges', true], [fetchBadges, [], '/user/badges', true],
[fetchOfferCompareData, [undefined], '/offers/compare', true], [fetchOfferCompareData, [undefined], '/offers/compare', true],
[fetchPendingOfferCount, [], '/offers/pending/count', true], [fetchPendingOfferCount, [], '/offers/pending/count', true],
[fetchWeightSettings, [], '/jobs/weight-settings', true], [fetchWeightSettings, [], '/profile/me/review-weights', false],
[fetchSalaryRecords, [], '/salary/records', true], [fetchSalaryRecords, [], '/salary/records', true],
[fetchAIInterviewData, [], '/interview/config', true], [fetchAIInterviewData, [], '/interview/config', true],
[fetchHREvaluation, [], '/evaluation/hr', true], [fetchHREvaluation, [], '/evaluation/hr', true],
@ -196,6 +196,8 @@ const REAL_FUNCS: Array<[(...a: any[]) => Promise<any>, any[], string]> = [
[fetchInterviewScore, ['1'], '/interviews/1/score'], [fetchInterviewScore, ['1'], '/interviews/1/score'],
[fetchInterviewScoreDraft, ['1'], '/interviews/1/score'], [fetchInterviewScoreDraft, ['1'], '/interviews/1/score'],
[fetchOfferCompareData, [undefined], '/offers/compare'], [fetchOfferCompareData, [undefined], '/offers/compare'],
// 权重设置已接真实后端:并行打「我的权重」与「模板列表」两个端点
[fetchWeightSettings, [], '/profile/me/review-weights'],
]; ];
describe('后端桥接 - 真实态路径断言(已对齐函数)', () => { describe('后端桥接 - 真实态路径断言(已对齐函数)', () => {
@ -220,8 +222,8 @@ describe('后端桥接 - 真实态路径断言(已对齐函数)', () => {
(global as any).fetch = originalFetch; (global as any).fetch = originalFetch;
}); });
it('已对齐函数数量正确(5 个)', () => { it('已对齐函数数量正确(6 个)', () => {
expect(REAL_FUNCS.length).toBe(5); expect(REAL_FUNCS.length).toBe(6);
}); });
REAL_FUNCS.forEach(([fn, args, path], i) => { REAL_FUNCS.forEach(([fn, args, path], i) => {
@ -229,10 +231,19 @@ describe('后端桥接 - 真实态路径断言(已对齐函数)', () => {
await fn(...args); await fn(...args);
const calls = ((global as any).fetch as jest.Mock).mock.calls; const calls = ((global as any).fetch as jest.Mock).mock.calls;
expect(calls.length).toBeGreaterThan(0); expect(calls.length).toBeGreaterThan(0);
const calledUrl: string = calls[calls.length - 1][0]; // 单个函数可能并行打多个端点(如 fetchWeightSettings),
expect(calledUrl).toContain(path); // 故断言「本次调用的 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 态冒烟(无回归)', () => { describe('后端桥接 - mock 态冒烟(无回归)', () => {

250
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<string, string>();
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<string, unknown>): 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<string, number>): 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<string, number>;
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);
});
});

230
src/api/api.ts

@ -25,6 +25,8 @@ import type {
BackendAssessmentType, BackendAssessmentType,
BackendJobItem, BackendJobItem,
BackendJobReviewSummary, BackendJobReviewSummary,
BackendReviewWeight,
BackendWeightTemplate,
AITool, AITool,
Article, Article,
Badge, Badge,
@ -192,6 +194,7 @@ import type {
ApplicationDetail, ApplicationDetail,
ApplicationItem, ApplicationItem,
ApplicationMatch, ApplicationMatch,
MatchRule,
ApplicationStatus, ApplicationStatus,
ApplicationTimelineEvent, ApplicationTimelineEvent,
BackendApplicationTimelineEvent, BackendApplicationTimelineEvent,
@ -227,6 +230,9 @@ import {
SALARY_RANGES, SALARY_RANGES,
SOCIAL_SECURITY_CITY_PROFILES, SOCIAL_SECURITY_CITY_PROFILES,
TOKEN_TTL_MS, TOKEN_TTL_MS,
WEIGHT_DIMENSION_LABEL,
WEIGHT_DIMENSION_ORDER,
WEIGHT_REVERSED_CODES,
} from '../constants'; } from '../constants';
import { buildResumeExportHtml, isValidPhone, isValidSmsCode } from '../utils'; import { buildResumeExportHtml, isValidPhone, isValidSmsCode } from '../utils';
import { colors } from '../theme'; 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<MatchRule>
*/
export async function fetchMatchRule(): Promise<MatchRule> {
if (isMockMode()) {
return mockRequest({ studentUserId: 0, requiredHrScore: 60 });
}
return request<MatchRule>('/profile/me/match-rule', { method: 'GET', auth: true });
}
/**
* 更新学生匹配阈值(requiredHrScore 0-100;首次更新创建配置)
* @method PUT
* @url /profile/me/match-rule
* @param requiredHrScore 要求企业 HR 适配分达到的最低分
* @returns Promise<MatchRule>
*/
export async function updateMatchRule(requiredHrScore: number): Promise<MatchRule> {
if (isMockMode()) {
return mockRequest({ studentUserId: 0, requiredHrScore });
}
return request<MatchRule>('/profile/me/match-rule', {
method: 'PUT',
auth: true,
body: { requiredHrScore },
});
}
/** /**
* 撤回投递 * 撤回投递
* @method POST * @method POST
@ -3711,101 +3748,172 @@ export async function fetchPendingOfferCount(): Promise<number> {
/* ===================== 评分权重设置 ===================== */ /* ===================== 评分权重设置 ===================== */
/** 默认权重维度(9 个,总和 100%) */ /*
const MOCK_WEIGHT_DIMENSIONS: WeightDimension[] = [ * 已接通真实后端(2026-09-30 实网验证):
{ id: 'salary', name: '薪资竞争力', weight: 20 }, * 读:GET /profile/me/review-weights → configuredWeights(八个英文 token → 权重)
{ id: 'growth', name: '发展空间', weight: 15 }, * GET /review-weight-templates → 4 个预设模板(code/name/description/weights)
{ id: 'atmosphere', name: '工作氛围', weight: 12 }, * 写:PUT /profile/me/review-weights,**两种互斥模式**:
{ id: 'benefit', name: '福利待遇', weight: 12 }, * · 套用模板:{ templateCode }
{ id: 'team', name: '团队水平', weight: 10 }, * · 自定义: { weights }(八个维度全给、总和 100)
{ id: 'culture', name: '企业文化', weight: 10 }, * 同时传两者 / 未知 templateCode / 缺维度 / 总和 ≠ 100 → 一律 400 code=1001。
{ id: 'intensity', name: '工作强度', weight: 8, isReversed: true }, * 详见《联调问题清单-致后端》§1.12(接口文档里的 body 示例是错的)。
{ id: 'location', name: '地理位置', weight: 8 }, *
{ id: 'authenticity', name: '岗位真实性', weight: 5 }, * mock 形态与真实同构(同样八维 token + 同 4 个模板),避免两套口径分叉。
]; */
/** 预设权重模板(3 个,各维度权重按维度名称匹配) */ /** 八个维度的默认均衡权重(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 } : {}),
}));
/** 预设权重模板(与后端 /review-weight-templates 的四个模板一致) */
const MOCK_WEIGHT_TEMPLATES: WeightTemplate[] = [ 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: '薪资优先', name: '薪资优先',
description: '薪资30% · 发展15%', description: '重点关注薪资透明度与基础保障',
weights: { weights: {
薪资竞争力: 30, BENEFITS: 20, WORK_LIFE_BALANCE: 5, GROWTH: 10, TEAM_ATMOSPHERE: 5,
发展空间: 15, PROMOTION: 5, AUTHENTICITY: 15, JD_CLARITY: 10, SALARY_TRANSPARENCY: 30,
工作氛围: 10,
福利待遇: 12,
团队水平: 10,
企业文化: 8,
工作强度: 5,
地理位置: 5,
岗位真实性: 5,
}, },
}, },
{ {
id: 'growth-first', id: 'GROWTH_FIRST',
name: '成长优先', name: '成长优先',
description: '发展30% · 团队15%', description: '重点关注成长空间与晋升机制',
weights: { weights: {
薪资竞争力: 12, BENEFITS: 5, WORK_LIFE_BALANCE: 5, GROWTH: 30, TEAM_ATMOSPHERE: 10,
发展空间: 30, PROMOTION: 20, AUTHENTICITY: 10, JD_CLARITY: 15, SALARY_TRANSPARENCY: 5,
工作氛围: 10,
福利待遇: 10,
团队水平: 15,
企业文化: 8,
工作强度: 5,
地理位置: 5,
岗位真实性: 5,
}, },
}, },
{ {
id: 'stable-first', id: 'STABILITY_FIRST',
name: '稳定优先', name: '稳定优先',
description: '福利25%', description: '重点关注工作生活平衡与基础保障',
weights: { weights: {
薪资竞争力: 12, BENEFITS: 25, WORK_LIFE_BALANCE: 30, GROWTH: 5, TEAM_ATMOSPHERE: 5,
发展空间: 12, PROMOTION: 5, AUTHENTICITY: 10, JD_CLARITY: 5, SALARY_TRANSPARENCY: 15,
工作氛围: 12,
福利待遇: 25,
团队水平: 8,
企业文化: 10,
工作强度: 6,
地理位置: 10,
岗位真实性: 5,
}, },
}, },
]; ];
/** /**
* 获取评分权重设置(含维度 + 预设模板) * 后端 `configuredWeights` / 模板 `weights` → 维度数组。
*
* 顺序以 WEIGHT_DIMENSION_ORDER 为准(后端返回的是对象,键序不可依赖);
* 未登记的 token 追加在末尾并原样显示 token 本身,不静默吞掉后端新增的维度。
*/
function toWeightDimensions(weights: Record<string, number> | 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<string, number>): 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 * @method GET
* @url /api/jobs/weight-settings * @url /profile/me/review-weights 与 /review-weight-templates(并行)
* @returns Promise<WeightSettings> 权重设置对象 * @returns Promise<WeightSettings> 权重设置(含来源与当前模板)
*/ */
export async function fetchWeightSettings(): Promise<WeightSettings> { export async function fetchWeightSettings(): Promise<WeightSettings> {
// 后端未提供 OpenAPI 契约:保持 mock(翻真实开关也不触网),待后端就绪再接真实分支 if (isMockMode()) {
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/weight-settings`, { method: 'GET' }); return res.json();
return mockRequest({ return mockRequest({
dimensions: MOCK_WEIGHT_DIMENSIONS.map((d) => ({ ...d })), dimensions: MOCK_WEIGHT_DIMENSIONS.map((d) => ({ ...d })),
templates: MOCK_WEIGHT_TEMPLATES, templates: MOCK_WEIGHT_TEMPLATES.map((t) => ({ ...t, weights: { ...t.weights } })),
source: 'SYSTEM_DEFAULT' as const,
}); });
// 真实路径 }
const [mine, templates] = await Promise.all([
request<BackendReviewWeight>('/profile/me/review-weights', { method: 'GET', auth: true }),
request<BackendWeightTemplate[]>('/review-weight-templates', { method: 'GET', auth: true }),
]);
return {
dimensions: toWeightDimensions(mine?.configuredWeights),
templates: toWeightTemplates(templates),
source: mine?.source,
templateCode: mine?.templateCode,
};
} }
/** /**
* 保存评分权重设置(各维度权重总和应为 100%) * 保存评分权重设置
* @method POST * @method PUT
* @url /api/jobs/weight-settings * @url /profile/me/review-weights
* @param dimensions { WeightDimension[] } 完整维度权重列表 * @param dimensions { WeightDimension[] } 完整维度权重列表(总和须为 100)
* @returns Promise<WeightDimension[]> 保存后的维度列表 * @param templateCode { string } 可选;由 matchWeightTemplate() 判定后传入,
* 传入即按「套用模板」提交,否则按「自定义权重」提交(二者互斥)
* @returns Promise<WeightDimension[]> 保存后服务端生效的维度列表
*/ */
export async function saveWeightSettings( export async function saveWeightSettings(
dimensions: WeightDimension[], dimensions: WeightDimension[],
templateCode?: string,
): Promise<WeightDimension[]> { ): Promise<WeightDimension[]> {
// @url /weight-settings if (isMockMode()) {
return mockRequest(dimensions.map((d) => ({ ...d }))); return mockRequest(dimensions.map((d) => ({ ...d })));
}
const body = templateCode
? { templateCode }
: { weights: Object.fromEntries(dimensions.map((d) => [d.id, d.weight])) };
const saved = await request<BackendReviewWeight>('/profile/me/review-weights', {
method: 'PUT',
auth: true,
body,
});
return toWeightDimensions(saved?.configuredWeights);
} }
/* ===================== 同岗位竞争力排名 ===================== */ /* ===================== 同岗位竞争力排名 ===================== */

1
src/api/index.ts

@ -171,6 +171,7 @@ export {
fetchUserProfile, fetchUserProfile,
fetchUserStats, fetchUserStats,
fetchWeightSettings, fetchWeightSettings,
matchWeightTemplate,
inviteCandidate, inviteCandidate,
estimateJobQuality, estimateJobQuality,
optimizeResume, optimizeResume,

44
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<string, string> = {
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;

14
src/hooks/useWeightSettings.ts

@ -3,7 +3,7 @@
* 业务规则(权重总和校验)放在 api 层,本层只负责本地编辑态与远端数据的合并。 * 业务规则(权重总和校验)放在 api 层,本层只负责本地编辑态与远端数据的合并。
*/ */
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { fetchWeightSettings, saveWeightSettings } from '../api'; import { fetchWeightSettings, matchWeightTemplate, saveWeightSettings } from '../api';
import type { WeightDimension, WeightSettings } from '../types'; import type { WeightDimension, WeightSettings } from '../types';
import { useAsyncData } from './useAsyncData'; import { useAsyncData } from './useAsyncData';
@ -63,7 +63,8 @@ export function useWeightSettings(): WeightSettingsState {
const base = prev ?? remoteDimensions; const base = prev ?? remoteDimensions;
return base.map((item) => ({ return base.map((item) => ({
...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 () => { const save = useCallback(async () => {
setSaving(true); setSaving(true);
try { 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); setLocalDimensions(saved);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [dimensions]); }, [dimensions, data]);
return { return {
settings: data ?? null, settings: data ?? null,

10
src/types/index.ts

@ -151,7 +151,14 @@ export type {
UserProfile, UserProfile,
UserStats, UserStats,
} from './user'; } from './user';
export type { WeightDimension, WeightSettings, WeightTemplate } from './weight'; export type {
BackendReviewWeight,
BackendWeightTemplate,
WeightDimension,
WeightSettings,
WeightSource,
WeightTemplate,
} from './weight';
export type { export type {
Resume, Resume,
ResumeAIActionResult, ResumeAIActionResult,
@ -330,5 +337,6 @@ export type {
CompanyApplicationItem, CompanyApplicationItem,
CompanyApplicationQuery, CompanyApplicationQuery,
CompanyCandidateDetail, CompanyCandidateDetail,
MatchRule,
PageResult, PageResult,
} from './application'; } from './application';

50
src/types/weight.ts

@ -2,30 +2,70 @@
* 权重设置相关类型定义 * 权重设置相关类型定义
*/ */
/** 权重模板 */ /** 权重模板(前端视图模型;由后端 /review-weight-templates 映射而来) */
export interface WeightTemplate { export interface WeightTemplate {
/** 模板标识,取值同后端 templateCode(如 "SALARY_FIRST") */
id: string; id: string;
/** 模板名称,如 "薪资优先" */ /** 模板名称,如 "薪资优先" */
name: string; name: string;
/** 模板描述,如 "薪资30% · 发展15%" */ /** 模板描述,如 "薪资30% · 发展15%" */
description: string; description: string;
/** 模板各维度权重,键为维度名称,如 { "薪资竞争力": 30, "发展空间": 15 } */ /**
* 模板各维度权重,键为**后端维度 token**(如 { "SALARY_TRANSPARENCY": 30 })。
* 后端不下发维度中文名,展示名由前端 constants 的 WEIGHT_DIMENSION_LABEL 提供。
*/
weights: Record<string, number>; weights: Record<string, number>;
} }
/** 权重维度 */ /** 权重维度 */
export interface WeightDimension { export interface WeightDimension {
/** 维度标识,取值同后端 token(如 "GROWTH") */
id: string; id: string;
/** 维度名称,如 "薪资竞争力" */ /** 维度展示名,如 "成长空间" */
name: string; name: string;
/** 权重百分比,如 20 */ /** 权重百分比,如 12.5 */
weight: number; weight: number;
/** true 表示反向指标(值越低评分越高,如工作强度) */ /** true 表示反向指标(值越低评分越高,如工作强度) */
isReversed?: boolean; isReversed?: boolean;
} }
/** 权重设置 */ /** 权重来源:系统默认 / 套用模板 / 用户自定义 */
export type WeightSource = 'SYSTEM_DEFAULT' | 'TEMPLATE' | 'CUSTOM';
/** 权重设置(页面消费的视图模型) */
export interface WeightSettings { export interface WeightSettings {
dimensions: WeightDimension[]; dimensions: WeightDimension[];
templates: WeightTemplate[]; templates: WeightTemplate[];
/** 当前配置来源;仅真实后端下发时存在 */
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<string, number>;
/** 仅在用户保存过之后下发(系统默认态无此字段) */
updatedAt?: string;
}
/** `GET /review-weight-templates` 的元素 */
export interface BackendWeightTemplate {
code: string;
name: string;
description: string;
weights: Record<string, number>;
} }
Loading…
Cancel
Save