Browse Source

feat(page): 实现隐私中心 8 子页与注销/删数据/隐私政策/用户协议;竞争力排名与岗位评价管理补数据态

隐私中心 8 子页:
- app/settings/privacy/[id].tsx(动态路由):真实姓名可见性、联系方式、算法训练、第三方分析、位置授权、通讯录 6 个开关类二级页
- app/settings/privacy/dnd.tsx:勿扰模式(时段设置 + 重要通知穿透)
- app/settings/privacy/keywords.tsx:关键词屏蔽(增删 + 系统建议)

合规 4 页(上架硬阻塞):
- app/settings/account-cancel.tsx:账号注销(后果 + 待办 + 原因 + 二次确认)
- app/settings/delete-data.tsx:删除数据(分类勾选 + 输入确认)
- app/settings/privacy-policy.tsx / user-agreement.tsx:法律文档(共享 LegalDocScreen 渲染)

数据态:竞争力排名(app/jobs/rank.tsx)、岗位评价管理(app/job-evaluations.tsx)已展示数据态

所有页面沿用 theme token,无硬编码颜色;入口均已在隐私中心 mock 接入,无孤儿页面
master
doublex001 2 months ago
parent
commit
78de95135f
  1. 350
      app/settings/account-cancel.tsx
  2. 373
      app/settings/delete-data.tsx
  3. 5
      app/settings/privacy-policy.tsx
  4. 279
      app/settings/privacy/[id].tsx
  5. 295
      app/settings/privacy/dnd.tsx
  6. 357
      app/settings/privacy/keywords.tsx
  7. 5
      app/settings/user-agreement.tsx
  8. 204
      src/components/LegalDocScreen.tsx

350
app/settings/account-cancel.tsx

@ -0,0 +1,350 @@
import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAccountCancel } from '../../src/hooks';
import { colors } from '../../src/theme';
/* ===================== 子组件:顶部导航 ===================== */
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>
);
}
/* ===================== 页面 ===================== */
export default function AccountCancelScreen() {
const router = useRouter();
const { data, loading, error, refresh, submit } = useAccountCancel();
const [reason, setReason] = useState('');
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState<{ success: boolean; message: string } | null>(null);
const handleSubmit = async () => {
setSubmitting(true);
try {
const res = await submit(reason);
setDone(res);
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<View style={styles.stateBox}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
if (error || !data) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<View style={styles.stateBox}>
<Ionicons name="alert-circle-outline" size={40} color={colors.danger} />
<Text style={styles.stateText}>加载失败,请重试</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
if (done?.success) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<View style={styles.stateBox}>
<Ionicons name="checkmark-circle" size={48} color={colors.success} />
<Text style={styles.stateText}>{done.message}</Text>
<Pressable style={styles.retryBtn} onPress={() => router.back()}>
<Text style={styles.retryText}>返回</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} />
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<View style={[styles.warnCard, { backgroundColor: colors.dangerLight }]}>
<Ionicons name="warning-outline" size={18} color={colors.dangerText} />
<Text style={[styles.warnText, { color: colors.dangerText }]}>
注销后账号将无法恢复,请谨慎操作
</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>注销后将发生</Text>
<View style={styles.card}>
{data.consequences.map((item, index) => (
<View
key={index}
style={[styles.bulletRow, index < data.consequences.length - 1 && styles.bulletBorder]}
>
<Ionicons name="close-circle-outline" size={18} color={colors.danger} />
<Text style={styles.bulletText}>{item}</Text>
</View>
))}
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>注销前待办</Text>
<View style={styles.card}>
{data.checklist.map((item, index) => (
<View
key={item.id}
style={[styles.checkRow, index < data.checklist.length - 1 && styles.rowBorder]}
>
<View style={styles.rowBody}>
<Text style={styles.rowTitle}>{item.label}</Text>
{item.actionText ? (
<Pressable onPress={() => console.log('[account-cancel] 去处理', item.id)}>
<Text style={styles.actionText}>{item.actionText}</Text>
</Pressable>
) : null}
</View>
<Ionicons
name={item.resolved ? 'checkmark-circle' : 'ellipse-outline'}
size={20}
color={item.resolved ? colors.success : colors.textHint}
/>
</View>
))}
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>注销原因(选填)</Text>
<View style={styles.chipWrap}>
{data.reasons.map((r) => {
const active = reason === r;
return (
<Pressable
key={r}
style={[styles.chip, active && styles.chipActive]}
onPress={() => setReason(active ? '' : r)}
>
<Text style={[styles.chipText, active && styles.chipTextActive]}>{r}</Text>
</Pressable>
);
})}
</View>
</View>
<Pressable
style={[styles.submitBtn, (!data.canCancel || submitting) && styles.submitDisabled]}
disabled={!data.canCancel || submitting}
onPress={handleSubmit}
>
<Text style={styles.submitText}>
{submitting ? '提交中…' : data.canCancel ? '申请注销' : '请先完成注销前待办'}
</Text>
</Pressable>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
height: 48,
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
headerBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
flex: 1,
textAlign: 'center',
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
scroll: {
flex: 1,
},
content: {
padding: 12,
paddingBottom: 24,
},
stateBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
paddingHorizontal: 32,
textAlign: 'center',
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
warnCard: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderRadius: 12,
padding: 14,
marginBottom: 16,
},
warnText: {
flex: 1,
fontSize: 13,
fontWeight: '600',
lineHeight: 18,
},
section: {
marginBottom: 16,
},
sectionTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: 10,
},
card: {
backgroundColor: colors.white,
borderRadius: 12,
overflow: 'hidden',
},
bulletRow: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingHorizontal: 14,
paddingVertical: 12,
gap: 8,
},
bulletBorder: {
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
bulletText: {
flex: 1,
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary,
},
checkRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 14,
paddingVertical: 14,
},
rowBorder: {
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
rowBody: {
flex: 1,
marginRight: 12,
},
rowTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
},
actionText: {
marginTop: 3,
fontSize: 12,
fontWeight: '600',
color: colors.primary,
},
chipWrap: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
chip: {
paddingVertical: 7,
paddingHorizontal: 14,
borderRadius: 16,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
},
chipActive: {
backgroundColor: colors.primaryLight,
borderColor: colors.primary,
},
chipText: {
fontSize: 13,
fontWeight: '600',
color: colors.textSecondary,
},
chipTextActive: {
color: colors.primary,
},
submitBtn: {
marginTop: 8,
height: 48,
borderRadius: 24,
backgroundColor: colors.danger,
alignItems: 'center',
justifyContent: 'center',
},
submitDisabled: {
backgroundColor: colors.border,
},
submitText: {
fontSize: 15,
fontWeight: '700',
color: colors.white,
},
});

373
app/settings/delete-data.tsx

@ -0,0 +1,373 @@
import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useDeleteAllData } from '../../src/hooks';
import { colors } from '../../src/theme';
/* ===================== 子组件:顶部导航 ===================== */
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 CheckRow({
title,
desc,
count,
checked,
onPress,
}: {
title: string;
desc?: string;
count?: string;
checked: boolean;
onPress: () => void;
}) {
return (
<Pressable style={styles.row} onPress={onPress}>
<View style={styles.rowBody}>
<Text style={styles.rowTitle}>{title}</Text>
{desc ? <Text style={styles.rowDesc}>{desc}</Text> : null}
</View>
<View style={[styles.checkbox, checked && styles.checkboxActive]}>
{checked ? <Ionicons name="checkmark" size={14} color={colors.white} /> : null}
</View>
{count ? <Text style={styles.rowCount}>{count}</Text> : null}
</Pressable>
);
}
/* ===================== 页面 ===================== */
export default function DeleteAllDataScreen() {
const router = useRouter();
const { data, loading, error, refresh, submit } = useDeleteAllData();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [confirmText, setConfirmText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState<{ success: boolean; message: string } | null>(null);
const toggleCat = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSubmit = async () => {
setSubmitting(true);
try {
const res = await submit(Array.from(selected));
setDone(res);
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<View style={styles.stateBox}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
if (error || !data) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<View style={styles.stateBox}>
<Ionicons name="alert-circle-outline" size={40} color={colors.danger} />
<Text style={styles.stateText}>加载失败,请重试</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
if (done?.success) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<View style={styles.stateBox}>
<Ionicons name="checkmark-circle" size={48} color={colors.success} />
<Text style={styles.stateText}>{done.message}</Text>
<Pressable style={styles.retryBtn} onPress={() => router.back()}>
<Text style={styles.retryText}>返回</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
const canSubmit = selected.size > 0 && confirmText === '删除全部数据' && !submitting;
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} />
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<View style={styles.introCard}>
<Text style={styles.introText}>{data.intro}</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>选择要删除的数据</Text>
<View style={styles.card}>
{data.categories.map((cat) => (
<CheckRow
key={cat.id}
title={cat.label}
desc={cat.description}
count={cat.count}
checked={selected.has(cat.id)}
onPress={() => toggleCat(cat.id)}
/>
))}
</View>
</View>
<View style={[styles.warnCard, { backgroundColor: colors.dangerLight }]}>
<Ionicons name="warning-outline" size={18} color={colors.dangerText} />
<Text style={[styles.warnText, { color: colors.dangerText }]}>{data.warning}</Text>
</View>
<View style={styles.card}>
<Text style={styles.confirmTitle}>确认删除</Text>
<Text style={styles.confirmHint}>
请输入「{data.confirmPlaceholder.replace('请输入「', '').replace('」以确认', '')}」以确认操作
</Text>
<TextInput
style={styles.confirmInput}
value={confirmText}
onChangeText={setConfirmText}
placeholder={data.confirmPlaceholder}
placeholderTextColor={colors.textHint}
/>
</View>
<Pressable
style={[styles.submitBtn, !canSubmit && styles.submitDisabled]}
disabled={!canSubmit}
onPress={handleSubmit}
>
<Text style={styles.submitText}>
{submitting ? '提交中…' : `删除选中数据(${selected.size} 项)`}
</Text>
</Pressable>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
height: 48,
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
headerBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
flex: 1,
textAlign: 'center',
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
scroll: {
flex: 1,
},
content: {
padding: 12,
paddingBottom: 24,
},
stateBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
paddingHorizontal: 32,
textAlign: 'center',
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
introCard: {
backgroundColor: colors.infoLight,
borderRadius: 12,
padding: 14,
marginBottom: 16,
},
introText: {
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary,
},
section: {
marginBottom: 16,
},
sectionTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: 10,
},
card: {
backgroundColor: colors.white,
borderRadius: 12,
overflow: 'hidden',
padding: 4,
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 14,
gap: 12,
},
rowBody: {
flex: 1,
},
rowTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
},
rowDesc: {
marginTop: 3,
fontSize: 12,
lineHeight: 16,
color: colors.textSecondary,
},
rowCount: {
fontSize: 12,
color: colors.textHint,
marginLeft: 4,
},
checkbox: {
width: 22,
height: 22,
borderRadius: 6,
borderWidth: 1.5,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
},
checkboxActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
warnCard: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 8,
borderRadius: 12,
padding: 14,
marginBottom: 16,
},
warnText: {
flex: 1,
fontSize: 13,
fontWeight: '600',
lineHeight: 18,
},
confirmTitle: {
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
paddingHorizontal: 14,
paddingTop: 12,
},
confirmHint: {
fontSize: 12,
color: colors.textSecondary,
paddingHorizontal: 14,
marginTop: 4,
marginBottom: 10,
},
confirmInput: {
marginHorizontal: 14,
marginBottom: 14,
height: 42,
borderRadius: 10,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 12,
fontSize: 14,
color: colors.textPrimary,
},
submitBtn: {
marginTop: 8,
height: 48,
borderRadius: 24,
backgroundColor: colors.danger,
alignItems: 'center',
justifyContent: 'center',
},
submitDisabled: {
backgroundColor: colors.border,
},
submitText: {
fontSize: 15,
fontWeight: '700',
color: colors.white,
},
});

5
app/settings/privacy-policy.tsx

@ -0,0 +1,5 @@
import { LegalDocScreen } from '../../src/components/LegalDocScreen';
export default function PrivacyPolicyScreen() {
return <LegalDocScreen docId="privacy-policy" fallbackTitle="隐私政策" />;
}

279
app/settings/privacy/[id].tsx

@ -0,0 +1,279 @@
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { usePrivacyDetailSetting } from '../../../src/hooks';
import { colors } from '../../../src/theme';
import type { PrivacyDetailOption } from '../../../src/types';
/* ===================== 子组件:顶部导航 ===================== */
function Header({ title, onBack }: { title: string; 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} numberOfLines={1}>
{title}
</Text>
<View style={styles.headerBtn} />
</View>
);
}
/* ===================== 子组件:可选项行 ===================== */
function OptionRow({
item,
disabled,
isLast,
onToggle,
}: {
item: PrivacyDetailOption;
disabled: boolean;
isLast: boolean;
onToggle: (id: string, enabled: boolean) => void;
}) {
return (
<View style={[styles.row, !isLast && styles.rowBorder]}>
<View style={styles.rowBody}>
<Text style={[styles.rowTitle, disabled && styles.rowTitleDisabled]}>{item.title}</Text>
{item.description ? <Text style={styles.rowDesc}>{item.description}</Text> : null}
</View>
<Switch
value={item.enabled}
disabled={disabled}
onValueChange={(next) => onToggle(item.id, next)}
trackColor={{ false: colors.border, true: colors.primaryBorder }}
thumbColor={item.enabled ? colors.primary : colors.white}
/>
</View>
);
}
/* ===================== 页面 ===================== */
export default function PrivacyDetailScreen() {
const params = useLocalSearchParams();
const rawId = params.id;
const id = typeof rawId === 'string' ? rawId : Array.isArray(rawId) ? rawId[0] ?? '' : '';
const router = useRouter();
const { detail, loading, error, refresh, toggle } = usePrivacyDetailSetting(id);
if (loading) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header title="隐私设置" onBack={() => router.back()} />
<View style={styles.stateBox}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
if (error || !detail) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header title="隐私设置" onBack={() => router.back()} />
<View style={styles.stateBox}>
<Ionicons name="alert-circle-outline" size={40} color={colors.danger} />
<Text style={styles.stateText}>{detail === null ? '暂无该设置项' : '加载失败,请重试'}</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header title={detail.title} onBack={() => router.back()} />
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<View style={styles.introCard}>
<Text style={styles.introText}>{detail.intro}</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>主开关</Text>
<View style={styles.card}>
<View style={styles.row}>
<View style={styles.rowBody}>
<Text style={styles.rowTitle}>{detail.master.title}</Text>
</View>
<Switch
value={detail.master.enabled}
onValueChange={(next) => toggle(detail.master.id, next)}
trackColor={{ false: colors.border, true: colors.primaryBorder }}
thumbColor={detail.master.enabled ? colors.primary : colors.white}
/>
</View>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>细选项</Text>
<View style={styles.card}>
{detail.options.map((item, index) => (
<OptionRow
key={item.id}
item={item}
disabled={!detail.master.enabled}
isLast={index === detail.options.length - 1}
onToggle={toggle}
/>
))}
</View>
</View>
{detail.note ? (
<View style={styles.noteCard}>
<Ionicons name="information-circle-outline" size={18} color={colors.info} />
<Text style={styles.noteText}>{detail.note}</Text>
</View>
) : null}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
height: 48,
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
headerBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
flex: 1,
textAlign: 'center',
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
scroll: {
flex: 1,
},
content: {
padding: 12,
paddingBottom: 24,
},
stateBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
introCard: {
backgroundColor: colors.infoLight,
borderRadius: 12,
padding: 14,
marginBottom: 16,
},
introText: {
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary,
},
section: {
marginBottom: 16,
},
sectionTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: 10,
},
card: {
backgroundColor: colors.white,
borderRadius: 12,
overflow: 'hidden',
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 14,
paddingVertical: 14,
},
rowBorder: {
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
rowBody: {
flex: 1,
marginRight: 12,
},
rowTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
},
rowTitleDisabled: {
color: colors.textHint,
},
rowDesc: {
marginTop: 3,
fontSize: 12,
lineHeight: 16,
color: colors.textSecondary,
},
noteCard: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: colors.white,
borderRadius: 12,
padding: 14,
gap: 8,
},
noteText: {
flex: 1,
fontSize: 12,
lineHeight: 18,
color: colors.textSecondary,
},
});

295
app/settings/privacy/dnd.tsx

@ -0,0 +1,295 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useDndSetting } from '../../../src/hooks';
import { colors } from '../../../src/theme';
/* ===================== 子组件:顶部导航 ===================== */
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 TimeRow({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (text: string) => void;
}) {
return (
<View style={styles.row}>
<Text style={styles.rowTitle}>{label}</Text>
<TextInput
style={styles.timeInput}
value={value}
onChangeText={onChange}
placeholder="00:00"
placeholderTextColor={colors.textHint}
keyboardType="numbers-and-punctuation"
/>
</View>
);
}
/* ===================== 页面 ===================== */
export default function DndSettingScreen() {
const router = useRouter();
const { setting, loading, error, refresh, update } = useDndSetting();
if (loading) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<View style={styles.stateBox}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
if (error || !setting) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<View style={styles.stateBox}>
<Ionicons name="alert-circle-outline" size={40} color={colors.danger} />
<Text style={styles.stateText}>加载失败,请重试</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} />
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<View style={[styles.statCard, { backgroundColor: colors.primaryLight }]}>
<Text style={[styles.statValue, { color: colors.primary }]}>{setting.blockedCount}</Text>
<Text style={styles.statLabel}>累计拦截的通知条数</Text>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>基础设置</Text>
<View style={styles.card}>
<View style={[styles.row, styles.rowBorder]}>
<View style={styles.rowBody}>
<Text style={styles.rowTitle}>开启勿扰模式</Text>
</View>
<Switch
value={setting.enabled}
onValueChange={(next) => update({ enabled: next })}
trackColor={{ false: colors.border, true: colors.primaryBorder }}
thumbColor={setting.enabled ? colors.primary : colors.white}
/>
</View>
<TimeRow
label="开始时间"
value={setting.startTime}
onChange={(text) => update({ startTime: text })}
/>
<View style={styles.rowBorder} />
<TimeRow
label="结束时间"
value={setting.endTime}
onChange={(text) => update({ endTime: text })}
/>
</View>
</View>
<View style={styles.section}>
<Text style={styles.sectionTitle}>高级设置</Text>
<View style={styles.card}>
<View style={styles.row}>
<View style={styles.rowBody}>
<Text style={styles.rowTitle}>允许重要通知穿透</Text>
<Text style={styles.rowDesc}>面试邀约等与企业的重要沟通仍会提醒你</Text>
</View>
<Switch
value={setting.allowUrgent}
onValueChange={(next) => update({ allowUrgent: next })}
trackColor={{ false: colors.border, true: colors.primaryBorder }}
thumbColor={setting.allowUrgent ? colors.primary : colors.white}
/>
</View>
</View>
</View>
<View style={styles.noteCard}>
<Ionicons name="information-circle-outline" size={18} color={colors.info} />
<Text style={styles.noteText}>
开启勿扰模式后,{setting.startTime} 至 {setting.endTime} 期间的非重要通知将被静音。你仍可在「企业沟通」中查看错过的消息。
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
height: 48,
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
headerBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
flex: 1,
textAlign: 'center',
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
scroll: {
flex: 1,
},
content: {
padding: 12,
paddingBottom: 24,
},
stateBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
statCard: {
borderRadius: 12,
padding: 20,
alignItems: 'center',
marginBottom: 16,
},
statValue: {
fontSize: 32,
fontWeight: '800',
},
statLabel: {
marginTop: 4,
fontSize: 13,
color: colors.textSecondary,
},
section: {
marginBottom: 16,
},
sectionTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: 10,
},
card: {
backgroundColor: colors.white,
borderRadius: 12,
overflow: 'hidden',
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 14,
paddingVertical: 14,
},
rowBorder: {
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
rowBody: {
flex: 1,
marginRight: 12,
},
rowTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
},
rowDesc: {
marginTop: 3,
fontSize: 12,
lineHeight: 16,
color: colors.textSecondary,
},
timeInput: {
minWidth: 64,
textAlign: 'right',
fontSize: 15,
fontWeight: '600',
color: colors.primary,
paddingVertical: 4,
paddingHorizontal: 8,
},
noteCard: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: colors.white,
borderRadius: 12,
padding: 14,
gap: 8,
},
noteText: {
flex: 1,
fontSize: 12,
lineHeight: 18,
color: colors.textSecondary,
},
});

357
app/settings/privacy/keywords.tsx

@ -0,0 +1,357 @@
import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useKeywordBlock } from '../../../src/hooks';
import { colors } from '../../../src/theme';
/* ===================== 子组件:顶部导航 ===================== */
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 KeywordChip({
text,
onRemove,
}: {
text: string;
onRemove: () => void;
}) {
return (
<View style={styles.chip}>
<Text style={styles.chipText}>{text}</Text>
<Pressable hitSlop={6} onPress={onRemove}>
<Ionicons name="close-circle" size={16} color={colors.textHint} />
</Pressable>
</View>
);
}
/* ===================== 页面 ===================== */
export default function KeywordBlockScreen() {
const router = useRouter();
const { setting, loading, error, refresh, addKeyword, removeKeyword } = useKeywordBlock();
const [text, setText] = useState('');
const handleAdd = async () => {
const value = text.trim();
if (!value) return;
await addKeyword(value);
setText('');
};
if (loading) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<View style={styles.stateBox}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
if (error || !setting) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<View style={styles.stateBox}>
<Ionicons name="alert-circle-outline" size={40} color={colors.danger} />
<Text style={styles.stateText}>加载失败,请重试</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} />
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<View style={[styles.statCard, { backgroundColor: colors.dangerLight }]}>
<Text style={[styles.statValue, { color: colors.dangerText }]}>{setting.blockedCount}</Text>
<Text style={styles.statLabel}>累计拦截的招聘信息条数</Text>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>已屏蔽关键词</Text>
{setting.keywords.length === 0 ? (
<Text style={styles.emptyText}>暂无屏蔽词,添加后相关岗位与消息将被过滤</Text>
) : (
<View style={styles.chipWrap}>
{setting.keywords.map((word) => (
<KeywordChip
key={word}
text={word}
onRemove={() => removeKeyword(word)}
/>
))}
</View>
)}
</View>
{setting.suggestions.length > 0 ? (
<View style={styles.card}>
<Text style={styles.cardTitle}>系统建议屏蔽</Text>
<Text style={styles.cardHint}>点击即可快速添加高频骚扰词</Text>
<View style={styles.chipWrap}>
{setting.suggestions.map((word) => (
<Pressable
key={word}
style={styles.suggestChip}
onPress={() => addKeyword(word)}
>
<Ionicons name="add" size={14} color={colors.primary} />
<Text style={styles.suggestText}>{word}</Text>
</Pressable>
))}
</View>
</View>
) : null}
<View style={styles.noteCard}>
<Ionicons name="information-circle-outline" size={18} color={colors.info} />
<Text style={styles.noteText}>
屏蔽关键词后,标题或详情包含该词的职位推荐与沟通消息将被隐藏,不影响你已投递的岗位。
</Text>
</View>
</ScrollView>
<View style={styles.inputBar}>
<TextInput
style={styles.input}
value={text}
onChangeText={setText}
placeholder="输入要屏蔽的关键词"
placeholderTextColor={colors.textHint}
returnKeyType="done"
onSubmitEditing={handleAdd}
/>
<Pressable
style={[styles.addBtn, !text.trim() && styles.addBtnDisabled]}
disabled={!text.trim()}
onPress={handleAdd}
>
<Text style={styles.addBtnText}>添加</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
flex: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
height: 48,
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
headerBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
flex: 1,
textAlign: 'center',
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
scroll: {
flex: 1,
},
content: {
padding: 12,
paddingBottom: 16,
},
stateBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
statCard: {
borderRadius: 12,
padding: 20,
alignItems: 'center',
marginBottom: 16,
},
statValue: {
fontSize: 32,
fontWeight: '800',
},
statLabel: {
marginTop: 4,
fontSize: 13,
color: colors.textSecondary,
},
card: {
backgroundColor: colors.white,
borderRadius: 12,
padding: 16,
marginBottom: 16,
},
cardTitle: {
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: 4,
},
cardHint: {
fontSize: 12,
color: colors.textSecondary,
marginBottom: 12,
},
emptyText: {
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary,
},
chipWrap: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: 12,
},
chip: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.dangerLight,
borderRadius: 16,
paddingVertical: 6,
paddingHorizontal: 10,
gap: 4,
},
chipText: {
fontSize: 13,
fontWeight: '600',
color: colors.dangerText,
},
suggestChip: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.primaryLight,
borderRadius: 16,
paddingVertical: 6,
paddingHorizontal: 10,
gap: 2,
},
suggestText: {
fontSize: 13,
fontWeight: '600',
color: colors.primary,
},
noteCard: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: colors.white,
borderRadius: 12,
padding: 14,
gap: 8,
},
noteText: {
flex: 1,
fontSize: 12,
lineHeight: 18,
color: colors.textSecondary,
},
inputBar: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingHorizontal: 12,
paddingVertical: 10,
backgroundColor: colors.white,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
input: {
flex: 1,
height: 40,
borderRadius: 20,
backgroundColor: colors.pageBg,
paddingHorizontal: 16,
fontSize: 14,
color: colors.textPrimary,
},
addBtn: {
height: 40,
paddingHorizontal: 20,
borderRadius: 20,
backgroundColor: colors.primaryBtn,
alignItems: 'center',
justifyContent: 'center',
},
addBtnDisabled: {
backgroundColor: colors.border,
},
addBtnText: {
fontSize: 14,
fontWeight: '700',
color: colors.white,
},
});

5
app/settings/user-agreement.tsx

@ -0,0 +1,5 @@
import { LegalDocScreen } from '../../src/components/LegalDocScreen';
export default function UserAgreementScreen() {
return <LegalDocScreen docId="user-agreement" fallbackTitle="用户协议" />;
}

204
src/components/LegalDocScreen.tsx

@ -0,0 +1,204 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLegalDocument } from '../hooks';
import { colors } from '../theme';
/* ===================== 子组件:顶部导航 ===================== */
function Header({ title, onBack }: { title: string; 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} numberOfLines={1}>
{title}
</Text>
<View style={styles.headerBtn} />
</View>
);
}
/* ===================== 共享页面:法律文档(隐私政策 / 用户协议) ===================== */
export function LegalDocScreen({
docId,
fallbackTitle,
}: {
docId: string;
fallbackTitle: string;
}) {
const router = useRouter();
const { doc, loading, error, refresh } = useLegalDocument(docId);
if (loading) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header title={fallbackTitle} onBack={() => router.back()} />
<View style={styles.stateBox}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
if (error || !doc) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header title={fallbackTitle} onBack={() => router.back()} />
<View style={styles.stateBox}>
<Ionicons name="alert-circle-outline" size={40} color={colors.danger} />
<Text style={styles.stateText}>加载失败,请重试</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header title={doc.title} onBack={() => router.back()} />
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<View style={styles.metaRow}>
<Text style={styles.metaText}>版本 {doc.version}</Text>
<Text style={styles.metaText}>更新于 {doc.updatedAt}</Text>
</View>
<View style={styles.preambleCard}>
<Text style={styles.preambleText}>{doc.preamble}</Text>
</View>
{doc.sections.map((section, index) => (
<View key={index} style={styles.section}>
<Text style={styles.sectionHeading}>{section.heading}</Text>
{section.paragraphs.map((p, pIndex) => (
<Text key={pIndex} style={styles.paragraph}>
{p}
</Text>
))}
</View>
))}
<Text style={styles.footNote}>
本文件含【待确认】占位项,正式发布前需由法务复核并替换公司全称、联系方式、管辖法院等信息。
</Text>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
height: 48,
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
headerBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
flex: 1,
textAlign: 'center',
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
scroll: {
flex: 1,
},
content: {
padding: 16,
paddingBottom: 32,
},
stateBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
metaRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 12,
},
metaText: {
fontSize: 12,
color: colors.textHint,
},
preambleCard: {
backgroundColor: colors.infoLight,
borderRadius: 12,
padding: 14,
marginBottom: 16,
},
preambleText: {
fontSize: 13,
lineHeight: 20,
color: colors.textSecondary,
},
section: {
marginBottom: 16,
},
sectionHeading: {
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: 8,
},
paragraph: {
fontSize: 13,
lineHeight: 21,
color: colors.textSecondary,
marginBottom: 8,
},
footNote: {
fontSize: 11,
lineHeight: 16,
color: colors.textHint,
marginTop: 8,
},
});
Loading…
Cancel
Save