From 1ae124c2f781a1656d2aa91e71447f2bc862c95a Mon Sep 17 00:00:00 2001
From: doublex001 <16157008+doublex001@user.noreply.gitee.com>
Date: Tue, 29 Sep 2026 17:10:24 +0800
Subject: [PATCH] =?UTF-8?q?feat(jobs):=20=E8=A1=A5=E9=BD=90=E5=B2=97?=
=?UTF-8?q?=E4=BD=8D=E8=AF=84=E4=BB=B7=E5=A2=9E=E5=88=A0=E6=94=B9=EF=BC=88?=
=?UTF-8?q?P0-6=EF=BC=89=E5=85=AB=E7=BB=B4=E8=AF=84=E5=88=86/=E6=A0=87?=
=?UTF-8?q?=E7=AD=BE/=E6=B1=87=E6=80=BB=E4=B8=8E=E7=BC=96=E8=BE=91?=
=?UTF-8?q?=E5=88=A0=E9=99=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- api: 新增 fetchJobReviewDetail / updateJobReview / deleteJobReview / fetchJobReviewSummary,
createJobReview 入参由 content 单字段扩为 { rating, content, tags, dimensionScores },
body 与文档 POST /jobs/{jobId}/reviews 对齐;mock 阶段同一岗位重复发布=覆盖更新(模拟后端唯一约束)
- types: EmployeeReview 增加 rating / dimensionScores / status / canEdit / updatedAt;
新增 JobReviewCreateInput / JobReviewUpdateInput / JobReviewSummary / JobReviewStatus
- constants: 新增 src/constants/review.ts(八维维度 REVIEW_DIMENSIONS、标签候选、评分上限)
- hook: useJobDetail 并行取汇总,新增 editReview / removeReview
- 页面: 岗位详情页评价区加汇总卡(平均分+星级分布+热门标签)、评价卡星级与八维、本人评价的编辑/删除入口;
底部单行输入框改为评价编辑器弹窗(综合评分+分项评分+标签+正文),四态齐全
- 测试: job-api 补 rating/tags/dimensionScores 断言 + 覆盖更新 + 改/删/汇总 5 例(全量 500 例通过)
---
app/jobs/[id].tsx | 749 +++++++++++++++++++++++++++++++---
src/__tests__/job-api.test.ts | 98 ++++-
src/api/api.ts | 221 ++++++++--
src/api/index.ts | 4 +
src/constants/index.ts | 9 +
src/constants/review.ts | 45 ++
src/hooks/useJobDetail.ts | 58 ++-
src/types/index.ts | 4 +
src/types/job.ts | 49 +++
9 files changed, 1132 insertions(+), 105 deletions(-)
create mode 100644 src/constants/review.ts
diff --git a/app/jobs/[id].tsx b/app/jobs/[id].tsx
index 8bcfecd..1d34ece 100644
--- a/app/jobs/[id].tsx
+++ b/app/jobs/[id].tsx
@@ -1,9 +1,12 @@
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
+ KeyboardAvoidingView,
+ Modal,
+ Platform,
Pressable,
ScrollView,
StyleSheet,
@@ -15,10 +18,23 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Circle, Line, Polygon, Text as SvgText } from 'react-native-svg';
import { useFavorites, useJobDetail } from '../../src/hooks';
import { buildJobShareText } from '../../src/api';
+import {
+ REVIEW_DIMENSIONS,
+ REVIEW_RATING_MAX,
+ REVIEW_TAG_SUGGESTIONS,
+} from '../../src/constants';
import { shareText } from '../../src/lib/shareFile';
import {colors, spacing} from '../../src/theme';
import { formatThousands } from '../../src/utils';
-import type { JobDimensionScore } from '../../src/types';
+import type {
+ EmployeeReview,
+ JobDimensionScore,
+ JobReviewCreateInput,
+ JobReviewSummary,
+} from '../../src/types';
+
+/** 星级色:星级图标在候选人详情、岗位评价页均复用 compareOrange,此处沿用同一语义色 */
+const STAR_COLOR = colors.compareOrange;
/* ===================== 雷达图(6 维度,SVG 绘制) ===================== */
@@ -168,15 +184,371 @@ function SectionTitle({
);
}
+/* ===================== 评价:星级 / 汇总 / 卡片 / 编辑器 ===================== */
+
+/**
+ * 星级组件。
+ * 传 `onChange` 时可点击打分(编辑器内用),不传则只读(列表展示用)。
+ * 只读态用实心/描边星区分,避免仅靠颜色传达分数(WCAG 1.4.1)。
+ */
+function Stars({
+ value,
+ size = 14,
+ onChange,
+}: {
+ value: number;
+ size?: number;
+ onChange?: (next: number) => void;
+}) {
+ const readonly = !onChange;
+ return (
+
+ {Array.from({ length: REVIEW_RATING_MAX }, (_, i) => {
+ const starValue = i + 1;
+ const filled = starValue <= value;
+ const star = (
+
+ );
+ if (readonly) {
+ return (
+
+ {star}
+
+ );
+ }
+ return (
+ onChange(starValue)}
+ accessibilityRole="button"
+ accessibilityLabel={`打 ${starValue} 分`}
+ >
+ {star}
+
+ );
+ })}
+
+ );
+}
+
+/**
+ * 评价汇总卡:平均分 + 星级分布 + 热门标签。
+ * 无评价时不渲染(空态由下方「暂无员工评价」承担,避免两个空块叠着)。
+ */
+function ReviewSummaryCard({ summary }: { summary: JobReviewSummary }) {
+ if (summary.total === 0) return null;
+ const maxCount = Math.max(1, ...summary.distribution.map((d) => d.count));
+ return (
+
+
+
+ {summary.averageRating.toFixed(1)}
+
+ {summary.total} 条真实评价
+
+
+ {summary.distribution.map((d) => (
+
+ {d.rating}星
+
+
+
+ {d.count}
+
+ ))}
+
+
+ {summary.topTags.length > 0 ? (
+
+ {summary.topTags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+/**
+ * 单条评价卡。
+ * `canEdit` 为真(本人评价)时展示「编辑 / 删除」;八维评分按行铺开,
+ * 未打分的维度不渲染,避免一屏全是 0 分占位。
+ */
+function ReviewCard({
+ review,
+ onEdit,
+ onDelete,
+}: {
+ review: EmployeeReview;
+ onEdit: () => void;
+ onDelete: () => void;
+}) {
+ const scoredDims = REVIEW_DIMENSIONS.filter(
+ (dim) => (review.dimensionScores?.[dim.key] ?? 0) > 0,
+ );
+ return (
+
+
+ {review.userName}
+ {review.userRole}
+ {review.time}
+
+ {typeof review.rating === 'number' && review.rating > 0 ? (
+
+
+ {review.rating}.0
+
+ ) : null}
+ {review.content}
+ {scoredDims.length > 0 ? (
+
+ {scoredDims.map((dim) => (
+
+ {dim.label}
+ {review.dimensionScores?.[dim.key]}
+
+ ))}
+
+ ) : null}
+ {review.tags.length > 0 ? (
+
+ {review.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ ) : null}
+ {review.canEdit ? (
+
+
+
+ 编辑
+
+
+
+ 删除
+
+
+ ) : null}
+
+ );
+}
+
+/** 编辑器初值:新建时全 0,编辑时用已有评价回填 */
+interface ReviewDraft {
+ rating: number;
+ content: string;
+ tags: string[];
+ dimensionScores: Record;
+}
+
+const EMPTY_DRAFT: ReviewDraft = { rating: 0, content: '', tags: [], dimensionScores: {} };
+
+function draftFromReview(review: EmployeeReview | null): ReviewDraft {
+ if (!review) return EMPTY_DRAFT;
+ return {
+ rating: review.rating ?? 0,
+ content: review.content,
+ tags: [...review.tags],
+ dimensionScores: { ...(review.dimensionScores ?? {}) },
+ };
+}
+
+/**
+ * 评价编辑器(新建 / 编辑共用)。
+ * 后端要求 rating + 八维 + 标签 + 正文一起提交,故不能再沿用底部的单行输入框。
+ * 校验在提交前做:综合评分必填、正文必填。
+ */
+function ReviewEditorModal({
+ visible,
+ initial,
+ submitting,
+ onClose,
+ onSubmit,
+}: {
+ visible: boolean;
+ /** 编辑时传入被编辑的评价;新建传 null */
+ initial: EmployeeReview | null;
+ submitting: boolean;
+ onClose: () => void;
+ onSubmit: (input: JobReviewCreateInput) => void;
+}) {
+ const [draft, setDraft] = useState(EMPTY_DRAFT);
+
+ // 每次打开时按 initial 重置,避免上一次的残留内容带进新一次编辑
+ useEffect(() => {
+ if (visible) setDraft(draftFromReview(initial));
+ }, [visible, initial]);
+
+ const toggleTag = (tag: string) => {
+ setDraft((prev) => ({
+ ...prev,
+ tags: prev.tags.includes(tag)
+ ? prev.tags.filter((t) => t !== tag)
+ : [...prev.tags, tag],
+ }));
+ };
+
+ const setDimension = (key: string, score: number) => {
+ setDraft((prev) => ({
+ ...prev,
+ dimensionScores: { ...prev.dimensionScores, [key]: score },
+ }));
+ };
+
+ const handleSubmit = () => {
+ if (draft.rating <= 0) {
+ Alert.alert('提示', '请先给一个综合评分');
+ return;
+ }
+ if (!draft.content.trim()) {
+ Alert.alert('提示', '请先输入你的评价内容');
+ return;
+ }
+ onSubmit({
+ rating: draft.rating,
+ content: draft.content.trim(),
+ tags: draft.tags,
+ // 未打分的维度补 0,键齐全便于后端按八维落库
+ dimensionScores: REVIEW_DIMENSIONS.reduce>((acc, dim) => {
+ acc[dim.key] = draft.dimensionScores[dim.key] ?? 0;
+ return acc;
+ }, {}),
+ });
+ };
+
+ return (
+
+
+
+
+ {initial ? '编辑评价' : '写评价'}
+
+
+
+
+
+ 综合评分
+
+ setDraft((prev) => ({ ...prev, rating: next }))}
+ />
+
+ {draft.rating > 0 ? `${draft.rating} 分` : '点击星星打分'}
+
+
+
+ 分项评分(选填)
+
+ {REVIEW_DIMENSIONS.map((dim) => (
+
+ {dim.label}
+ setDimension(dim.key, next)}
+ />
+
+ ))}
+
+
+ 标签(选填,可多选)
+
+ {REVIEW_TAG_SUGGESTIONS.map((tag) => {
+ const active = draft.tags.includes(tag);
+ return (
+ toggleTag(tag)}
+ >
+
+ {tag}
+
+
+ );
+ })}
+
+
+ 评价内容
+ setDraft((prev) => ({ ...prev, content: text }))}
+ multiline
+ maxLength={500}
+ />
+ {draft.content.length}/500
+
+
+
+
+ {submitting ? (
+
+ ) : (
+ {initial ? '保存修改' : '发布评价'}
+ )}
+
+
+
+
+
+ );
+}
+
/* ===================== 页面 ===================== */
export default function JobDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
- const { detail, overallScore, salaryData, reviews, loading, error, refresh, publishReview } =
- useJobDetail(id);
- const [comment, setComment] = useState('');
- const [publishing, setPublishing] = useState(false);
+ const {
+ detail,
+ overallScore,
+ salaryData,
+ reviews,
+ reviewSummary,
+ loading,
+ error,
+ refresh,
+ publishReview,
+ editReview,
+ removeReview,
+ } = useJobDetail(id);
+ const [editorOpen, setEditorOpen] = useState(false);
+ /** 正在编辑的评价;null = 新建 */
+ const [editing, setEditing] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ /**
+ * 本人已发布的评价。后端约束「同一学生对同一岗位仅一条」,故取第一条 canEdit 即可。
+ * 有它时底部按钮变「编辑我的评价」,避免用户以为能发第二条。
+ */
+ const myReview = reviews.find((r) => r.canEdit) ?? null;
// 收藏态:用已收藏 id 集合判断是否已收藏(仅 STUDENT 可收藏)
const { favoriteIds, toggle: toggleFavorite } = useFavorites();
@@ -195,24 +567,52 @@ export default function JobDetailScreen() {
if (!outcome.ok) Alert.alert('无法分享', outcome.message);
};
- // 发布评价:空内容拦截,成功后清空输入框(列表由 hook 自动刷新)
- const handlePublish = async () => {
- const text = comment.trim();
- if (!text) {
- Alert.alert('提示', '请先输入你的评价内容');
- return;
- }
- setPublishing(true);
+ const openCreate = () => {
+ setEditing(null);
+ setEditorOpen(true);
+ };
+
+ const openEdit = (review: EmployeeReview) => {
+ setEditing(review);
+ setEditorOpen(true);
+ };
+
+ // 新建 / 编辑共用一个提交口:编辑时忽略岗位覆盖,新建时由 hook 补岗位 id
+ const handleSubmitReview = async (input: JobReviewCreateInput) => {
+ setSubmitting(true);
try {
- await publishReview(text);
- setComment('');
+ if (editing) {
+ await editReview(editing.id, input);
+ } else {
+ await publishReview(input);
+ }
+ setEditorOpen(false);
+ setEditing(null);
} catch (e) {
- Alert.alert('发布失败', (e as Error).message || '请稍后重试');
+ Alert.alert(editing ? '保存失败' : '发布失败', (e as Error).message || '请稍后重试');
} finally {
- setPublishing(false);
+ setSubmitting(false);
}
};
+ // 删除前二次确认:评价删掉不可恢复,不做静默删除
+ const handleDeleteReview = (review: EmployeeReview) => {
+ Alert.alert('删除评价', '删除后无法恢复,确定要删除这条评价吗?', [
+ { text: '取消', style: 'cancel' },
+ {
+ text: '删除',
+ style: 'destructive',
+ onPress: async () => {
+ try {
+ await removeReview(review.id);
+ } catch (e) {
+ Alert.alert('删除失败', (e as Error).message || '请稍后重试');
+ }
+ },
+ },
+ ]);
+ };
+
// 错误态
if (error) {
return (
@@ -324,8 +724,9 @@ export default function JobDetailScreen() {
{salaryData.dataSource}
- {/* 4. 员工评价 */}
+ {/* 4. 员工评价(汇总 → 列表) */}
+ {reviewSummary ? : null}
{reviews.length === 0 && (
/* 标题是常驻的,没有这一块时下方会是一片空白,看上去像加载坏了 */
@@ -335,45 +736,40 @@ export default function JobDetailScreen() {
)}
{reviews.map((review) => (
-
-
- {review.userName}
- {review.userRole}
- {review.time}
-
- {review.content}
-
- {review.tags.map((tag) => (
-
- {tag}
-
- ))}
-
-
+ openEdit(review)}
+ onDelete={() => handleDeleteReview(review)}
+ />
))}
- {/* 5. 评论输入框(固定底部) */}
+ {/* 5. 评价入口(固定底部):已有本人评价时改为编辑,避免用户以为能重复发布 */}
-
openEdit(myReview) : openCreate}
>
- {publishing ? (
-
- ) : (
- 发布
- )}
+
+ {myReview ? '编辑我的评价' : '写评价'}
+
+ {
+ setEditorOpen(false);
+ setEditing(null);
+ }}
+ onSubmit={handleSubmitReview}
+ />
);
}
@@ -688,7 +1084,123 @@ const styles = StyleSheet.create({
fontSize: 12,
color: colors.textSecondary,
},
- /* ---- 评论输入框 ---- */
+ /* ---- 星级 ---- */
+ starsRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ starCell: {
+ paddingHorizontal: 1,
+ },
+ /* ---- 评价汇总 ---- */
+ summaryTop: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ summaryScoreCol: {
+ width: 104,
+ alignItems: 'center',
+ gap: 4,
+ },
+ summaryScore: {
+ fontSize: 30,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ },
+ summaryTotal: {
+ fontSize: 11,
+ color: colors.textHint,
+ },
+ summaryBars: {
+ flex: 1,
+ marginLeft: 12,
+ gap: 4,
+ },
+ summaryBarRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ summaryBarLabel: {
+ width: 30,
+ fontSize: 11,
+ color: colors.textHint,
+ },
+ summaryBarTrack: {
+ flex: 1,
+ height: 6,
+ borderRadius: 3,
+ backgroundColor: colors.pageBg,
+ overflow: 'hidden',
+ },
+ summaryBarFill: {
+ height: 6,
+ borderRadius: 3,
+ backgroundColor: STAR_COLOR,
+ },
+ summaryBarCount: {
+ width: 26,
+ textAlign: 'right',
+ fontSize: 11,
+ color: colors.textHint,
+ },
+ /* ---- 评价卡 ---- */
+ reviewRatingRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: 8,
+ gap: 4,
+ },
+ reviewRatingText: {
+ fontSize: 12,
+ fontWeight: '600',
+ color: STAR_COLOR,
+ },
+ dimGrid: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ marginTop: 10,
+ gap: spacing.card,
+ },
+ dimCell: {
+ minWidth: 72,
+ paddingHorizontal: 8,
+ paddingVertical: 6,
+ borderRadius: spacing.card,
+ backgroundColor: colors.pageBg,
+ },
+ dimLabel: {
+ fontSize: 11,
+ color: colors.textHint,
+ },
+ dimValue: {
+ marginTop: 2,
+ fontSize: 14,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ },
+ reviewActions: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ marginTop: 12,
+ paddingTop: 10,
+ borderTopWidth: 1,
+ borderTopColor: colors.borderLight,
+ gap: 16,
+ },
+ reviewActionBtn: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4,
+ },
+ reviewActionText: {
+ fontSize: 12,
+ fontWeight: '600',
+ color: colors.primaryText,
+ },
+ reviewActionDanger: {
+ color: colors.dangerText,
+ },
+ /* ---- 底部评价入口 ---- */
commentBar: {
flexDirection: 'row',
alignItems: 'center',
@@ -698,30 +1210,141 @@ const styles = StyleSheet.create({
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
- commentInput: {
+ composeBtn: {
flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 6,
height: 40,
borderRadius: 20,
- backgroundColor: colors.pageBg,
- paddingHorizontal: spacing.framework,
+ backgroundColor: colors.primaryBtn,
+ },
+ publishText: {
fontSize: 14,
+ fontWeight: '600',
+ color: colors.white,
+ },
+ /* ---- 评价编辑器弹窗 ---- */
+ modalMask: {
+ flex: 1,
+ backgroundColor: colors.scrim,
+ justifyContent: 'flex-end',
+ },
+ modalSheet: {
+ maxHeight: '86%',
+ backgroundColor: colors.white,
+ borderTopLeftRadius: spacing.framework,
+ borderTopRightRadius: spacing.framework,
+ },
+ modalHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: spacing.framework,
+ paddingVertical: 14,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderLight,
+ },
+ modalTitle: {
+ fontSize: 16,
+ fontWeight: '700',
color: colors.textPrimary,
},
- publishBtn: {
- marginLeft: 12,
- paddingHorizontal: 18,
- height: 40,
- borderRadius: 20,
+ modalBody: {
+ paddingHorizontal: spacing.framework,
+ paddingTop: 12,
+ },
+ fieldLabel: {
+ marginTop: 14,
+ marginBottom: 8,
+ fontSize: 13,
+ fontWeight: '600',
+ color: colors.textPrimary,
+ },
+ ratingRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ },
+ ratingHint: {
+ fontSize: 13,
+ color: colors.textSecondary,
+ },
+ dimEditor: {
+ gap: 6,
+ },
+ dimEditorRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ dimEditorLabel: {
+ fontSize: 13,
+ color: colors.textSecondary,
+ },
+ tagPicker: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: spacing.card,
+ },
+ tagChip: {
+ paddingHorizontal: 10,
+ paddingVertical: 5,
+ borderRadius: spacing.card,
+ borderWidth: 1,
+ borderColor: colors.border,
+ backgroundColor: colors.white,
+ },
+ tagChipActive: {
+ borderColor: colors.primary,
+ backgroundColor: colors.primaryLight,
+ },
+ tagChipText: {
+ fontSize: 12,
+ color: colors.textSecondary,
+ },
+ tagChipTextActive: {
+ color: colors.primaryText,
+ fontWeight: '600',
+ },
+ modalInput: {
+ minHeight: 96,
+ borderRadius: spacing.card,
+ borderWidth: 1,
+ borderColor: colors.border,
+ backgroundColor: colors.pageBg,
+ paddingHorizontal: spacing.card,
+ paddingVertical: spacing.card,
+ fontSize: 13,
+ lineHeight: 20,
+ color: colors.textPrimary,
+ textAlignVertical: 'top',
+ },
+ counterText: {
+ marginTop: 6,
+ textAlign: 'right',
+ fontSize: 11,
+ color: colors.textHint,
+ },
+ modalFooter: {
+ paddingHorizontal: spacing.framework,
+ paddingTop: 10,
+ paddingBottom: 24,
+ },
+ submitBtn: {
+ height: 44,
+ borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primaryBtn,
},
- publishBtnDisabled: {
+ submitBtnDisabled: {
opacity: 0.6,
},
- publishText: {
- fontSize: 14,
- fontWeight: '600',
+ submitText: {
+ fontSize: 15,
+ fontWeight: '700',
color: colors.white,
},
});
diff --git a/src/__tests__/job-api.test.ts b/src/__tests__/job-api.test.ts
index 452720f..9e937b1 100644
--- a/src/__tests__/job-api.test.ts
+++ b/src/__tests__/job-api.test.ts
@@ -8,6 +8,9 @@ import {
fetchSalaryData,
fetchEmployeeReviews,
createJobReview,
+ updateJobReview,
+ deleteJobReview,
+ fetchJobReviewSummary,
fetchCompareData,
} from '../api/api';
import { setMockMode, API_BASE_URL } from '../api/http';
@@ -90,11 +93,11 @@ describe('岗位域 mock 开关', () => {
expect(typeof detail.level).toBe('string');
});
- it('真实模式:fetchJobDetail 打 /jobs/{id} 并返回 data', async () => {
+ it('真实模式:fetchJobDetail 打 /jobs/public/{id}(公开详情匿名可用)', async () => {
setMockMode(false);
responder = () => okData({ id: '1', positionName: '后端工程师' });
const detail = await fetchJobDetail('1');
- expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/jobs/1`);
+ expect(fetchCalls[0].url).toBe(`${API_BASE_URL}/jobs/public/1`);
expect(detail.positionName).toBe('后端工程师');
});
@@ -215,25 +218,108 @@ describe('岗位详情子资源 mock 开关', () => {
it('mock 模式:createJobReview 本地持久化且不发请求', async () => {
setMockMode(true);
- const review = await createJobReview('1', '环境友好');
+ const review = await createJobReview('1', {
+ rating: 4,
+ content: '环境友好',
+ tags: ['团队氛围好'],
+ dimensionScores: { overall: 4, intensity: 3 },
+ });
expect(fetchCalls.length).toBe(0);
expect(review.content).toBe('环境友好');
+ expect(review.rating).toBe(4);
+ expect(review.dimensionScores?.intensity).toBe(3);
expect(review.id).toContain('my-review-');
});
- it('真实模式:createJobReview POST /jobs/{id}/reviews 且 body 含 content', async () => {
+ it('mock 模式:同一岗位重复发布是覆盖更新,不产生第二条本人评价', async () => {
+ setMockMode(true);
+ await createJobReview('9', {
+ rating: 3,
+ content: '第一次',
+ tags: [],
+ dimensionScores: {},
+ });
+ await createJobReview('9', {
+ rating: 5,
+ content: '改了',
+ tags: [],
+ dimensionScores: {},
+ });
+ const reviews = await fetchEmployeeReviews('9');
+ const mine = reviews.filter((r) => r.canEdit);
+ expect(mine.length).toBe(1);
+ expect(mine[0].content).toBe('改了');
+ expect(mine[0].rating).toBe(5);
+ });
+
+ it('真实模式:createJobReview POST /jobs/{id}/reviews 且 body 含 rating/tags/dimensionScores', async () => {
setMockMode(false);
responder = (url, init) => {
expect(url).toBe(`${API_BASE_URL}/jobs/4/reviews`);
expect(init.method).toBe('POST');
- const body = JSON.parse(String(init.body)) as { content: string };
+ const body = JSON.parse(String(init.body)) as {
+ content: string;
+ rating: number;
+ tags: string[];
+ dimensionScores: Record;
+ };
expect(body.content).toBe('弹性办公');
+ expect(body.rating).toBe(5);
+ expect(body.tags).toEqual(['福利好']);
+ expect(body.dimensionScores.salary).toBe(5);
return okData({ id: 'srv-1', content: '弹性办公' });
};
- const review = await createJobReview('4', '弹性办公');
+ const review = await createJobReview('4', {
+ rating: 5,
+ content: '弹性办公',
+ tags: ['福利好'],
+ dimensionScores: { salary: 5 },
+ });
expect(review.id).toBe('srv-1');
});
+ it('真实模式:updateJobReview PUT /jobs/{id}/reviews/{reviewId}', async () => {
+ setMockMode(false);
+ responder = (url, init) => {
+ expect(url).toBe(`${API_BASE_URL}/jobs/4/reviews/rv-9`);
+ expect(init.method).toBe('PUT');
+ return okData({ id: 'rv-9', content: '改过的' });
+ };
+ const updated = await updateJobReview('4', 'rv-9', { content: '改过的' });
+ expect(updated.content).toBe('改过的');
+ });
+
+ it('真实模式:deleteJobReview DELETE /jobs/{id}/reviews/{reviewId}', async () => {
+ setMockMode(false);
+ responder = (url, init) => {
+ expect(url).toBe(`${API_BASE_URL}/jobs/4/reviews/rv-9`);
+ expect(init.method).toBe('DELETE');
+ return okData(null);
+ };
+ await expect(deleteJobReview('4', 'rv-9')).resolves.toBeUndefined();
+ });
+
+ it('mock 模式:deleteJobReview 只删本人评价,删不存在的评价报 404', async () => {
+ setMockMode(true);
+ await createJobReview('11', { rating: 4, content: '待删', tags: [], dimensionScores: {} });
+ const before = (await fetchEmployeeReviews('11')).filter((r) => r.canEdit);
+ await deleteJobReview('11', before[0].id);
+ const after = (await fetchEmployeeReviews('11')).filter((r) => r.canEdit);
+ expect(after.length).toBe(0);
+ await expect(deleteJobReview('11', before[0].id)).rejects.toThrow('评价不存在或无权删除');
+ });
+
+ it('mock 模式:fetchJobReviewSummary 平均分只统计有 rating 的评价', async () => {
+ setMockMode(true);
+ const summary = await fetchJobReviewSummary('12');
+ expect(summary.total).toBeGreaterThan(0);
+ expect(summary.averageRating).toBeGreaterThan(0);
+ expect(summary.distribution.length).toBe(5);
+ // 分布条数之和必须等于总数,否则 UI 里条形图与「N 条真实评价」对不上
+ const sum = summary.distribution.reduce((s, d) => s + d.count, 0);
+ expect(sum).toBe(summary.total);
+ });
+
it('mock 模式:fetchCompareData 返回本地过滤数据', async () => {
setMockMode(true);
const data = await fetchCompareData(['j1', 'j2']);
diff --git a/src/api/api.ts b/src/api/api.ts
index c653c96..9c6a53e 100644
--- a/src/api/api.ts
+++ b/src/api/api.ts
@@ -24,6 +24,9 @@ import type {
OfferCompareData,
DimensionScore,
EmployeeReview,
+ JobReviewCreateInput,
+ JobReviewSummary,
+ JobReviewUpdateInput,
EnterpriseEntry,
HollandAssessment,
HollandIntro,
@@ -939,7 +942,13 @@ const MOCK_SALARY_DATA: SalaryData = {
totalCount: 2846,
};
-/** 员工评价列表 */
+/**
+ * 员工评价列表(公开评价,非本人发布)。
+ *
+ * 必须带 `rating` 与 `dimensionScores`:汇总接口的平均分 / 星级分布都从这两个字段派生,
+ * 早期只填 tags + content 会让「岗位评价汇总」永远算出 0 分。八维 key 与
+ * `constants/review` 的 `REVIEW_DIMENSIONS` 一致。
+ */
const MOCK_EMPLOYEE_REVIEWS: EmployeeReview[] = [
{
id: 'r-1',
@@ -948,6 +957,18 @@ const MOCK_EMPLOYEE_REVIEWS: EmployeeReview[] = [
time: '3天前',
content: '团队氛围很好,技术栈比较新,成长空间大,就是加班有点多...',
tags: ['团队氛围好', '技术先进'],
+ rating: 4,
+ status: 'PUBLISHED',
+ dimensionScores: {
+ overall: 4,
+ salary: 4,
+ growth: 5,
+ atmosphere: 5,
+ benefits: 4,
+ team: 5,
+ culture: 4,
+ intensity: 2,
+ },
},
{
id: 'r-2',
@@ -956,6 +977,18 @@ const MOCK_EMPLOYEE_REVIEWS: EmployeeReview[] = [
time: '2周前',
content: '薪资待遇不错,福利也还行,就是晋升通道不够清晰...',
tags: ['薪资高', '福利好'],
+ rating: 4,
+ status: 'PUBLISHED',
+ dimensionScores: {
+ overall: 4,
+ salary: 5,
+ growth: 3,
+ atmosphere: 4,
+ benefits: 5,
+ team: 4,
+ culture: 3,
+ intensity: 3,
+ },
},
{
id: 'r-3',
@@ -964,6 +997,18 @@ const MOCK_EMPLOYEE_REVIEWS: EmployeeReview[] = [
time: '1个月前',
content: '公司文化很好,同事都很优秀,能学到很多东西...',
tags: ['文化好', '成长快'],
+ rating: 5,
+ status: 'PUBLISHED',
+ dimensionScores: {
+ overall: 5,
+ salary: 4,
+ growth: 5,
+ atmosphere: 5,
+ benefits: 4,
+ team: 5,
+ culture: 5,
+ intensity: 3,
+ },
},
];
@@ -2271,20 +2316,23 @@ export async function fetchSalaryData(id: string): Promise {
/**
* 获取员工评价列表
* @method GET
- * @url /api/jobs/:id/reviews
- * @param id { string } 岗位 id
- * @returns Promise 评价数组
+ * @url /jobs/:jobId/reviews
+ * @param jobId { string } 岗位 id
+ * @returns Promise 评价数组(含八维评分;mock 下本人评价带 canEdit)
*/
-export async function fetchEmployeeReviews(id: string): Promise {
+export async function fetchEmployeeReviews(jobId: string): Promise {
if (isMockMode()) {
- // mock 阶段:内置评价 + 该岗位下本人已发布的评价(本人发布的最新在前)
- const mine = (await readJobReviews()).filter((r) => r.jobId === id);
- return mockRequest([...mine, ...MOCK_EMPLOYEE_REVIEWS]);
+ // mock 阶段:内置公开评价 + 该岗位下本人已发布评价(本人评价带 canEdit 标记)
+ const mine = (await readJobReviews())
+ .filter((r) => r.jobId === jobId)
+ .map((r) => ({ ...r, canEdit: true }));
+ const publicReviews = MOCK_EMPLOYEE_REVIEWS.map((r) => ({ ...r, canEdit: false }));
+ return mockRequest([...mine, ...publicReviews]);
}
- return request(`/jobs/${id}/reviews`);
+ return request(`/jobs/${jobId}/reviews`);
}
-/* ===================== 岗位评价发布(mock 阶段持久化到 AsyncStorage) ===================== */
+/* ===================== 岗位评价发布 / 修改 / 删除(mock 阶段持久化到 AsyncStorage) ===================== */
const JOB_REVIEWS_KEY = 'job:reviews';
@@ -2299,37 +2347,162 @@ async function readJobReviews(): Promise {
return raw ? (JSON.parse(raw) as StoredJobReview[]) : [];
}
+/** 覆盖写回本人评价列表 */
+async function writeJobReviews(list: StoredJobReview[]): Promise {
+ await storageSet(JOB_REVIEWS_KEY, JSON.stringify(list));
+}
+
/**
* 发布岗位评价
* @method POST
- * @url /api/jobs/:id/reviews
- * @param id 岗位 id
- * @param content 评价内容
+ * @url /jobs/:jobId/reviews
+ * @param jobId 岗位 id
+ * @param input 评价内容:评分 / 八维 / 标签 / 正文
* @returns Promise 新建的评价
- * @remark 真实后端下需做敏感词校验与重复提交限制,此处不做
+ * @remark 同一学生对同一岗位仅可评价一次(后端约束);mock 阶段以「已有本人评价则覆盖更新」模拟
*/
-export async function createJobReview(id: string, content: string): Promise {
+export async function createJobReview(
+ jobId: string,
+ input: JobReviewCreateInput,
+): Promise {
if (isMockMode()) {
+ const all = await readJobReviews();
+ const existing = all.find((r) => r.jobId === jobId);
+ const now = new Date().toISOString();
const review: StoredJobReview = {
- id: `my-review-${Date.now()}`,
- jobId: id,
+ id: existing?.id ?? `my-review-${Date.now()}`,
+ jobId,
userName: '我',
userRole: '我的评价',
time: '刚刚',
- content,
- tags: [],
+ status: 'PUBLISHED',
+ rating: input.rating,
+ content: input.content,
+ tags: input.tags,
+ dimensionScores: input.dimensionScores,
+ canEdit: true,
+ updatedAt: now,
};
- const all = await readJobReviews();
- all.unshift(review);
- await storageSet(JOB_REVIEWS_KEY, JSON.stringify(all));
+ const next = existing
+ ? all.map((r) => (r.id === existing.id ? review : r))
+ : [review, ...all];
+ await writeJobReviews(next);
return mockRequest(review);
}
- return request(`/jobs/${id}/reviews`, {
+ return request(`/jobs/${jobId}/reviews`, {
method: 'POST',
- body: { content },
+ auth: true,
+ body: {
+ rating: input.rating,
+ content: input.content,
+ tags: input.tags,
+ dimensionScores: input.dimensionScores,
+ },
+ });
+}
+
+/**
+ * 岗位评价详情
+ * @method GET
+ * @url /jobs/:jobId/reviews/:reviewId
+ * @param jobId 岗位 id
+ * @param reviewId 评价 id
+ * @returns Promise 单条评价(含八维评分)
+ */
+export async function fetchJobReviewDetail(
+ jobId: string,
+ reviewId: string,
+): Promise {
+ if (isMockMode()) {
+ const found = (await readJobReviews()).find(
+ (r) => r.jobId === jobId && r.id === reviewId,
+ );
+ if (!found) throw new ApiError({ httpStatus: 404, code: 404, message: '评价不存在或无权查看' });
+ return mockRequest(found);
+ }
+ return request(`/jobs/${jobId}/reviews/${reviewId}`);
+}
+
+/**
+ * 修改岗位评价(仅本人)
+ * @method PUT
+ * @url /jobs/:jobId/reviews/:reviewId
+ * @returns Promise 更新后的评价
+ */
+export async function updateJobReview(
+ jobId: string,
+ reviewId: string,
+ input: JobReviewUpdateInput,
+): Promise {
+ if (isMockMode()) {
+ const all = await readJobReviews();
+ const idx = all.findIndex((r) => r.jobId === jobId && r.id === reviewId);
+ if (idx < 0) throw new ApiError({ httpStatus: 404, code: 404, message: '评价不存在或无权修改' });
+ const updated: StoredJobReview = {
+ ...all[idx],
+ ...input,
+ updatedAt: new Date().toISOString(),
+ };
+ all[idx] = updated;
+ await writeJobReviews(all);
+ return mockRequest(updated);
+ }
+ return request(`/jobs/${jobId}/reviews/${reviewId}`, {
+ method: 'PUT',
+ auth: true,
+ body: input,
});
}
+/**
+ * 删除岗位评价(仅本人)
+ * @method DELETE
+ * @url /jobs/:jobId/reviews/:reviewId
+ * @returns Promise
+ */
+export async function deleteJobReview(jobId: string, reviewId: string): Promise {
+ if (isMockMode()) {
+ const all = await readJobReviews();
+ const next = all.filter((r) => !(r.jobId === jobId && r.id === reviewId));
+ if (next.length === all.length)
+ throw new ApiError({ httpStatus: 404, code: 404, message: '评价不存在或无权删除' });
+ await writeJobReviews(next);
+ return mockRequest(undefined);
+ }
+ await request(`/jobs/${jobId}/reviews/${reviewId}`, {
+ method: 'DELETE',
+ auth: true,
+ });
+}
+
+/**
+ * 岗位评价汇总(平均分 / 分布 / 热门标签)
+ * @method GET
+ * @url /jobs/:jobId/reviews/summary
+ * @returns Promise
+ */
+export async function fetchJobReviewSummary(jobId: string): Promise {
+ if (isMockMode()) {
+ const mine = (await readJobReviews()).filter((r) => r.jobId === jobId);
+ const all = [...mine, ...MOCK_EMPLOYEE_REVIEWS];
+ const total = all.length;
+ const sum = all.reduce((s, r) => s + (r.rating ?? 0), 0);
+ const averageRating = total ? Number((sum / total).toFixed(1)) : 0;
+ const distribution = [5, 4, 3, 2, 1].map((rating) => ({
+ rating,
+ count: all.filter((r) => (r.rating ?? 0) === rating).length,
+ }));
+ const tagCount = new Map();
+ all.forEach((r) => r.tags.forEach((t) => tagCount.set(t, (tagCount.get(t) ?? 0) + 1)));
+ const topTags = [...tagCount.entries()]
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 5)
+ .map(([t]) => t);
+ return mockRequest({ averageRating, total, distribution, topTags });
+ }
+ return request(`/jobs/${jobId}/reviews/summary`);
+}
+
/**
* 获取岗位对比数据(按 jobIds 过滤九大维度评分 + 基本信息 + 福利)
* @method GET
diff --git a/src/api/index.ts b/src/api/index.ts
index d063858..1dc2009 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -60,6 +60,10 @@ export {
fetchLegalTemplates,
fetchEmployeeReviews,
createJobReview,
+ fetchJobReviewDetail,
+ updateJobReview,
+ deleteJobReview,
+ fetchJobReviewSummary,
fetchEnterpriseEntry,
fetchHollandAssessment,
fetchHollandResult,
diff --git a/src/constants/index.ts b/src/constants/index.ts
index 4e3c70c..62af110 100644
--- a/src/constants/index.ts
+++ b/src/constants/index.ts
@@ -372,3 +372,12 @@ export const RESUME_EXPORT_PASSWORD_NOTICE = '密码保护需后端导出服务
/** 导出失败提示:本机生成不可用时给出明确归因,而不是返回假链接 */
export const RESUME_EXPORT_FAIL_HINT = '本机生成 PDF 失败,导出功能需后端导出服务支持';
+
+/* ===================== 岗位评价(八维评分 / 标签 / 上限) ===================== */
+
+export {
+ REVIEW_DIMENSIONS,
+ REVIEW_RATING_MAX,
+ REVIEW_TAG_SUGGESTIONS,
+ emptyDimensionScores,
+} from './review';
diff --git a/src/constants/review.ts b/src/constants/review.ts
new file mode 100644
index 0000000..7646943
--- /dev/null
+++ b/src/constants/review.ts
@@ -0,0 +1,45 @@
+/**
+ * 岗位评价领域常量:八维评分维度、标签候选、评分上限。
+ *
+ * 八维与后端「八维评分」对齐;维度 key 同时是 `EmployeeReview.dimensionScores`
+ * 的键,发送创建/修改请求时直接用这套 key 拼成 `Record`。
+ */
+
+/** 单维评分上限(星级) */
+export const REVIEW_RATING_MAX = 5;
+
+/** 八维评分维度(顺序即展示顺序) */
+export const REVIEW_DIMENSIONS: { key: string; label: string; max: number }[] = [
+ { key: 'overall', label: '综合评分', max: REVIEW_RATING_MAX },
+ { key: 'salary', label: '薪资竞争力', max: REVIEW_RATING_MAX },
+ { key: 'growth', label: '发展空间', max: REVIEW_RATING_MAX },
+ { key: 'atmosphere', label: '工作氛围', max: REVIEW_RATING_MAX },
+ { key: 'benefits', label: '福利待遇', max: REVIEW_RATING_MAX },
+ { key: 'team', label: '团队水平', max: REVIEW_RATING_MAX },
+ { key: 'culture', label: '企业文化', max: REVIEW_RATING_MAX },
+ { key: 'intensity', label: '工作强度', max: REVIEW_RATING_MAX },
+];
+
+/** 发布评价时的标签候选(一键多选) */
+export const REVIEW_TAG_SUGGESTIONS: string[] = [
+ '团队氛围好',
+ '技术先进',
+ '薪资高',
+ '福利好',
+ '成长快',
+ '文化好',
+ '管理清晰',
+ '加班多',
+ '晋升慢',
+ '管理混乱',
+ '岗位真实',
+ '坑位预警',
+];
+
+/** 空八维评分(创建时初始化用) */
+export function emptyDimensionScores(): Record {
+ return REVIEW_DIMENSIONS.reduce>((acc, dim) => {
+ acc[dim.key] = 0;
+ return acc;
+ }, {});
+}
diff --git a/src/hooks/useJobDetail.ts b/src/hooks/useJobDetail.ts
index 71676f2..726789b 100644
--- a/src/hooks/useJobDetail.ts
+++ b/src/hooks/useJobDetail.ts
@@ -1,19 +1,25 @@
/**
- * 岗位详情数据 hook:并行组合岗位详情、综合评分、薪资区间、员工评价四类数据,
- * 统一管理 loading / error / refresh 三态。
+ * 岗位详情数据 hook:并行组合岗位详情、综合评分、薪资区间、员工评价、评价汇总五类数据,
+ * 统一管理 loading / error / refresh 三态,并暴露评价的增 / 改 / 删动作。
*/
import { useCallback } from 'react';
import {
createJobReview,
+ deleteJobReview,
fetchEmployeeReviews,
fetchJobDetail,
fetchJobOverallScore,
+ fetchJobReviewSummary,
fetchSalaryData,
+ updateJobReview,
} from '../api';
import type {
EmployeeReview,
JobDetail,
JobOverallScore,
+ JobReviewCreateInput,
+ JobReviewSummary,
+ JobReviewUpdateInput,
SalaryData,
} from '../types';
import { useAsyncData } from './useAsyncData';
@@ -24,39 +30,67 @@ interface JobDetailData {
overallScore: JobOverallScore;
salaryData: SalaryData;
reviews: EmployeeReview[];
+ reviewSummary: JobReviewSummary;
}
export function useJobDetail(id: string) {
const fetcher = useCallback(async (): Promise => {
- const [detail, overallScore, salaryData, reviews] = await Promise.all([
- fetchJobDetail(id),
- fetchJobOverallScore(id),
- fetchSalaryData(id),
- fetchEmployeeReviews(id),
- ]);
- return { detail, overallScore, salaryData, reviews };
+ const [detail, overallScore, salaryData, reviews, reviewSummary] =
+ await Promise.all([
+ fetchJobDetail(id),
+ fetchJobOverallScore(id),
+ fetchSalaryData(id),
+ fetchEmployeeReviews(id),
+ fetchJobReviewSummary(id),
+ ]);
+ return { detail, overallScore, salaryData, reviews, reviewSummary };
}, [id]);
const { data, loading, error, refresh } = useAsyncData(fetcher);
- /** 发布岗位评价,成功后自动刷新评价列表 */
+ /**
+ * 发布岗位评价,成功后自动刷新评价列表与汇总。
+ * 同一学生对同一岗位仅可评价一次,后端语义是「再发即覆盖」。
+ */
const publishReview = useCallback(
- async (content: string) => {
- const created = await createJobReview(id, content);
+ async (input: JobReviewCreateInput) => {
+ const created = await createJobReview(id, input);
refresh();
return created;
},
[id, refresh],
);
+ /** 修改本人岗位评价,成功后刷新 */
+ const editReview = useCallback(
+ async (reviewId: string, input: JobReviewUpdateInput) => {
+ const updated = await updateJobReview(id, reviewId, input);
+ refresh();
+ return updated;
+ },
+ [id, refresh],
+ );
+
+ /** 删除本人岗位评价,成功后刷新 */
+ const removeReview = useCallback(
+ async (reviewId: string) => {
+ await deleteJobReview(id, reviewId);
+ refresh();
+ },
+ [id, refresh],
+ );
+
return {
detail: data?.detail ?? null,
overallScore: data?.overallScore ?? null,
salaryData: data?.salaryData ?? null,
reviews: data?.reviews ?? [],
+ reviewSummary: data?.reviewSummary ?? null,
loading,
error,
refresh,
publishReview,
+ editReview,
+ removeReview,
};
}
diff --git a/src/types/index.ts b/src/types/index.ts
index def268c..3552c58 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -9,6 +9,10 @@ export type {
JobLevel,
JobListParams,
JobOverallScore,
+ JobReviewCreateInput,
+ JobReviewStatus,
+ JobReviewSummary,
+ JobReviewUpdateInput,
SalaryData,
SalaryRange,
SortType,
diff --git a/src/types/job.ts b/src/types/job.ts
index d1163c8..cb71fe7 100644
--- a/src/types/job.ts
+++ b/src/types/job.ts
@@ -113,6 +113,9 @@ export interface SalaryData {
}
/** 员工评价 */
+/** 岗位评价状态:草稿 / 待审 / 已发布 / 已驳回 / 已删除 */
+export type JobReviewStatus = 'DRAFT' | 'PENDING' | 'PUBLISHED' | 'REJECTED' | 'DELETED';
+
export interface EmployeeReview {
id: string;
/** 用户名(脱敏),如 "张**" */
@@ -125,5 +128,51 @@ export interface EmployeeReview {
content: string;
/** 评价标签 */
tags: string[];
+ /** 综合评分 1-5(后端公开列表含八维评分,这里用单分概括) */
+ rating?: number;
+ /** 八维评分,键见 constants/review 的 REVIEW_DIMENSIONS,值为 1-5 */
+ dimensionScores?: Record;
+ /** 审核状态;列表只返回 PUBLISHED */
+ status?: JobReviewStatus;
+ /** 当前用户是否可编辑/删除(后端对本人评价下发的标志;mock 由归属判断) */
+ canEdit?: boolean;
+ /** 最近更新时间 ISO;用于编辑态展示 */
+ updatedAt?: string;
+}
+
+/**
+ * 创建岗位评价入参(对齐 POST /jobs/{jobId}/reviews 的请求体)。
+ * 不含 jobId —— 岗位由路径参数给定,请求体里再带一份是冗余且易与路径不一致。
+ */
+export interface JobReviewCreateInput {
+ /** 综合评分 1-5 */
+ rating: number;
+ /** 评价内容 */
+ content: string;
+ /** 标签 */
+ tags: string[];
+ /** 八维评分键值对 */
+ dimensionScores: Record;
+}
+
+/** 修改岗位评价入参(对齐 PUT /jobs/{jobId}/reviews/{reviewId},字段均可选) */
+export interface JobReviewUpdateInput {
+ rating?: number;
+ content?: string;
+ tags?: string[];
+ dimensionScores?: Record;
+ status?: JobReviewStatus;
+}
+
+/** 岗位评价汇总(对齐 GET /jobs/{jobId}/reviews/summary) */
+export interface JobReviewSummary {
+ /** 平均分 0-5 */
+ averageRating: number;
+ /** 评价总数 */
+ total: number;
+ /** 各星级分布(5→1),count 为条数 */
+ distribution: { rating: number; count: number }[];
+ /** 热门标签 Top5 */
+ topTags: string[];
}