|
|
|
@ -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 ( |
|
|
|
<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() { |
|
|
|
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<EmployeeReview | null>(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() { |
|
|
|
<Text style={styles.salarySource}>{salaryData.dataSource}</Text> |
|
|
|
</View> |
|
|
|
|
|
|
|
{/* 4. 员工评价 */} |
|
|
|
{/* 4. 员工评价(汇总 → 列表) */} |
|
|
|
<SectionTitle title="员工评价" /> |
|
|
|
{reviewSummary ? <ReviewSummaryCard summary={reviewSummary} /> : null} |
|
|
|
{reviews.length === 0 && ( |
|
|
|
/* 标题是常驻的,没有这一块时下方会是一片空白,看上去像加载坏了 */ |
|
|
|
<View style={styles.emptyCard}> |
|
|
|
@ -335,45 +736,40 @@ export default function JobDetailScreen() { |
|
|
|
</View> |
|
|
|
)} |
|
|
|
{reviews.map((review) => ( |
|
|
|
<View key={review.id} 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> |
|
|
|
<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> |
|
|
|
<ReviewCard |
|
|
|
key={review.id} |
|
|
|
review={review} |
|
|
|
onEdit={() => openEdit(review)} |
|
|
|
onDelete={() => handleDeleteReview(review)} |
|
|
|
/> |
|
|
|
))} |
|
|
|
</ScrollView> |
|
|
|
|
|
|
|
{/* 5. 评论输入框(固定底部) */} |
|
|
|
{/* 5. 评价入口(固定底部):已有本人评价时改为编辑,避免用户以为能重复发布 */} |
|
|
|
<View style={styles.commentBar}> |
|
|
|
<TextInput |
|
|
|
style={styles.commentInput} |
|
|
|
placeholder="说说你对这个岗位的看法..." |
|
|
|
placeholderTextColor={colors.textHint} |
|
|
|
value={comment} |
|
|
|
onChangeText={setComment} |
|
|
|
/> |
|
|
|
<Pressable |
|
|
|
style={[styles.publishBtn, publishing && styles.publishBtnDisabled]} |
|
|
|
onPress={handlePublish} |
|
|
|
disabled={publishing} |
|
|
|
style={styles.composeBtn} |
|
|
|
onPress={myReview ? () => openEdit(myReview) : openCreate} |
|
|
|
> |
|
|
|
{publishing ? ( |
|
|
|
<ActivityIndicator size="small" color={colors.white} /> |
|
|
|
) : ( |
|
|
|
<Text style={styles.publishText}>发布</Text> |
|
|
|
)} |
|
|
|
<Ionicons |
|
|
|
name={myReview ? 'create-outline' : 'pencil-outline'} |
|
|
|
size={16} |
|
|
|
color={colors.white} |
|
|
|
/> |
|
|
|
<Text style={styles.publishText}>{myReview ? '编辑我的评价' : '写评价'}</Text> |
|
|
|
</Pressable> |
|
|
|
</View> |
|
|
|
|
|
|
|
<ReviewEditorModal |
|
|
|
visible={editorOpen} |
|
|
|
initial={editing} |
|
|
|
submitting={submitting} |
|
|
|
onClose={() => { |
|
|
|
setEditorOpen(false); |
|
|
|
setEditing(null); |
|
|
|
}} |
|
|
|
onSubmit={handleSubmitReview} |
|
|
|
/> |
|
|
|
</SafeAreaView> |
|
|
|
); |
|
|
|
} |
|
|
|
@ -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, |
|
|
|
}, |
|
|
|
}); |
|
|
|
|