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[]; }