import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { useEffect, useState } from 'react'; import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import Svg, { Line, Path, Polygon, Text as SvgText } from 'react-native-svg'; import { useJobEvaluationEmptyState, useJobEvaluations } from '../src/hooks'; import { LoadingState, ErrorState } from '../src/components/StateView'; import {colors, spacing} from '../src/theme'; import type { JobEvaluationDimension, JobEvaluationEmptyState, JobEvaluationEmptyTab, JobEvaluationSummary, JobReview, } from '../src/types'; /* ===================== 子组件:顶部导航 ===================== */ function Header({ onBack }: { onBack: () => void }) { return ( 岗位评价管理 {/* 原先这里是「筛选」图标:筛选面板尚未定义,先摘掉,做筛选时再挂回来 */} ); } /* ===================== 子组件:综合评分卡 ===================== */ function OverallScoreCard({ score, change, reviewCount, positiveRate, replyRate, complaintRate, }: { score: number; change: number; reviewCount: number; positiveRate: number; replyRate: number; complaintRate: number; }) { const up = change >= 0; return ( {score.toFixed(1)} 企业综合评分 较上期 {up ? '+' : ''}{change.toFixed(1)} {reviewCount} 条评价 {positiveRate}% 好评率 {replyRate}% 回复率 {complaintRate}% 投诉率 ); } /* ===================== 子组件:趋势折线图 ===================== */ function TrendChart({ trend }: { trend: { month: string; score: number }[] }) { const width = 320; const height = 120; const padding = { top: 16, right: 24, bottom: 24, left: 32 }; const chartW = width - padding.left - padding.right; const chartH = height - padding.top - padding.bottom; const minScore = 4.0; const maxScore = 5.0; const points = trend.map((t, i) => { const x = padding.left + (i / (trend.length - 1)) * chartW; const y = padding.top + chartH - ((t.score - minScore) / (maxScore - minScore)) * chartH; return { x, y, score: t.score, month: t.month }; }); const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' '); return ( 评分趋势 {/* 网格线 */} {[4.2, 4.4, 4.6, 4.8].map((s) => { const y = padding.top + chartH - ((s - minScore) / (maxScore - minScore)) * chartH; return ; })} {/* 折线 */} {/* 数据点 */} {points.map((p, i) => ( ))} {/* X 轴标签 */} {points.map((p, i) => ( {p.month} ))} ); } /* ===================== 子组件:雷达图 ===================== */ function RadarChart({ dimensions }: { dimensions: JobEvaluationDimension[] }) { const size = 200; const center = size / 2; const radius = 70; const angleStep = (Math.PI * 2) / dimensions.length; const points = dimensions.map((d, i) => { const angle = i * angleStep - Math.PI / 2; const r = (d.score / d.maxScore) * radius; const x = center + r * Math.cos(angle); const y = center + r * Math.sin(angle); return { x, y, label: d.name }; }); const axisPoints = dimensions.map((_, i) => { const angle = i * angleStep - Math.PI / 2; return { x: center + radius * Math.cos(angle), y: center + radius * Math.sin(angle), label: dimensions[i].name, }; }); const polygonPoints = points.map((p) => `${p.x},${p.y}`).join(' '); return ( 六维评分 {/* 网格圆 */} {[0.2, 0.4, 0.6, 0.8, 1].map((scale) => ( { const px = center + (a.x - center) * scale; const py = center + (a.y - center) * scale; return `${px},${py}`; }).join(' ')} fill="none" stroke={colors.borderLight} strokeWidth={1} /> ))} {/* 轴线 */} {axisPoints.map((a, i) => ( ))} {/* 数据区域 */} {/* 标签 */} {axisPoints.map((a, i) => { const labelX = center + (a.x - center) * 1.22; const labelY = center + (a.y - center) * 1.22; return ( {a.label} ); })} ); } /* ===================== 子组件:岗位评分列表 ===================== */ function JobScoreList({ jobs }: { jobs: JobEvaluationSummary[] }) { return ( 各岗位评分 {jobs.map((job) => { const up = job.change >= 0; return ( {job.positionName} {job.department} · {job.reviewCount} 条评价 {job.score.toFixed(1)} {up ? '↑' : '↓'} {Math.abs(job.change).toFixed(1)} ); })} ); } /* ===================== 子组件:空状态 Tab 栏 ===================== */ function EmptyTabBar({ tabs, active, onChange, }: { tabs: JobEvaluationEmptyTab[]; active: string; onChange: (key: string) => void; }) { return ( {tabs.map((tab) => { const isActive = tab.key === active; return ( onChange(tab.key)} > {tab.label} ({tab.count}) ); })} ); } /* ===================== 子组件:空状态 ===================== */ function EmptyState({ state }: { state: JobEvaluationEmptyState }) { const [activeTab, setActiveTab] = useState('all'); return ( 暂无评分数据 {state.emptyTitle} {state.emptySubtitle} 如何获得更多好评? {state.tips.map((tip, index) => ( {tip} ))} ); } /* ===================== 子组件:评价列表 ===================== */ function ReviewList({ reviews, onReply, }: { reviews: JobReview[]; onReply: (review: JobReview) => void; }) { return ( 最新评价 {reviews.map((review) => ( {review.userName} {review.userRole} {review.rating}.0 {review.content} {review.tags.map((tag) => ( {tag} ))} {/* 已回复的评价把回复内容展示出来,否则「已回复」只是个说不清的标签 */} {review.replied && review.replyContent ? ( 企业回复 {review.replyContent} ) : null} {review.date} {/* 已回复的评价不再提供重复回复入口 */} {review.replied ? ( 已回复 ) : ( onReply(review)}> 回复 )} ))} ); } /* ===================== 子组件:回复输入弹层 ===================== */ /** 回复弹层:用 Modal + TextInput 而不是 Alert.prompt(后者只有 iOS 有) */ function ReplyModal({ review, submitting, onCancel, onSubmit, }: { review: JobReview | null; submitting: boolean; onCancel: () => void; onSubmit: (content: string) => void; }) { const [content, setContent] = useState(''); // 换一条评价就清空输入,避免把上一条的回复带过去 useEffect(() => { setContent(''); }, [review?.id]); const trimmed = content.trim(); return ( 回复评价 {review ? ( {review.userName}:{review.content} ) : null} 取消 trimmed !== '' && onSubmit(trimmed)} disabled={trimmed === '' || submitting} > {submitting ? '提交中' : '提交回复'} ); } /* ===================== 页面主组件 ===================== */ export default function JobEvaluationsScreen() { const router = useRouter(); const { management, loading: dataLoading, error: dataError, refresh, reply, replyingId, } = useJobEvaluations(); const { state: emptyState, loading: emptyLoading, error: emptyError } = useJobEvaluationEmptyState(); /** 正在回复的评价;null 表示弹层关闭 */ const [replying, setReplying] = useState(null); const loading = dataLoading || emptyLoading; const error = dataError || emptyError; /** 提交回复:成功后重新拉数据(回复率与回复内容都要跟着变),失败给出可读提示 */ const submitReply = (content: string) => { if (!replying) return; void reply(replying.id, content) .then(() => { setReplying(null); refresh(); }) .catch((e: unknown) => { Alert.alert('回复失败', e instanceof Error ? e.message : '请稍后重试'); }); }; if (loading) { return ( ); } if (error) { return ( ); } const showEmpty = emptyState?.hasData === false; return (
router.back()} /> {showEmpty && emptyState ? ( ) : management ? ( <> ) : null} setReplying(null)} onSubmit={submitReply} /> ); } /* ===================== 样式 ===================== */ const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.pageBg, }, loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: colors.pageBg, }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: spacing.framework, paddingVertical: 12, backgroundColor: colors.white, borderBottomWidth: 1, borderBottomColor: colors.borderLight, }, headerBtn: { width: 36, height: 36, justifyContent: 'center', alignItems: 'center', }, headerTitle: { fontSize: 17, fontWeight: '600', color: colors.textPrimary, }, scroll: { flex: 1, }, scrollContent: { padding: spacing.card, paddingBottom: 32, }, scoreCard: { backgroundColor: colors.primary, borderRadius: spacing.card, padding: spacing.card, marginBottom: 8, }, scoreMain: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20, }, scoreValue: { fontSize: 42, fontWeight: '700', color: colors.white, }, scoreLabel: { fontSize: 14, color: colors.white, opacity: 0.9, }, scoreMeta: { alignItems: 'flex-end', }, scoreChange: { fontSize: 14, fontWeight: '600', marginBottom: 4, }, scoreCount: { fontSize: 13, color: colors.white, opacity: 0.9, }, rateRow: { flexDirection: 'row', borderTopWidth: 1, borderTopColor: colors.onBrandLine, paddingTop: 16, }, rateItem: { flex: 1, alignItems: 'center', }, rateValue: { fontSize: 18, fontWeight: '700', color: colors.white, marginBottom: 2, }, rateLabel: { fontSize: 12, color: colors.white, opacity: 0.9, }, card: { backgroundColor: colors.white, borderRadius: spacing.card, padding: spacing.card, marginBottom: 8, }, cardTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary, marginBottom: 12, }, chartCard: { backgroundColor: colors.white, borderRadius: spacing.card, padding: spacing.card, marginBottom: 8, alignItems: 'center', }, radarCard: { backgroundColor: colors.white, borderRadius: spacing.card, padding: spacing.card, marginBottom: 8, alignItems: 'center', }, jobRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.borderLight, }, jobInfo: { flex: 1, }, jobName: { fontSize: 15, fontWeight: '600', color: colors.textPrimary, marginBottom: 2, }, jobDepartment: { fontSize: 12, color: colors.textSecondary, }, jobScoreBox: { alignItems: 'flex-end', }, jobScore: { fontSize: 18, fontWeight: '700', color: colors.primary, }, jobChange: { fontSize: 11, fontWeight: '500', marginTop: 2, }, reviewItem: { paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: colors.borderLight, }, reviewHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8, }, reviewUser: { flexDirection: 'row', alignItems: 'center', }, reviewName: { fontSize: 14, fontWeight: '600', color: colors.textPrimary, marginRight: 8, }, reviewRole: { fontSize: 12, color: colors.textSecondary, }, reviewRating: { flexDirection: 'row', alignItems: 'center', }, reviewScore: { fontSize: 14, fontWeight: '700', color: colors.compareOrange, marginRight: 4, }, reviewContent: { fontSize: 14, color: colors.textPrimary, lineHeight: 20, marginBottom: 10, }, tagRow: { flexDirection: 'row', flexWrap: 'wrap', marginBottom: 10, }, reviewTag: { paddingHorizontal: spacing.card, paddingVertical: 4, borderRadius: 12, backgroundColor: colors.primaryLight, marginRight: 8, marginBottom: 6, }, reviewTagText: { fontSize: 11, color: colors.primaryText, }, reviewFooter: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, reviewDate: { fontSize: 12, color: colors.textHint, }, replyBtn: { paddingHorizontal: 12, paddingVertical: 5, borderRadius: 12, backgroundColor: colors.primaryLight, }, replyText: { fontSize: 12, color: colors.primary, fontWeight: '500', }, /** 已回复:不再是按钮,只是一个状态说明 */ repliedText: { fontSize: 12, color: colors.successText, fontWeight: '500', }, /* ---- 回复内容块 ---- */ replyBlock: { marginTop: 8, padding: spacing.card, borderRadius: spacing.card, backgroundColor: colors.successLight, }, replyBlockLabel: { fontSize: 12, fontWeight: '600', color: colors.successText, }, replyBlockText: { marginTop: 4, fontSize: 13, lineHeight: 20, color: colors.textPrimary, }, /* ---- 回复弹层 ---- */ modalMask: { flex: 1, backgroundColor: colors.scrim, justifyContent: 'center', paddingHorizontal: 28, }, modalCard: { backgroundColor: colors.surface, borderRadius: spacing.card, padding: spacing.card, }, modalTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary, }, modalQuote: { marginTop: 8, fontSize: 13, lineHeight: 20, color: colors.textSecondary, }, modalInput: { marginTop: 12, minHeight: 88, borderRadius: spacing.card, borderWidth: 1, borderColor: colors.border, paddingHorizontal: 12, paddingVertical: 10, fontSize: 14, color: colors.textPrimary, textAlignVertical: 'top', }, modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 12, marginTop: 16, }, modalCancel: { paddingHorizontal: spacing.framework, paddingVertical: 10, }, modalCancelText: { fontSize: 14, color: colors.textSecondary, }, modalSubmit: { paddingHorizontal: 18, paddingVertical: 10, borderRadius: 20, backgroundColor: colors.primaryBtn, }, modalSubmitOff: { backgroundColor: colors.textDisabled, }, modalSubmitText: { fontSize: 14, fontWeight: '600', color: colors.white, }, /* ---- 空状态 ---- */ emptyWrapper: { marginBottom: 16, }, emptyTabContent: { paddingHorizontal: spacing.card, paddingVertical: 12, gap: 8, }, emptyTabItem: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16, backgroundColor: colors.pageBg, borderWidth: 1, borderColor: colors.border, }, emptyTabItemActive: { backgroundColor: colors.primaryLight, borderColor: colors.primary, }, emptyTabLabel: { fontSize: 13, color: colors.textSecondary, }, emptyTabLabelActive: { color: colors.primary, fontWeight: '600', }, emptyTabCount: { fontSize: 12, color: colors.textHint, marginLeft: 2, }, emptyTabCountActive: { color: colors.primary, }, emptyCard: { backgroundColor: colors.white, borderRadius: spacing.card, padding: spacing.card, alignItems: 'center', marginTop: 8, }, emptyIconWrap: { width: 88, height: 88, borderRadius: 44, backgroundColor: colors.primaryLight, alignItems: 'center', justifyContent: 'center', marginBottom: 16, }, emptyMainTitle: { fontSize: 18, fontWeight: '700', color: colors.textPrimary, marginBottom: 8, }, emptyTitle: { fontSize: 16, fontWeight: '600', color: colors.textPrimary, marginBottom: 6, }, emptySubtitle: { fontSize: 13, color: colors.textSecondary, textAlign: 'center', lineHeight: 20, marginBottom: 20, }, tipCard: { width: '100%', backgroundColor: colors.actionLight, borderRadius: spacing.card, padding: spacing.card, alignItems: 'flex-start', }, tipHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12, }, tipHeaderTitle: { fontSize: 14, fontWeight: '700', color: colors.actionText, marginLeft: 8, }, tipRow: { flexDirection: 'row', alignItems: 'flex-start', marginBottom: 10, }, tipDot: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.action, marginTop: 6, marginRight: 10, }, tipText: { flex: 1, fontSize: 13, color: colors.textPrimary, lineHeight: 18, }, });