轻松聘
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

947 lines
25 KiB

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 } from '../src/theme';
import type {
JobEvaluationDimension,
JobEvaluationEmptyState,
JobEvaluationEmptyTab,
JobEvaluationSummary,
JobReview,
} from '../src/types';
/* ===================== 子组件:顶部导航 ===================== */
function Header({ onBack }: { onBack: () => void }) {
return (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>岗位评价管理</Text>
{/* 原先这里是「筛选」图标:筛选面板尚未定义,先摘掉,做筛选时再挂回来 */}
<View style={styles.headerBtn} />
</View>
);
}
/* ===================== 子组件:综合评分卡 ===================== */
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 (
<View style={styles.scoreCard}>
<View style={styles.scoreMain}>
<View>
<Text style={styles.scoreValue}>{score.toFixed(1)}</Text>
<Text style={styles.scoreLabel}>企业综合评分</Text>
</View>
<View style={styles.scoreMeta}>
<Text style={[styles.scoreChange, { color: up ? colors.success : colors.danger }]}>
较上期 {up ? '+' : ''}{change.toFixed(1)}
</Text>
<Text style={styles.scoreCount}>{reviewCount} 条评价</Text>
</View>
</View>
<View style={styles.rateRow}>
<View style={styles.rateItem}>
<Text style={styles.rateValue}>{positiveRate}%</Text>
<Text style={styles.rateLabel}>好评率</Text>
</View>
<View style={styles.rateItem}>
<Text style={styles.rateValue}>{replyRate}%</Text>
<Text style={styles.rateLabel}>回复率</Text>
</View>
<View style={styles.rateItem}>
<Text style={styles.rateValue}>{complaintRate}%</Text>
<Text style={styles.rateLabel}>投诉率</Text>
</View>
</View>
</View>
);
}
/* ===================== 子组件:趋势折线图 ===================== */
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 (
<View style={styles.chartCard}>
<Text style={styles.cardTitle}>评分趋势</Text>
<Svg width={width} height={height}>
{/* 网格线 */}
{[4.2, 4.4, 4.6, 4.8].map((s) => {
const y = padding.top + chartH - ((s - minScore) / (maxScore - minScore)) * chartH;
return <Line key={s} x1={padding.left} y1={y} x2={width - padding.right} y2={y} stroke={colors.borderLight} strokeWidth={1} />;
})}
{/* 折线 */}
<Path d={pathD} stroke={colors.primary} strokeWidth={2} fill="none" />
{/* 数据点 */}
{points.map((p, i) => (
<Polygon
key={i}
points={`${p.x - 4},${p.y - 4} ${p.x + 4},${p.y - 4} ${p.x},${p.y + 4}`}
fill={colors.primary}
/>
))}
{/* X 轴标签 */}
{points.map((p, i) => (
<SvgText key={i} x={p.x} y={height - 6} fill={colors.textSecondary} fontSize={10} textAnchor="middle">
{p.month}
</SvgText>
))}
</Svg>
</View>
);
}
/* ===================== 子组件:雷达图 ===================== */
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 (
<View style={styles.radarCard}>
<Text style={styles.cardTitle}>六维评分</Text>
<Svg width={size} height={size}>
{/* 网格圆 */}
{[0.2, 0.4, 0.6, 0.8, 1].map((scale) => (
<Polygon
key={scale}
points={axisPoints.map((a) => {
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) => (
<Line key={i} x1={center} y1={center} x2={a.x} y2={a.y} stroke={colors.borderLight} strokeWidth={1} />
))}
{/* 数据区域 */}
<Polygon points={polygonPoints} fill={colors.primary + '30'} stroke={colors.primary} strokeWidth={2} />
{/* 标签 */}
{axisPoints.map((a, i) => {
const labelX = center + (a.x - center) * 1.22;
const labelY = center + (a.y - center) * 1.22;
return (
<SvgText key={i} x={labelX} y={labelY} fill={colors.textSecondary} fontSize={10} textAnchor="middle" alignmentBaseline="middle">
{a.label}
</SvgText>
);
})}
</Svg>
</View>
);
}
/* ===================== 子组件:岗位评分列表 ===================== */
function JobScoreList({ jobs }: { jobs: JobEvaluationSummary[] }) {
return (
<View style={styles.card}>
<Text style={styles.cardTitle}>各岗位评分</Text>
{jobs.map((job) => {
const up = job.change >= 0;
return (
<View key={job.id} style={styles.jobRow}>
<View style={styles.jobInfo}>
<Text style={styles.jobName}>{job.positionName}</Text>
<Text style={styles.jobDepartment}>{job.department} · {job.reviewCount} 条评价</Text>
</View>
<View style={styles.jobScoreBox}>
<Text style={styles.jobScore}>{job.score.toFixed(1)}</Text>
<Text style={[styles.jobChange, { color: up ? colors.success : colors.danger }]}>
{up ? '↑' : '↓'} {Math.abs(job.change).toFixed(1)}
</Text>
</View>
</View>
);
})}
</View>
);
}
/* ===================== 子组件:空状态 Tab 栏 ===================== */
function EmptyTabBar({
tabs,
active,
onChange,
}: {
tabs: JobEvaluationEmptyTab[];
active: string;
onChange: (key: string) => void;
}) {
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.emptyTabContent}
>
{tabs.map((tab) => {
const isActive = tab.key === active;
return (
<Pressable
key={tab.key}
style={[styles.emptyTabItem, isActive && styles.emptyTabItemActive]}
onPress={() => onChange(tab.key)}
>
<Text style={[styles.emptyTabLabel, isActive && styles.emptyTabLabelActive]}>
{tab.label}
</Text>
<Text style={[styles.emptyTabCount, isActive && styles.emptyTabCountActive]}>
({tab.count})
</Text>
</Pressable>
);
})}
</ScrollView>
);
}
/* ===================== 子组件:空状态 ===================== */
function EmptyState({ state }: { state: JobEvaluationEmptyState }) {
const [activeTab, setActiveTab] = useState('all');
return (
<View style={styles.emptyWrapper}>
<EmptyTabBar tabs={state.tabs} active={activeTab} onChange={setActiveTab} />
<View style={styles.emptyCard}>
<View style={styles.emptyIconWrap}>
<Ionicons name="star-outline" size={48} color={colors.primary} />
</View>
<Text style={styles.emptyMainTitle}>暂无评分数据</Text>
<Text style={styles.emptyTitle}>{state.emptyTitle}</Text>
<Text style={styles.emptySubtitle}>{state.emptySubtitle}</Text>
<View style={styles.tipCard}>
<View style={styles.tipHeader}>
<Ionicons name="bulb-outline" size={18} color={colors.compareOrange} />
<Text style={styles.tipHeaderTitle}>如何获得更多好评?</Text>
</View>
{state.tips.map((tip, index) => (
<View key={index} style={styles.tipRow}>
<View style={styles.tipDot} />
<Text style={styles.tipText}>{tip}</Text>
</View>
))}
</View>
</View>
</View>
);
}
/* ===================== 子组件:评价列表 ===================== */
function ReviewList({
reviews,
onReply,
}: {
reviews: JobReview[];
onReply: (review: JobReview) => void;
}) {
return (
<View style={styles.card}>
<Text style={styles.cardTitle}>最新评价</Text>
{reviews.map((review) => (
<View key={review.id} style={styles.reviewItem}>
<View style={styles.reviewHeader}>
<View style={styles.reviewUser}>
<Text style={styles.reviewName}>{review.userName}</Text>
<Text style={styles.reviewRole}>{review.userRole}</Text>
</View>
<View style={styles.reviewRating}>
<Text style={styles.reviewScore}>{review.rating}.0</Text>
<Ionicons name="star" size={12} color={colors.compareOrange} />
</View>
</View>
<Text style={styles.reviewContent}>{review.content}</Text>
<View style={styles.tagRow}>
{review.tags.map((tag) => (
<View key={tag} style={styles.reviewTag}>
<Text style={styles.reviewTagText}>{tag}</Text>
</View>
))}
</View>
{/* 已回复的评价把回复内容展示出来,否则「已回复」只是个说不清的标签 */}
{review.replied && review.replyContent ? (
<View style={styles.replyBlock}>
<Text style={styles.replyBlockLabel}>企业回复</Text>
<Text style={styles.replyBlockText}>{review.replyContent}</Text>
</View>
) : null}
<View style={styles.reviewFooter}>
<Text style={styles.reviewDate}>{review.date}</Text>
{/* 已回复的评价不再提供重复回复入口 */}
{review.replied ? (
<Text style={styles.repliedText}>已回复</Text>
) : (
<Pressable style={styles.replyBtn} onPress={() => onReply(review)}>
<Text style={styles.replyText}>回复</Text>
</Pressable>
)}
</View>
</View>
))}
</View>
);
}
/* ===================== 子组件:回复输入弹层 ===================== */
/** 回复弹层:用 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 (
<Modal visible={review !== null} transparent animationType="fade" onRequestClose={onCancel}>
<View style={styles.modalMask}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>回复评价</Text>
{review ? (
<Text style={styles.modalQuote} numberOfLines={3}>
{review.userName}:{review.content}
</Text>
) : null}
<TextInput
style={styles.modalInput}
value={content}
onChangeText={setContent}
placeholder="写下你的回复,会展示在该评价下方"
placeholderTextColor={colors.textHint}
multiline
maxLength={200}
editable={!submitting}
/>
<View style={styles.modalActions}>
<Pressable style={styles.modalCancel} onPress={onCancel} disabled={submitting}>
<Text style={styles.modalCancelText}>取消</Text>
</Pressable>
<Pressable
style={[styles.modalSubmit, (trimmed === '' || submitting) && styles.modalSubmitOff]}
onPress={() => trimmed !== '' && onSubmit(trimmed)}
disabled={trimmed === '' || submitting}
>
<Text style={styles.modalSubmitText}>{submitting ? '提交中' : '提交回复'}</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
);
}
/* ===================== 页面主组件 ===================== */
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<JobReview | null>(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 (
<SafeAreaView style={styles.loadingContainer} edges={['top']}>
<LoadingState text="加载中…" />
</SafeAreaView>
);
}
if (error) {
return (
<SafeAreaView style={styles.loadingContainer} edges={['top']}>
<ErrorState onRetry={refresh} />
</SafeAreaView>
);
}
const showEmpty = emptyState?.hasData === false;
return (
<View style={styles.container}>
<SafeAreaView edges={['top']}>
<Header onBack={() => router.back()} />
</SafeAreaView>
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{showEmpty && emptyState ? (
<EmptyState state={emptyState} />
) : management ? (
<>
<OverallScoreCard
score={management.overallScore}
change={management.change}
reviewCount={management.reviewCount}
positiveRate={management.positiveRate}
replyRate={management.replyRate}
complaintRate={management.complaintRate}
/>
<TrendChart trend={management.trend} />
<RadarChart dimensions={management.dimensions} />
<JobScoreList jobs={management.jobScores} />
<ReviewList reviews={management.reviews} onReply={setReplying} />
</>
) : null}
</ScrollView>
<ReplyModal
review={replying}
submitting={replying !== null && replyingId === replying.id}
onCancel={() => setReplying(null)}
onSubmit={submitReply}
/>
</View>
);
}
/* ===================== 样式 ===================== */
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: 16,
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: 16,
paddingBottom: 32,
},
scoreCard: {
backgroundColor: colors.primary,
borderRadius: 16,
padding: 20,
marginBottom: 16,
},
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: 16,
padding: 16,
marginBottom: 16,
shadowColor: colors.shadow,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.04,
shadowRadius: 6,
elevation: 2,
},
cardTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: 12,
},
chartCard: {
backgroundColor: colors.white,
borderRadius: 16,
padding: 16,
marginBottom: 16,
alignItems: 'center',
},
radarCard: {
backgroundColor: colors.white,
borderRadius: 16,
padding: 16,
marginBottom: 16,
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: 8,
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: 10,
padding: 10,
borderRadius: 8,
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: 14,
padding: 18,
},
modalTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
modalQuote: {
marginTop: 8,
fontSize: 13,
lineHeight: 20,
color: colors.textSecondary,
},
modalInput: {
marginTop: 12,
minHeight: 88,
borderRadius: 8,
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: 16,
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: 8,
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: 16,
padding: 28,
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: 12,
padding: 16,
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,
},
});