Browse Source

feat(jobs): 补齐岗位评价增删改(P0-6)八维评分/标签/汇总与编辑删除

- 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 例通过)
master
doublex001 4 weeks ago
parent
commit
1ae124c2f7
  1. 749
      app/jobs/[id].tsx
  2. 98
      src/__tests__/job-api.test.ts
  3. 221
      src/api/api.ts
  4. 4
      src/api/index.ts
  5. 9
      src/constants/index.ts
  6. 45
      src/constants/review.ts
  7. 58
      src/hooks/useJobDetail.ts
  8. 4
      src/types/index.ts
  9. 49
      src/types/job.ts

749
app/jobs/[id].tsx

@ -1,9 +1,12 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
KeyboardAvoidingView,
Modal,
Platform,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, 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 Svg, { Circle, Line, Polygon, Text as SvgText } from 'react-native-svg';
import { useFavorites, useJobDetail } from '../../src/hooks'; import { useFavorites, useJobDetail } from '../../src/hooks';
import { buildJobShareText } from '../../src/api'; import { buildJobShareText } from '../../src/api';
import {
REVIEW_DIMENSIONS,
REVIEW_RATING_MAX,
REVIEW_TAG_SUGGESTIONS,
} from '../../src/constants';
import { shareText } from '../../src/lib/shareFile'; import { shareText } from '../../src/lib/shareFile';
import {colors, spacing} from '../../src/theme'; import {colors, spacing} from '../../src/theme';
import { formatThousands } from '../../src/utils'; 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 绘制) ===================== */ /* ===================== 雷达图(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 (
<View style={styles.starsRow}>
{Array.from({ length: REVIEW_RATING_MAX }, (_, i) => {
const starValue = i + 1;
const filled = starValue <= value;
const star = (
<Ionicons
name={filled ? 'star' : 'star-outline'}
size={size}
color={filled ? STAR_COLOR : colors.borderStrong}
/>
);
if (readonly) {
return (
<View key={starValue} style={styles.starCell}>
{star}
</View>
);
}
return (
<Pressable
key={starValue}
style={styles.starCell}
hitSlop={6}
onPress={() => onChange(starValue)}
accessibilityRole="button"
accessibilityLabel={`打 ${starValue} 分`}
>
{star}
</Pressable>
);
})}
</View>
);
}
/**
* 评价汇总卡:平均分 + 星级分布 + 热门标签。
* 无评价时不渲染(空态由下方「暂无员工评价」承担,避免两个空块叠着)。
*/
function ReviewSummaryCard({ summary }: { summary: JobReviewSummary }) {
if (summary.total === 0) return null;
const maxCount = Math.max(1, ...summary.distribution.map((d) => d.count));
return (
<View style={styles.card}>
<View style={styles.summaryTop}>
<View style={styles.summaryScoreCol}>
<Text style={styles.summaryScore}>{summary.averageRating.toFixed(1)}</Text>
<Stars value={Math.round(summary.averageRating)} size={13} />
<Text style={styles.summaryTotal}>{summary.total} 条真实评价</Text>
</View>
<View style={styles.summaryBars}>
{summary.distribution.map((d) => (
<View key={d.rating} style={styles.summaryBarRow}>
<Text style={styles.summaryBarLabel}>{d.rating}星</Text>
<View style={styles.summaryBarTrack}>
<View
style={[
styles.summaryBarFill,
{ width: `${Math.round((d.count / maxCount) * 100)}%` },
]}
/>
</View>
<Text style={styles.summaryBarCount}>{d.count}</Text>
</View>
))}
</View>
</View>
{summary.topTags.length > 0 ? (
<View style={styles.reviewTags}>
{summary.topTags.map((tag) => (
<View key={tag} style={styles.reviewTag}>
<Text style={styles.reviewTagText}>{tag}</Text>
</View>
))}
</View>
) : null}
</View>
);
}
/**
* 单条评价卡。
* `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 (
<View style={styles.card}>
<View style={styles.reviewHeader}>
<Text style={styles.reviewName}>{review.userName}</Text>
<Text style={styles.reviewRole}>{review.userRole}</Text>
<Text style={styles.reviewTime}>{review.time}</Text>
</View>
{typeof review.rating === 'number' && review.rating > 0 ? (
<View style={styles.reviewRatingRow}>
<Stars value={review.rating} size={13} />
<Text style={styles.reviewRatingText}>{review.rating}.0</Text>
</View>
) : null}
<Text style={styles.reviewContent}>{review.content}</Text>
{scoredDims.length > 0 ? (
<View style={styles.dimGrid}>
{scoredDims.map((dim) => (
<View key={dim.key} style={styles.dimCell}>
<Text style={styles.dimLabel}>{dim.label}</Text>
<Text style={styles.dimValue}>{review.dimensionScores?.[dim.key]}</Text>
</View>
))}
</View>
) : null}
{review.tags.length > 0 ? (
<View style={styles.reviewTags}>
{review.tags.map((tag) => (
<View key={tag} style={styles.reviewTag}>
<Text style={styles.reviewTagText}>{tag}</Text>
</View>
))}
</View>
) : null}
{review.canEdit ? (
<View style={styles.reviewActions}>
<Pressable style={styles.reviewActionBtn} onPress={onEdit} hitSlop={6}>
<Ionicons name="create-outline" size={14} color={colors.primaryText} />
<Text style={styles.reviewActionText}>编辑</Text>
</Pressable>
<Pressable style={styles.reviewActionBtn} onPress={onDelete} hitSlop={6}>
<Ionicons name="trash-outline" size={14} color={colors.dangerText} />
<Text style={[styles.reviewActionText, styles.reviewActionDanger]}>删除</Text>
</Pressable>
</View>
) : null}
</View>
);
}
/** 编辑器初值:新建时全 0,编辑时用已有评价回填 */
interface ReviewDraft {
rating: number;
content: string;
tags: string[];
dimensionScores: Record<string, number>;
}
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<ReviewDraft>(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<Record<string, number>>((acc, dim) => {
acc[dim.key] = draft.dimensionScores[dim.key] ?? 0;
return acc;
}, {}),
});
};
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<KeyboardAvoidingView
style={styles.modalMask}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>{initial ? '编辑评价' : '写评价'}</Text>
<Pressable onPress={onClose} hitSlop={8} accessibilityLabel="关闭">
<Ionicons name="close" size={22} color={colors.textSecondary} />
</Pressable>
</View>
<ScrollView style={styles.modalBody} keyboardShouldPersistTaps="handled">
<Text style={styles.fieldLabel}>综合评分</Text>
<View style={styles.ratingRow}>
<Stars
value={draft.rating}
size={26}
onChange={(next) => setDraft((prev) => ({ ...prev, rating: next }))}
/>
<Text style={styles.ratingHint}>
{draft.rating > 0 ? `${draft.rating} 分` : '点击星星打分'}
</Text>
</View>
<Text style={styles.fieldLabel}>分项评分(选填)</Text>
<View style={styles.dimEditor}>
{REVIEW_DIMENSIONS.map((dim) => (
<View key={dim.key} style={styles.dimEditorRow}>
<Text style={styles.dimEditorLabel}>{dim.label}</Text>
<Stars
value={draft.dimensionScores[dim.key] ?? 0}
size={16}
onChange={(next) => setDimension(dim.key, next)}
/>
</View>
))}
</View>
<Text style={styles.fieldLabel}>标签(选填,可多选)</Text>
<View style={styles.tagPicker}>
{REVIEW_TAG_SUGGESTIONS.map((tag) => {
const active = draft.tags.includes(tag);
return (
<Pressable
key={tag}
style={[styles.tagChip, active && styles.tagChipActive]}
onPress={() => toggleTag(tag)}
>
<Text style={[styles.tagChipText, active && styles.tagChipTextActive]}>
{tag}
</Text>
</Pressable>
);
})}
</View>
<Text style={styles.fieldLabel}>评价内容</Text>
<TextInput
style={styles.modalInput}
placeholder="说说这个岗位的真实情况,帮助更多人做判断..."
placeholderTextColor={colors.textHint}
value={draft.content}
onChangeText={(text) => setDraft((prev) => ({ ...prev, content: text }))}
multiline
maxLength={500}
/>
<Text style={styles.counterText}>{draft.content.length}/500</Text>
</ScrollView>
<View style={styles.modalFooter}>
<Pressable
style={[styles.submitBtn, submitting && styles.submitBtnDisabled]}
onPress={handleSubmit}
disabled={submitting}
>
{submitting ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<Text style={styles.submitText}>{initial ? '保存修改' : '发布评价'}</Text>
)}
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
}
/* ===================== 页面 ===================== */ /* ===================== 页面 ===================== */
export default function JobDetailScreen() { export default function JobDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter(); const router = useRouter();
const { detail, overallScore, salaryData, reviews, loading, error, refresh, publishReview } = const {
useJobDetail(id); detail,
const [comment, setComment] = useState(''); overallScore,
const [publishing, setPublishing] = useState(false); salaryData,
reviews,
reviewSummary,
loading,
error,
refresh,
publishReview,
editReview,
removeReview,
} = useJobDetail(id);
const [editorOpen, setEditorOpen] = useState(false);
/** 正在编辑的评价;null = 新建 */
const [editing, setEditing] = useState<EmployeeReview | null>(null);
const [submitting, setSubmitting] = useState(false);
/**
* 本人已发布的评价。后端约束「同一学生对同一岗位仅一条」,故取第一条 canEdit 即可。
* 有它时底部按钮变「编辑我的评价」,避免用户以为能发第二条。
*/
const myReview = reviews.find((r) => r.canEdit) ?? null;
// 收藏态:用已收藏 id 集合判断是否已收藏(仅 STUDENT 可收藏) // 收藏态:用已收藏 id 集合判断是否已收藏(仅 STUDENT 可收藏)
const { favoriteIds, toggle: toggleFavorite } = useFavorites(); const { favoriteIds, toggle: toggleFavorite } = useFavorites();
@ -195,24 +567,52 @@ export default function JobDetailScreen() {
if (!outcome.ok) Alert.alert('无法分享', outcome.message); if (!outcome.ok) Alert.alert('无法分享', outcome.message);
}; };
// 发布评价:空内容拦截,成功后清空输入框(列表由 hook 自动刷新) const openCreate = () => {
const handlePublish = async () => { setEditing(null);
const text = comment.trim(); setEditorOpen(true);
if (!text) { };
Alert.alert('提示', '请先输入你的评价内容');
return; const openEdit = (review: EmployeeReview) => {
} setEditing(review);
setPublishing(true); setEditorOpen(true);
};
// 新建 / 编辑共用一个提交口:编辑时忽略岗位覆盖,新建时由 hook 补岗位 id
const handleSubmitReview = async (input: JobReviewCreateInput) => {
setSubmitting(true);
try { try {
await publishReview(text); if (editing) {
setComment(''); await editReview(editing.id, input);
} else {
await publishReview(input);
}
setEditorOpen(false);
setEditing(null);
} catch (e) { } catch (e) {
Alert.alert('发布失败', (e as Error).message || '请稍后重试'); Alert.alert(editing ? '保存失败' : '发布失败', (e as Error).message || '请稍后重试');
} finally { } 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) { if (error) {
return ( return (
@ -324,8 +724,9 @@ export default function JobDetailScreen() {
<Text style={styles.salarySource}>{salaryData.dataSource}</Text> <Text style={styles.salarySource}>{salaryData.dataSource}</Text>
</View> </View>
{/* 4. 员工评价 */} {/* 4. 员工评价(汇总 → 列表) */}
<SectionTitle title="员工评价" /> <SectionTitle title="员工评价" />
{reviewSummary ? <ReviewSummaryCard summary={reviewSummary} /> : null}
{reviews.length === 0 && ( {reviews.length === 0 && (
/* 标题是常驻的,没有这一块时下方会是一片空白,看上去像加载坏了 */ /* 标题是常驻的,没有这一块时下方会是一片空白,看上去像加载坏了 */
<View style={styles.emptyCard}> <View style={styles.emptyCard}>
@ -335,45 +736,40 @@ export default function JobDetailScreen() {
</View> </View>
)} )}
{reviews.map((review) => ( {reviews.map((review) => (
<View key={review.id} style={styles.card}> <ReviewCard
<View style={styles.reviewHeader}> key={review.id}
<Text style={styles.reviewName}>{review.userName}</Text> review={review}
<Text style={styles.reviewRole}>{review.userRole}</Text> onEdit={() => openEdit(review)}
<Text style={styles.reviewTime}>{review.time}</Text> onDelete={() => handleDeleteReview(review)}
</View> />
<Text style={styles.reviewContent}>{review.content}</Text>
<View style={styles.reviewTags}>
{review.tags.map((tag) => (
<View key={tag} style={styles.reviewTag}>
<Text style={styles.reviewTagText}>{tag}</Text>
</View>
))}
</View>
</View>
))} ))}
</ScrollView> </ScrollView>
{/* 5. 评论输入框(固定底部) */} {/* 5. 评价入口(固定底部):已有本人评价时改为编辑,避免用户以为能重复发布 */}
<View style={styles.commentBar}> <View style={styles.commentBar}>
<TextInput
style={styles.commentInput}
placeholder="说说你对这个岗位的看法..."
placeholderTextColor={colors.textHint}
value={comment}
onChangeText={setComment}
/>
<Pressable <Pressable
style={[styles.publishBtn, publishing && styles.publishBtnDisabled]} style={styles.composeBtn}
onPress={handlePublish} onPress={myReview ? () => openEdit(myReview) : openCreate}
disabled={publishing}
> >
{publishing ? ( <Ionicons
<ActivityIndicator size="small" color={colors.white} /> name={myReview ? 'create-outline' : 'pencil-outline'}
) : ( size={16}
<Text style={styles.publishText}>发布</Text> color={colors.white}
)} />
<Text style={styles.publishText}>{myReview ? '编辑我的评价' : '写评价'}</Text>
</Pressable> </Pressable>
</View> </View>
<ReviewEditorModal
visible={editorOpen}
initial={editing}
submitting={submitting}
onClose={() => {
setEditorOpen(false);
setEditing(null);
}}
onSubmit={handleSubmitReview}
/>
</SafeAreaView> </SafeAreaView>
); );
} }
@ -688,7 +1084,123 @@ const styles = StyleSheet.create({
fontSize: 12, fontSize: 12,
color: colors.textSecondary, 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: { commentBar: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -698,30 +1210,141 @@ const styles = StyleSheet.create({
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: colors.borderLight, borderTopColor: colors.borderLight,
}, },
commentInput: { composeBtn: {
flex: 1, flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
height: 40, height: 40,
borderRadius: 20, borderRadius: 20,
backgroundColor: colors.pageBg, backgroundColor: colors.primaryBtn,
paddingHorizontal: spacing.framework, },
publishText: {
fontSize: 14, 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, color: colors.textPrimary,
}, },
publishBtn: { modalBody: {
marginLeft: 12, paddingHorizontal: spacing.framework,
paddingHorizontal: 18, paddingTop: 12,
height: 40, },
borderRadius: 20, 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', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: colors.primaryBtn, backgroundColor: colors.primaryBtn,
}, },
publishBtnDisabled: { submitBtnDisabled: {
opacity: 0.6, opacity: 0.6,
}, },
publishText: { submitText: {
fontSize: 14, fontSize: 15,
fontWeight: '600', fontWeight: '700',
color: colors.white, color: colors.white,
}, },
}); });

98
src/__tests__/job-api.test.ts

@ -8,6 +8,9 @@ import {
fetchSalaryData, fetchSalaryData,
fetchEmployeeReviews, fetchEmployeeReviews,
createJobReview, createJobReview,
updateJobReview,
deleteJobReview,
fetchJobReviewSummary,
fetchCompareData, fetchCompareData,
} from '../api/api'; } from '../api/api';
import { setMockMode, API_BASE_URL } from '../api/http'; import { setMockMode, API_BASE_URL } from '../api/http';
@ -90,11 +93,11 @@ describe('岗位域 mock 开关', () => {
expect(typeof detail.level).toBe('string'); expect(typeof detail.level).toBe('string');
}); });
it('真实模式:fetchJobDetail 打 /jobs/{id} 并返回 data', async () => { it('真实模式:fetchJobDetail 打 /jobs/public/{id}(公开详情匿名可用)', async () => {
setMockMode(false); setMockMode(false);
responder = () => okData({ id: '1', positionName: '后端工程师' }); responder = () => okData({ id: '1', positionName: '后端工程师' });
const detail = await fetchJobDetail('1'); 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('后端工程师'); expect(detail.positionName).toBe('后端工程师');
}); });
@ -215,25 +218,108 @@ describe('岗位详情子资源 mock 开关', () => {
it('mock 模式:createJobReview 本地持久化且不发请求', async () => { it('mock 模式:createJobReview 本地持久化且不发请求', async () => {
setMockMode(true); 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(fetchCalls.length).toBe(0);
expect(review.content).toBe('环境友好'); expect(review.content).toBe('环境友好');
expect(review.rating).toBe(4);
expect(review.dimensionScores?.intensity).toBe(3);
expect(review.id).toContain('my-review-'); 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); setMockMode(false);
responder = (url, init) => { responder = (url, init) => {
expect(url).toBe(`${API_BASE_URL}/jobs/4/reviews`); expect(url).toBe(`${API_BASE_URL}/jobs/4/reviews`);
expect(init.method).toBe('POST'); 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<string, number>;
};
expect(body.content).toBe('弹性办公'); 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: '弹性办公' }); 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'); 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 () => { it('mock 模式:fetchCompareData 返回本地过滤数据', async () => {
setMockMode(true); setMockMode(true);
const data = await fetchCompareData(['j1', 'j2']); const data = await fetchCompareData(['j1', 'j2']);

221
src/api/api.ts

@ -24,6 +24,9 @@ import type {
OfferCompareData, OfferCompareData,
DimensionScore, DimensionScore,
EmployeeReview, EmployeeReview,
JobReviewCreateInput,
JobReviewSummary,
JobReviewUpdateInput,
EnterpriseEntry, EnterpriseEntry,
HollandAssessment, HollandAssessment,
HollandIntro, HollandIntro,
@ -939,7 +942,13 @@ const MOCK_SALARY_DATA: SalaryData = {
totalCount: 2846, totalCount: 2846,
}; };
/** 员工评价列表 */ /**
* 员工评价列表(公开评价,非本人发布)。
*
* 必须带 `rating` 与 `dimensionScores`:汇总接口的平均分 / 星级分布都从这两个字段派生,
* 早期只填 tags + content 会让「岗位评价汇总」永远算出 0 分。八维 key 与
* `constants/review` 的 `REVIEW_DIMENSIONS` 一致。
*/
const MOCK_EMPLOYEE_REVIEWS: EmployeeReview[] = [ const MOCK_EMPLOYEE_REVIEWS: EmployeeReview[] = [
{ {
id: 'r-1', id: 'r-1',
@ -948,6 +957,18 @@ const MOCK_EMPLOYEE_REVIEWS: EmployeeReview[] = [
time: '3天前', time: '3天前',
content: '团队氛围很好,技术栈比较新,成长空间大,就是加班有点多...', content: '团队氛围很好,技术栈比较新,成长空间大,就是加班有点多...',
tags: ['团队氛围好', '技术先进'], 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', id: 'r-2',
@ -956,6 +977,18 @@ const MOCK_EMPLOYEE_REVIEWS: EmployeeReview[] = [
time: '2周前', time: '2周前',
content: '薪资待遇不错,福利也还行,就是晋升通道不够清晰...', content: '薪资待遇不错,福利也还行,就是晋升通道不够清晰...',
tags: ['薪资高', '福利好'], 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', id: 'r-3',
@ -964,6 +997,18 @@ const MOCK_EMPLOYEE_REVIEWS: EmployeeReview[] = [
time: '1个月前', time: '1个月前',
content: '公司文化很好,同事都很优秀,能学到很多东西...', content: '公司文化很好,同事都很优秀,能学到很多东西...',
tags: ['文化好', '成长快'], 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<SalaryData> {
/** /**
* 获取员工评价列表 * 获取员工评价列表
* @method GET * @method GET
* @url /api/jobs/:id/reviews * @url /jobs/:jobId/reviews
* @param id { string } 岗位 id * @param jobId { string } 岗位 id
* @returns Promise<EmployeeReview[]> 评价数组 * @returns Promise<EmployeeReview[]> 评价数组(含八维评分;mock 下本人评价带 canEdit)
*/ */
export async function fetchEmployeeReviews(id: string): Promise<EmployeeReview[]> { export async function fetchEmployeeReviews(jobId: string): Promise<EmployeeReview[]> {
if (isMockMode()) { if (isMockMode()) {
// mock 阶段:内置评价 + 该岗位下本人已发布的评价(本人发布的最新在前) // mock 阶段:内置公开评价 + 该岗位下本人已发布评价(本人评价带 canEdit 标记)
const mine = (await readJobReviews()).filter((r) => r.jobId === id); const mine = (await readJobReviews())
return mockRequest([...mine, ...MOCK_EMPLOYEE_REVIEWS]); .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<EmployeeReview[]>(`/jobs/${id}/reviews`); return request<EmployeeReview[]>(`/jobs/${jobId}/reviews`);
} }
/* ===================== 岗位评价发布(mock 阶段持久化到 AsyncStorage) ===================== */ /* ===================== 岗位评价发布 / 修改 / 删除(mock 阶段持久化到 AsyncStorage) ===================== */
const JOB_REVIEWS_KEY = 'job:reviews'; const JOB_REVIEWS_KEY = 'job:reviews';
@ -2299,37 +2347,162 @@ async function readJobReviews(): Promise<StoredJobReview[]> {
return raw ? (JSON.parse(raw) as StoredJobReview[]) : []; return raw ? (JSON.parse(raw) as StoredJobReview[]) : [];
} }
/** 覆盖写回本人评价列表 */
async function writeJobReviews(list: StoredJobReview[]): Promise<void> {
await storageSet(JOB_REVIEWS_KEY, JSON.stringify(list));
}
/** /**
* 发布岗位评价 * 发布岗位评价
* @method POST * @method POST
* @url /api/jobs/:id/reviews * @url /jobs/:jobId/reviews
* @param id 岗位 id * @param jobId 岗位 id
* @param content 评价内容 * @param input 评价内容:评分 / 八维 / 标签 / 正文
* @returns Promise<EmployeeReview> 新建的评价 * @returns Promise<EmployeeReview> 新建的评价
* @remark 真实后端下需做敏感词校验与重复提交限制,此处不做 * @remark 同一学生对同一岗位仅可评价一次(后端约束);mock 阶段以「已有本人评价则覆盖更新」模拟
*/ */
export async function createJobReview(id: string, content: string): Promise<EmployeeReview> { export async function createJobReview(
jobId: string,
input: JobReviewCreateInput,
): Promise<EmployeeReview> {
if (isMockMode()) { if (isMockMode()) {
const all = await readJobReviews();
const existing = all.find((r) => r.jobId === jobId);
const now = new Date().toISOString();
const review: StoredJobReview = { const review: StoredJobReview = {
id: `my-review-${Date.now()}`, id: existing?.id ?? `my-review-${Date.now()}`,
jobId: id, jobId,
userName: '我', userName: '我',
userRole: '我的评价', userRole: '我的评价',
time: '刚刚', time: '刚刚',
content, status: 'PUBLISHED',
tags: [], rating: input.rating,
content: input.content,
tags: input.tags,
dimensionScores: input.dimensionScores,
canEdit: true,
updatedAt: now,
}; };
const all = await readJobReviews(); const next = existing
all.unshift(review); ? all.map((r) => (r.id === existing.id ? review : r))
await storageSet(JOB_REVIEWS_KEY, JSON.stringify(all)); : [review, ...all];
await writeJobReviews(next);
return mockRequest(review); return mockRequest(review);
} }
return request<EmployeeReview>(`/jobs/${id}/reviews`, { return request<EmployeeReview>(`/jobs/${jobId}/reviews`, {
method: 'POST', 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<EmployeeReview> 单条评价(含八维评分)
*/
export async function fetchJobReviewDetail(
jobId: string,
reviewId: string,
): Promise<EmployeeReview> {
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<EmployeeReview>(`/jobs/${jobId}/reviews/${reviewId}`);
}
/**
* 修改岗位评价(仅本人)
* @method PUT
* @url /jobs/:jobId/reviews/:reviewId
* @returns Promise<EmployeeReview> 更新后的评价
*/
export async function updateJobReview(
jobId: string,
reviewId: string,
input: JobReviewUpdateInput,
): Promise<EmployeeReview> {
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<EmployeeReview>(`/jobs/${jobId}/reviews/${reviewId}`, {
method: 'PUT',
auth: true,
body: input,
}); });
} }
/**
* 删除岗位评价(仅本人)
* @method DELETE
* @url /jobs/:jobId/reviews/:reviewId
* @returns Promise<void>
*/
export async function deleteJobReview(jobId: string, reviewId: string): Promise<void> {
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<void>(`/jobs/${jobId}/reviews/${reviewId}`, {
method: 'DELETE',
auth: true,
});
}
/**
* 岗位评价汇总(平均分 / 分布 / 热门标签)
* @method GET
* @url /jobs/:jobId/reviews/summary
* @returns Promise<JobReviewSummary>
*/
export async function fetchJobReviewSummary(jobId: string): Promise<JobReviewSummary> {
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<string, number>();
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<JobReviewSummary>(`/jobs/${jobId}/reviews/summary`);
}
/** /**
* 获取岗位对比数据(按 jobIds 过滤九大维度评分 + 基本信息 + 福利) * 获取岗位对比数据(按 jobIds 过滤九大维度评分 + 基本信息 + 福利)
* @method GET * @method GET

4
src/api/index.ts

@ -60,6 +60,10 @@ export {
fetchLegalTemplates, fetchLegalTemplates,
fetchEmployeeReviews, fetchEmployeeReviews,
createJobReview, createJobReview,
fetchJobReviewDetail,
updateJobReview,
deleteJobReview,
fetchJobReviewSummary,
fetchEnterpriseEntry, fetchEnterpriseEntry,
fetchHollandAssessment, fetchHollandAssessment,
fetchHollandResult, fetchHollandResult,

9
src/constants/index.ts

@ -372,3 +372,12 @@ export const RESUME_EXPORT_PASSWORD_NOTICE = '密码保护需后端导出服务
/** 导出失败提示:本机生成不可用时给出明确归因,而不是返回假链接 */ /** 导出失败提示:本机生成不可用时给出明确归因,而不是返回假链接 */
export const RESUME_EXPORT_FAIL_HINT = '本机生成 PDF 失败,导出功能需后端导出服务支持'; export const RESUME_EXPORT_FAIL_HINT = '本机生成 PDF 失败,导出功能需后端导出服务支持';
/* ===================== 岗位评价(八维评分 / 标签 / 上限) ===================== */
export {
REVIEW_DIMENSIONS,
REVIEW_RATING_MAX,
REVIEW_TAG_SUGGESTIONS,
emptyDimensionScores,
} from './review';

45
src/constants/review.ts

@ -0,0 +1,45 @@
/**
* 岗位评价领域常量:八维评分维度、标签候选、评分上限。
*
* 八维与后端「八维评分」对齐;维度 key 同时是 `EmployeeReview.dimensionScores`
* 的键,发送创建/修改请求时直接用这套 key 拼成 `Record<string, number>`。
*/
/** 单维评分上限(星级) */
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<string, number> {
return REVIEW_DIMENSIONS.reduce<Record<string, number>>((acc, dim) => {
acc[dim.key] = 0;
return acc;
}, {});
}

58
src/hooks/useJobDetail.ts

@ -1,19 +1,25 @@
/** /**
* 岗位详情数据 hook:并行组合岗位详情、综合评分、薪资区间、员工评价四类数据, * 岗位详情数据 hook:并行组合岗位详情、综合评分、薪资区间、员工评价、评价汇总五类数据,
* 统一管理 loading / error / refresh 三态。 * 统一管理 loading / error / refresh 三态,并暴露评价的增 / 改 / 删动作。
*/ */
import { useCallback } from 'react'; import { useCallback } from 'react';
import { import {
createJobReview, createJobReview,
deleteJobReview,
fetchEmployeeReviews, fetchEmployeeReviews,
fetchJobDetail, fetchJobDetail,
fetchJobOverallScore, fetchJobOverallScore,
fetchJobReviewSummary,
fetchSalaryData, fetchSalaryData,
updateJobReview,
} from '../api'; } from '../api';
import type { import type {
EmployeeReview, EmployeeReview,
JobDetail, JobDetail,
JobOverallScore, JobOverallScore,
JobReviewCreateInput,
JobReviewSummary,
JobReviewUpdateInput,
SalaryData, SalaryData,
} from '../types'; } from '../types';
import { useAsyncData } from './useAsyncData'; import { useAsyncData } from './useAsyncData';
@ -24,39 +30,67 @@ interface JobDetailData {
overallScore: JobOverallScore; overallScore: JobOverallScore;
salaryData: SalaryData; salaryData: SalaryData;
reviews: EmployeeReview[]; reviews: EmployeeReview[];
reviewSummary: JobReviewSummary;
} }
export function useJobDetail(id: string) { export function useJobDetail(id: string) {
const fetcher = useCallback(async (): Promise<JobDetailData> => { const fetcher = useCallback(async (): Promise<JobDetailData> => {
const [detail, overallScore, salaryData, reviews] = await Promise.all([ const [detail, overallScore, salaryData, reviews, reviewSummary] =
fetchJobDetail(id), await Promise.all([
fetchJobOverallScore(id), fetchJobDetail(id),
fetchSalaryData(id), fetchJobOverallScore(id),
fetchEmployeeReviews(id), fetchSalaryData(id),
]); fetchEmployeeReviews(id),
return { detail, overallScore, salaryData, reviews }; fetchJobReviewSummary(id),
]);
return { detail, overallScore, salaryData, reviews, reviewSummary };
}, [id]); }, [id]);
const { data, loading, error, refresh } = useAsyncData(fetcher); const { data, loading, error, refresh } = useAsyncData(fetcher);
/** 发布岗位评价,成功后自动刷新评价列表 */ /**
* 发布岗位评价,成功后自动刷新评价列表与汇总。
* 同一学生对同一岗位仅可评价一次,后端语义是「再发即覆盖」。
*/
const publishReview = useCallback( const publishReview = useCallback(
async (content: string) => { async (input: JobReviewCreateInput) => {
const created = await createJobReview(id, content); const created = await createJobReview(id, input);
refresh(); refresh();
return created; return created;
}, },
[id, refresh], [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 { return {
detail: data?.detail ?? null, detail: data?.detail ?? null,
overallScore: data?.overallScore ?? null, overallScore: data?.overallScore ?? null,
salaryData: data?.salaryData ?? null, salaryData: data?.salaryData ?? null,
reviews: data?.reviews ?? [], reviews: data?.reviews ?? [],
reviewSummary: data?.reviewSummary ?? null,
loading, loading,
error, error,
refresh, refresh,
publishReview, publishReview,
editReview,
removeReview,
}; };
} }

4
src/types/index.ts

@ -9,6 +9,10 @@ export type {
JobLevel, JobLevel,
JobListParams, JobListParams,
JobOverallScore, JobOverallScore,
JobReviewCreateInput,
JobReviewStatus,
JobReviewSummary,
JobReviewUpdateInput,
SalaryData, SalaryData,
SalaryRange, SalaryRange,
SortType, SortType,

49
src/types/job.ts

@ -113,6 +113,9 @@ export interface SalaryData {
} }
/** 员工评价 */ /** 员工评价 */
/** 岗位评价状态:草稿 / 待审 / 已发布 / 已驳回 / 已删除 */
export type JobReviewStatus = 'DRAFT' | 'PENDING' | 'PUBLISHED' | 'REJECTED' | 'DELETED';
export interface EmployeeReview { export interface EmployeeReview {
id: string; id: string;
/** 用户名(脱敏),如 "张**" */ /** 用户名(脱敏),如 "张**" */
@ -125,5 +128,51 @@ export interface EmployeeReview {
content: string; content: string;
/** 评价标签 */ /** 评价标签 */
tags: string[]; tags: string[];
/** 综合评分 1-5(后端公开列表含八维评分,这里用单分概括) */
rating?: number;
/** 八维评分,键见 constants/review 的 REVIEW_DIMENSIONS,值为 1-5 */
dimensionScores?: Record<string, number>;
/** 审核状态;列表只返回 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<string, number>;
}
/** 修改岗位评价入参(对齐 PUT /jobs/{jobId}/reviews/{reviewId},字段均可选) */
export interface JobReviewUpdateInput {
rating?: number;
content?: string;
tags?: string[];
dimensionScores?: Record<string, number>;
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[];
} }

Loading…
Cancel
Save