feat(verification): add user identity verification system
Some checks failed
Frontend CI / build-android-apk (push) Failing after 9m29s
Frontend CI / build-and-push-web (push) Failing after 1m12s
Frontend CI / ota-android (push) Failing after 7m29s

Add comprehensive user verification feature including:
- VerificationGuideScreen: Guide screen explaining the verification process
- VerificationFormScreen: Form for submitting verification applications
- VerificationSettingsScreen: Settings page to view verification status
- verificationService: API service for verification operations
- verificationStore: State management for verification state
- DTOs: Types for verification records, status, and admin operations

The system supports multiple identity types (student, teacher, staff, alumni, external)
and includes both user-facing and admin-facing functionality.
This commit is contained in:
lafay
2026-04-05 20:26:51 +08:00
parent 82c2970a85
commit 542d385d08
8 changed files with 1525 additions and 1 deletions

View File

@@ -0,0 +1,425 @@
/**
* 身份认证申请表单页面
* 用户填写认证信息并提交申请
*/
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
ScrollView,
Image,
Alert,
ActivityIndicator,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { useAppColors, type AppColors } from '../../theme';
import { useVerificationStore } from '../../stores/verificationStore';
import { showPrompt } from '../../services/promptService';
import type { UserIdentity } from '../../types/dto';
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
const IDENTITY_CONFIG: Record<string, { title: string; idField: string; idLabel: string }> = {
student: { title: '在校学生', idField: 'student_id', idLabel: '学号' },
teacher: { title: '教职工', idField: 'employee_id', idLabel: '工号' },
staff: { title: '工作人员', idField: 'employee_id', idLabel: '工号' },
alumni: { title: '校友', idField: 'student_id', idLabel: '学号(原)' },
};
export const VerificationFormScreen: React.FC = () => {
const colors = useAppColors();
const styles = createStyles(colors);
const router = useRouter();
const params = useLocalSearchParams<{ identity?: string }>();
const { submitVerification, isLoading } = useVerificationStore();
const [formData, setFormData] = useState({
real_name: '',
student_id: '',
employee_id: '',
department: '',
proof_materials: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const identity = params.identity as UserIdentity || 'student';
const config = IDENTITY_CONFIG[identity] || IDENTITY_CONFIG.student;
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.real_name.trim()) {
newErrors.real_name = '请输入真实姓名';
}
if (config.idField === 'student_id' && !formData.student_id.trim()) {
newErrors.student_id = `请输入${config.idLabel}`;
}
if (config.idField === 'employee_id' && !formData.employee_id.trim()) {
newErrors.employee_id = `请输入${config.idLabel}`;
}
if (!formData.proof_materials) {
newErrors.proof_materials = '请上传证明材料';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handlePickImage = async () => {
try {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
Alert.alert('提示', '需要相册权限才能上传图片');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setFormData({ ...formData, proof_materials: result.assets[0].uri });
if (errors.proof_materials) {
setErrors({ ...errors, proof_materials: '' });
}
}
} catch (error) {
console.error('Failed to pick image:', error);
Alert.alert('错误', '选择图片失败');
}
};
const handleSubmit = async () => {
if (!validateForm()) return;
const success = await submitVerification({
identity,
real_name: formData.real_name.trim(),
student_id: formData.student_id.trim() || undefined,
employee_id: formData.employee_id.trim() || undefined,
department: formData.department.trim() || undefined,
proof_materials: formData.proof_materials,
});
if (success) {
showPrompt({
title: '提交成功',
message: '您的认证申请已提交,请等待审核',
type: 'success',
});
router.back();
} else {
const error = useVerificationStore.getState().error;
showPrompt({
title: '提交失败',
message: error || '请稍后重试',
type: 'error',
});
}
};
const renderInput = (
field: keyof typeof formData,
label: string,
placeholder: string,
options: {
required?: boolean;
keyboardType?: 'default' | 'numeric' | 'email-address';
maxLength?: number;
} = {}
) => {
const { required, keyboardType = 'default', maxLength } = options;
const error = errors[field];
return (
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}>
{label}
{required && <Text style={styles.required}> *</Text>}
</Text>
<View style={[styles.inputWrapper, error && styles.inputWrapperError]}>
<TextInput
style={styles.input}
placeholder={placeholder}
placeholderTextColor={colors.text?.hint || '#999'}
value={formData[field]}
onChangeText={(text) => {
setFormData({ ...formData, [field]: text });
if (errors[field]) {
setErrors({ ...errors, [field]: '' });
}
}}
keyboardType={keyboardType}
maxLength={maxLength}
editable={!isLoading}
/>
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
activeOpacity={0.8}
>
<MaterialCommunityIcons
name="arrow-left"
size={24}
color={colors.text?.primary || '#333'}
/>
</TouchableOpacity>
<Text style={styles.headerTitle}>{config.title}</Text>
<View style={styles.headerRight} />
</View>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<View style={styles.infoCard}>
<MaterialCommunityIcons
name="information-outline"
size={20}
color={THEME_COLORS.primary}
/>
<Text style={styles.infoText}>
</Text>
</View>
<View style={styles.formSection}>
{renderInput('real_name', '真实姓名', '请输入身份证上的姓名', { required: true })}
{config.idField === 'student_id' && (
renderInput('student_id', config.idLabel, `请输入${config.idLabel}`, {
required: true,
keyboardType: 'numeric',
maxLength: 20,
})
)}
{config.idField === 'employee_id' && (
renderInput('employee_id', config.idLabel, `请输入${config.idLabel}`, {
required: true,
keyboardType: 'numeric',
maxLength: 20,
})
)}
{renderInput('department', '院系/部门', '选填,如:计算机学院', {
maxLength: 50,
})}
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}>
<Text style={styles.required}> *</Text>
</Text>
<Text style={styles.hintText}>
</Text>
<TouchableOpacity
style={[styles.uploadButton, errors.proof_materials && styles.uploadButtonError]}
onPress={handlePickImage}
activeOpacity={0.8}
disabled={isLoading}
>
{formData.proof_materials ? (
<Image
source={{ uri: formData.proof_materials }}
style={styles.previewImage}
resizeMode="cover"
/>
) : (
<View style={styles.uploadPlaceholder}>
<MaterialCommunityIcons
name="camera-plus-outline"
size={40}
color={colors.text?.hint || '#999'}
/>
<Text style={styles.uploadText}></Text>
</View>
)}
</TouchableOpacity>
{errors.proof_materials && (
<Text style={styles.errorText}>{errors.proof_materials}</Text>
)}
</View>
</View>
<TouchableOpacity
style={[styles.submitButton, isLoading && styles.submitButtonDisabled]}
onPress={handleSubmit}
activeOpacity={0.9}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color="#FFF" />
) : (
<Text style={styles.submitButtonText}></Text>
)}
</TouchableOpacity>
</ScrollView>
</SafeAreaView>
);
};
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider || '#E0E0E0',
},
backButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.text?.primary || '#333',
},
headerRight: {
width: 40,
},
scrollContent: {
flexGrow: 1,
padding: 24,
},
infoCard: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: '#FFF5F0',
borderRadius: 12,
padding: 16,
marginBottom: 24,
},
infoText: {
flex: 1,
fontSize: 14,
color: colors.text?.secondary || '#666',
marginLeft: 8,
lineHeight: 20,
},
formSection: {
marginBottom: 24,
},
inputContainer: {
marginBottom: 20,
},
inputLabel: {
fontSize: 15,
fontWeight: '500',
color: colors.text?.primary || '#333',
marginBottom: 8,
},
required: {
color: THEME_COLORS.primary,
},
hintText: {
fontSize: 13,
color: colors.text?.hint || '#999',
marginBottom: 8,
},
inputWrapper: {
backgroundColor: '#F5F5F7',
borderRadius: 12,
paddingHorizontal: 16,
height: 50,
borderWidth: 1,
borderColor: 'transparent',
},
inputWrapperError: {
borderColor: colors.error?.main || '#FF4444',
},
input: {
flex: 1,
fontSize: 16,
color: colors.text?.primary || '#333',
height: 50,
},
errorText: {
fontSize: 12,
color: colors.error?.main || '#FF4444',
marginTop: 6,
},
uploadButton: {
backgroundColor: '#F5F5F7',
borderRadius: 12,
overflow: 'hidden',
minHeight: 200,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: 'transparent',
},
uploadButtonError: {
borderColor: colors.error?.main || '#FF4444',
},
uploadPlaceholder: {
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
uploadText: {
fontSize: 14,
color: colors.text?.hint || '#999',
marginTop: 12,
},
previewImage: {
width: '100%',
height: 200,
},
submitButton: {
height: 56,
borderRadius: 14,
backgroundColor: THEME_COLORS.primary,
justifyContent: 'center',
alignItems: 'center',
},
submitButtonDisabled: {
opacity: 0.7,
},
submitButtonText: {
fontSize: 17,
fontWeight: '600',
color: '#FFFFFF',
},
});
}
export default VerificationFormScreen;

View File

@@ -0,0 +1,338 @@
/**
* 注册后身份认证引导页面
* 可跳过的认证界面,引导用户完成身份认证
*/
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
Image,
Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, type AppColors } from '../../theme';
import * as hrefs from '../../navigation/hrefs';
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
// 身份类型选项
const IDENTITY_OPTIONS = [
{
key: 'student',
title: '在校学生',
description: '需要提交学生证或校园卡照片',
icon: 'school-outline',
},
{
key: 'teacher',
title: '教职工',
description: '需要提交工作证或教师资格证',
icon: 'chalkboard-teacher',
},
{
key: 'staff',
title: '工作人员',
description: '需要提交工作证或在职证明',
icon: 'badge-account-outline',
},
{
key: 'alumni',
title: '校友',
description: '需要提交毕业证或校友卡',
icon: 'account-school-outline',
},
];
export const VerificationGuideScreen: React.FC = () => {
const colors = useAppColors();
const styles = createStyles(colors);
const router = useRouter();
const [selectedIdentity, setSelectedIdentity] = useState<string | null>(null);
const handleSkip = () => {
Alert.alert(
'跳过认证',
'跳过后您将以普通用户身份使用,部分功能可能受限。您可以在设置中随时完成认证。',
[
{ text: '继续认证', style: 'cancel' },
{
text: '跳过',
style: 'default',
onPress: () => {
router.replace(hrefs.hrefHome());
},
},
]
);
};
const handleContinue = () => {
if (!selectedIdentity) {
Alert.alert('提示', '请选择您的身份类型');
return;
}
// 导航到认证表单页面
router.push({
pathname: '/auth/verification-form',
params: { identity: selectedIdentity },
});
};
const renderIdentityCard = (option: typeof IDENTITY_OPTIONS[0]) => {
const isSelected = selectedIdentity === option.key;
return (
<TouchableOpacity
key={option.key}
style={[styles.identityCard, isSelected && styles.identityCardSelected]}
onPress={() => setSelectedIdentity(option.key)}
activeOpacity={0.8}
>
<View style={styles.identityIconContainer}>
<MaterialCommunityIcons
name={option.icon as any}
size={32}
color={isSelected ? THEME_COLORS.primary : colors.text?.secondary || '#666'}
/>
</View>
<View style={styles.identityContent}>
<Text style={[styles.identityTitle, isSelected && styles.identityTitleSelected]}>
{option.title}
</Text>
<Text style={styles.identityDescription}>{option.description}</Text>
</View>
<View style={styles.checkContainer}>
{isSelected && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={THEME_COLORS.primary}
/>
)}
</View>
</TouchableOpacity>
);
};
return (
<SafeAreaView style={styles.container}>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 头部图标 */}
<View style={styles.header}>
<View style={styles.iconCircle}>
<MaterialCommunityIcons
name="shield-check-outline"
size={48}
color={THEME_COLORS.primary}
/>
</View>
<Text style={styles.title}></Text>
<Text style={styles.subtitle}>
</Text>
</View>
{/* 认证优势 */}
<View style={styles.benefitsContainer}>
<View style={styles.benefitItem}>
<MaterialCommunityIcons
name="verified"
size={20}
color={THEME_COLORS.primary}
/>
<Text style={styles.benefitText}></Text>
</View>
<View style={styles.benefitItem}>
<MaterialCommunityIcons
name="lock-open-outline"
size={20}
color={THEME_COLORS.primary}
/>
<Text style={styles.benefitText}></Text>
</View>
<View style={styles.benefitItem}>
<MaterialCommunityIcons
name="account-group"
size={20}
color={THEME_COLORS.primary}
/>
<Text style={styles.benefitText}></Text>
</View>
</View>
{/* 身份选择 */}
<View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}></Text>
{IDENTITY_OPTIONS.map(renderIdentityCard)}
</View>
{/* 按钮区域 */}
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.continueButton, !selectedIdentity && styles.continueButtonDisabled]}
onPress={handleContinue}
activeOpacity={0.9}
>
<Text style={styles.continueButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.skipButton} onPress={handleSkip} activeOpacity={0.8}>
<Text style={styles.skipButtonText}></Text>
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
);
};
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: 24,
paddingTop: 40,
paddingBottom: 40,
},
header: {
alignItems: 'center',
marginBottom: 32,
},
iconCircle: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: '#FFF5F0',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
},
title: {
fontSize: 28,
fontWeight: '700',
color: colors.text?.primary || '#333',
marginBottom: 8,
},
subtitle: {
fontSize: 15,
color: colors.text?.secondary || '#666',
textAlign: 'center',
},
benefitsContainer: {
flexDirection: 'row',
justifyContent: 'center',
gap: 16,
marginBottom: 40,
},
benefitItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
benefitText: {
fontSize: 13,
color: colors.text?.secondary || '#666',
},
sectionContainer: {
marginBottom: 32,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: colors.text?.primary || '#333',
marginBottom: 16,
},
identityCard: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
backgroundColor: '#F5F5F7',
borderRadius: 14,
marginBottom: 12,
borderWidth: 2,
borderColor: 'transparent',
},
identityCardSelected: {
backgroundColor: '#FFF5F0',
borderColor: THEME_COLORS.primary,
},
identityIconContainer: {
width: 48,
height: 48,
justifyContent: 'center',
alignItems: 'center',
marginRight: 16,
},
identityContent: {
flex: 1,
},
identityTitle: {
fontSize: 16,
fontWeight: '600',
color: colors.text?.primary || '#333',
marginBottom: 4,
},
identityTitleSelected: {
color: THEME_COLORS.primary,
},
identityDescription: {
fontSize: 13,
color: colors.text?.secondary || '#666',
},
checkContainer: {
width: 24,
height: 24,
justifyContent: 'center',
alignItems: 'center',
},
buttonContainer: {
gap: 12,
},
continueButton: {
height: 56,
borderRadius: 14,
backgroundColor: THEME_COLORS.primary,
justifyContent: 'center',
alignItems: 'center',
},
continueButtonDisabled: {
backgroundColor: '#ccc',
},
continueButtonText: {
fontSize: 17,
fontWeight: '600',
color: '#fff',
},
skipButton: {
height: 56,
borderRadius: 14,
backgroundColor: 'transparent',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: colors.divider || '#E0E0E0',
},
skipButtonText: {
fontSize: 17,
fontWeight: '500',
color: colors.text?.secondary || '#666',
},
});
}
export default VerificationGuideScreen;

View File

@@ -52,6 +52,7 @@ const SETTINGS_GROUPS_BASE = [
icon: 'shield-check-outline',
items: [
{ key: 'edit_profile', title: '编辑资料', icon: 'account-edit-outline', showArrow: true },
{ key: 'verification', title: '身份认证', icon: 'check-decagram', showArrow: true, subtitle: '获得认证标识,解锁更多功能' },
{ key: 'privacy', title: '隐私设置', icon: 'shield-account-outline', showArrow: true },
{ key: 'security', title: '账号安全', icon: 'lock-outline', showArrow: true },
{ key: 'blocked_users', title: '黑名单', icon: 'account-off-outline', showArrow: true },
@@ -252,6 +253,9 @@ export const SettingsScreen: React.FC = () => {
case 'edit_profile':
router.push(hrefs.hrefProfileEdit());
break;
case 'verification':
router.push('/profile/verification');
break;
case 'notification_settings':
router.push(hrefs.hrefProfileNotifications());
break;

View File

@@ -0,0 +1,498 @@
/**
* 身份认证设置页面
* 用户查看认证状态、提交认证申请
*/
import React, { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
Image,
Alert,
ActivityIndicator,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, type AppColors } from '../../theme';
import {
useVerificationStore,
useVerificationStatus,
useVerificationLoading,
} from '../../stores/verificationStore';
import { showPrompt } from '../../services/promptService';
import * as hrefs from '../../navigation/hrefs';
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
const STATUS_CONFIG = {
not_submitted: {
title: '未认证',
description: '完成身份认证,解锁更多专属功能',
icon: 'account-question-outline',
color: '#999',
},
pending: {
title: '审核中',
description: '您的认证申请正在审核中,请耐心等待',
icon: 'clock-outline',
color: '#FFA500',
},
approved: {
title: '已认证',
description: '您已完成身份认证',
icon: 'check-decagram',
color: '#4CAF50',
},
rejected: {
title: '认证失败',
description: '您的认证申请被拒绝,可以重新提交',
icon: 'close-circle-outline',
color: '#F44336',
},
};
const IDENTITY_TEXT: Record<string, string> = {
general: '普通用户',
student: '在校学生',
teacher: '教职工',
staff: '工作人员',
alumni: '校友',
external: '外部人员',
};
export const VerificationSettingsScreen: React.FC = () => {
const colors = useAppColors();
const styles = createStyles(colors);
const router = useRouter();
const status = useVerificationStatus();
const loading = useVerificationLoading();
const { fetchStatus, reset } = useVerificationStore();
useEffect(() => {
fetchStatus();
return () => {
reset();
};
}, []);
const handleStartVerification = () => {
router.push('/auth/verification-guide');
};
const handleViewRecords = () => {
showPrompt({
title: '认证记录',
message: '认证记录功能即将上线',
type: 'info',
});
};
if (loading && !status) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={THEME_COLORS.primary} />
</View>
</SafeAreaView>
);
}
const statusConfig = STATUS_CONFIG[status?.verification_status || 'not_submitted'];
const identityText = IDENTITY_TEXT[status?.identity || 'general'];
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
activeOpacity={0.8}
>
<MaterialCommunityIcons
name="arrow-left"
size={24}
color={colors.text?.primary || '#333'}
/>
</TouchableOpacity>
<Text style={styles.headerTitle}></Text>
<View style={styles.headerRight} />
</View>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 状态卡片 */}
<View style={styles.statusCard}>
<View style={[styles.statusIconContainer, { backgroundColor: statusConfig.color + '20' }]}>
<MaterialCommunityIcons
name={statusConfig.icon as any}
size={48}
color={statusConfig.color}
/>
</View>
<Text style={styles.statusTitle}>{statusConfig.title}</Text>
<Text style={styles.statusDescription}>{statusConfig.description}</Text>
{status?.verification_status === 'approved' && (
<View style={styles.identityBadge}>
<MaterialCommunityIcons name="check-circle" size={16} color={THEME_COLORS.primary} />
<Text style={styles.identityText}>{identityText}</Text>
</View>
)}
{status?.latest_record?.reject_reason && (
<View style={styles.rejectReasonContainer}>
<Text style={styles.rejectReasonLabel}></Text>
<Text style={styles.rejectReasonText}>
{status.latest_record.reject_reason}
</Text>
</View>
)}
</View>
{/* 认证优势 */}
{status?.verification_status !== 'approved' && (
<View style={styles.benefitsSection}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.benefitsList}>
<View style={styles.benefitItem}>
<View style={styles.benefitIconContainer}>
<MaterialCommunityIcons
name="verified"
size={24}
color={THEME_COLORS.primary}
/>
</View>
<View style={styles.benefitContent}>
<Text style={styles.benefitTitle}></Text>
<Text style={styles.benefitDescription}>
</Text>
</View>
</View>
<View style={styles.benefitItem}>
<View style={styles.benefitIconContainer}>
<MaterialCommunityIcons
name="lock-open-variant"
size={24}
color={THEME_COLORS.primary}
/>
</View>
<View style={styles.benefitContent}>
<Text style={styles.benefitTitle}></Text>
<Text style={styles.benefitDescription}>
访
</Text>
</View>
</View>
<View style={styles.benefitItem}>
<View style={styles.benefitIconContainer}>
<MaterialCommunityIcons
name="account-group"
size={24}
color={THEME_COLORS.primary}
/>
</View>
<View style={styles.benefitContent}>
<Text style={styles.benefitTitle}></Text>
<Text style={styles.benefitDescription}>
</Text>
</View>
</View>
</View>
</View>
)}
{/* 操作按钮 */}
<View style={styles.actionSection}>
{(status?.verification_status === 'not_submitted' ||
status?.verification_status === 'rejected') && (
<TouchableOpacity
style={styles.primaryButton}
onPress={handleStartVerification}
activeOpacity={0.9}
>
<MaterialCommunityIcons name="shield-check-outline" size={20} color="#FFF" />
<Text style={styles.primaryButtonText}>
{status?.verification_status === 'rejected' ? '重新认证' : '开始认证'}
</Text>
</TouchableOpacity>
)}
{status?.verification_status === 'pending' && (
<TouchableOpacity
style={styles.secondaryButton}
onPress={handleViewRecords}
activeOpacity={0.8}
>
<MaterialCommunityIcons name="file-document-outline" size={20} color={THEME_COLORS.primary} />
<Text style={styles.secondaryButtonText}></Text>
</TouchableOpacity>
)}
{status?.verification_status === 'approved' && (
<>
<View style={styles.verifiedInfo}>
<View style={styles.verifiedInfoRow}>
<Text style={styles.verifiedInfoLabel}></Text>
<Text style={styles.verifiedInfoValue}>{identityText}</Text>
</View>
{status.latest_record?.real_name && (
<View style={styles.verifiedInfoRow}>
<Text style={styles.verifiedInfoLabel}></Text>
<Text style={styles.verifiedInfoValue}>
{status.latest_record.real_name}
</Text>
</View>
)}
{status.latest_record?.department && (
<View style={styles.verifiedInfoRow}>
<Text style={styles.verifiedInfoLabel}>/</Text>
<Text style={styles.verifiedInfoValue}>
{status.latest_record.department}
</Text>
</View>
)}
</View>
</>
)}
</View>
{/* 帮助信息 */}
<View style={styles.helpSection}>
<Text style={styles.helpTitle}></Text>
<Text style={styles.helpText}>
</Text>
</View>
</ScrollView>
</SafeAreaView>
);
};
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider || '#E0E0E0',
},
backButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.text?.primary || '#333',
},
headerRight: {
width: 40,
},
scrollContent: {
flexGrow: 1,
padding: 24,
},
statusCard: {
alignItems: 'center',
paddingVertical: 32,
paddingHorizontal: 24,
backgroundColor: '#F9F9F9',
borderRadius: 16,
marginBottom: 24,
},
statusIconContainer: {
width: 96,
height: 96,
borderRadius: 48,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 16,
},
statusTitle: {
fontSize: 24,
fontWeight: '700',
color: colors.text?.primary || '#333',
marginBottom: 8,
},
statusDescription: {
fontSize: 15,
color: colors.text?.secondary || '#666',
textAlign: 'center',
lineHeight: 22,
},
identityBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#FFF5F0',
borderRadius: 20,
paddingVertical: 8,
paddingHorizontal: 16,
marginTop: 16,
},
identityText: {
fontSize: 14,
fontWeight: '600',
color: THEME_COLORS.primary,
marginLeft: 6,
},
rejectReasonContainer: {
marginTop: 16,
padding: 12,
backgroundColor: '#FFF3F3',
borderRadius: 8,
width: '100%',
},
rejectReasonLabel: {
fontSize: 13,
fontWeight: '600',
color: '#F44336',
marginBottom: 4,
},
rejectReasonText: {
fontSize: 13,
color: '#D32F2F',
lineHeight: 18,
},
benefitsSection: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 17,
fontWeight: '600',
color: colors.text?.primary || '#333',
marginBottom: 16,
},
benefitsList: {
gap: 12,
},
benefitItem: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: '#F9F9F9',
borderRadius: 12,
padding: 16,
},
benefitIconContainer: {
width: 48,
height: 48,
borderRadius: 12,
backgroundColor: '#FFF5F0',
justifyContent: 'center',
alignItems: 'center',
marginRight: 12,
},
benefitContent: {
flex: 1,
},
benefitTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.text?.primary || '#333',
marginBottom: 4,
},
benefitDescription: {
fontSize: 13,
color: colors.text?.secondary || '#666',
lineHeight: 18,
},
actionSection: {
marginBottom: 24,
},
primaryButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
height: 56,
borderRadius: 14,
backgroundColor: THEME_COLORS.primary,
},
primaryButtonText: {
fontSize: 17,
fontWeight: '600',
color: '#FFF',
marginLeft: 8,
},
secondaryButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
height: 56,
borderRadius: 14,
backgroundColor: '#FFF5F0',
borderWidth: 1,
borderColor: THEME_COLORS.primary,
},
secondaryButtonText: {
fontSize: 17,
fontWeight: '600',
color: THEME_COLORS.primary,
marginLeft: 8,
},
verifiedInfo: {
backgroundColor: '#F9F9F9',
borderRadius: 12,
padding: 16,
},
verifiedInfoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 8,
},
verifiedInfoLabel: {
fontSize: 15,
color: colors.text?.secondary || '#666',
},
verifiedInfoValue: {
fontSize: 15,
fontWeight: '500',
color: colors.text?.primary || '#333',
},
helpSection: {
alignItems: 'center',
paddingVertical: 16,
},
helpTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.text?.primary || '#333',
marginBottom: 8,
},
helpText: {
fontSize: 13,
color: colors.text?.hint || '#999',
textAlign: 'center',
lineHeight: 18,
},
});
}
export default VerificationSettingsScreen;

View File

@@ -114,3 +114,7 @@ export { showConfirm } from './dialogService';
// APK 更新检查服务
export { apkUpdateService, checkForAPKUpdate, manualCheckForUpdate } from './apkUpdateService';
export type { APKVersionInfo, VersionCheckResult } from './apkUpdateService';
// 身份认证服务
export { verificationService } from './verificationService';
export type { SubmitVerificationResponse } from './verificationService';

View File

@@ -0,0 +1,68 @@
/**
* 身份认证服务
* 处理用户身份认证申请、状态查询等功能
*/
import { api } from './api';
import {
VerificationStatusDTO,
VerificationRecordDTO,
SubmitVerificationRequest,
PaginatedData,
} from '../types/dto';
// 提交认证响应
export interface SubmitVerificationResponse {
id: string;
user_id: string;
identity: string;
status: string;
real_name: string;
student_id?: string;
employee_id?: string;
department?: string;
proof_materials: string;
created_at: string;
updated_at: string;
}
class VerificationService {
// 获取当前用户的认证状态
async getVerificationStatus(): Promise<VerificationStatusDTO | null> {
try {
const response = await api.get<VerificationStatusDTO>('/verification/status');
return response.data;
} catch (error) {
console.error('[VerificationService] 获取认证状态失败:', error);
return null;
}
}
// 提交认证申请
async submitVerification(data: SubmitVerificationRequest): Promise<SubmitVerificationResponse | null> {
try {
const response = await api.post<SubmitVerificationResponse>('/verification/submit', data);
return response.data;
} catch (error: any) {
console.error('[VerificationService] 提交认证申请失败:', error);
throw error;
}
}
// 获取认证记录列表
async getVerificationRecords(page = 1, pageSize = 10): Promise<PaginatedData<VerificationRecordDTO> | null> {
try {
const response = await api.get<PaginatedData<VerificationRecordDTO>>('/verification/records', {
page,
page_size: pageSize,
});
return response.data;
} catch (error) {
console.error('[VerificationService] 获取认证记录失败:', error);
return null;
}
}
}
// 导出服务实例
export const verificationService = new VerificationService();

View File

@@ -0,0 +1,114 @@
/**
* 身份认证状态管理
* 处理用户身份认证申请、状态查询等功能
*/
import { create } from 'zustand';
import { verificationService } from '../services/verificationService';
import {
VerificationStatusDTO,
VerificationRecordDTO,
SubmitVerificationRequest,
UserIdentity,
VerificationStatus,
} from '../types/dto';
interface VerificationState {
// 状态
status: VerificationStatusDTO | null;
records: VerificationRecordDTO[];
isLoading: boolean;
error: string | null;
hasFetchedStatus: boolean;
// 操作
fetchStatus: () => Promise<void>;
fetchRecords: (page?: number, pageSize?: number) => Promise<void>;
submitVerification: (data: SubmitVerificationRequest) => Promise<boolean>;
clearError: () => void;
reset: () => void;
}
export const useVerificationStore = create<VerificationState>((set, get) => ({
status: null,
records: [],
isLoading: false,
error: null,
hasFetchedStatus: false,
// 获取认证状态
fetchStatus: async () => {
set({ isLoading: true, error: null });
try {
const status = await verificationService.getVerificationStatus();
set({ status, isLoading: false, hasFetchedStatus: true });
} catch (error: any) {
set({
isLoading: false,
error: error?.message || '获取认证状态失败',
hasFetchedStatus: true,
});
}
},
// 获取认证记录
fetchRecords: async (page = 1, pageSize = 10) => {
set({ isLoading: true, error: null });
try {
const result = await verificationService.getVerificationRecords(page, pageSize);
if (result) {
set({ records: result.list, isLoading: false });
}
} catch (error: any) {
set({
isLoading: false,
error: error?.message || '获取认证记录失败',
});
}
},
// 提交认证申请
submitVerification: async (data: SubmitVerificationRequest) => {
set({ isLoading: true, error: null });
try {
const result = await verificationService.submitVerification(data);
if (result) {
// 提交成功后刷新状态
await get().fetchStatus();
set({ isLoading: false });
return true;
}
return false;
} catch (error: any) {
set({
isLoading: false,
error: error?.message || '提交认证申请失败',
});
return false;
}
},
// 清除错误
clearError: () => set({ error: null }),
// 重置状态
reset: () => set({
status: null,
records: [],
isLoading: false,
error: null,
hasFetchedStatus: false,
}),
}));
// 导出便捷 hooks
export const useVerificationStatus = () => useVerificationStore((state) => state.status);
export const useIsVerified = () => {
const status = useVerificationStore((state) => state.status);
return status?.verification_status === 'approved';
};
export const useHasPendingVerification = () => {
const status = useVerificationStore((state) => state.status);
return status?.has_pending_request || status?.verification_status === 'pending';
};
export const useVerificationLoading = () => useVerificationStore((state) => state.isLoading);

View File

@@ -773,7 +773,80 @@ export function extractTextFromSegments(segments?: MessageSegment[]): string {
return result.trim();
}
// ==================== Vote DTOs ====================
// ==================== Verification DTOs ====================
// 用户身份类型
export type UserIdentity = 'general' | 'student' | 'teacher' | 'staff' | 'alumni' | 'external';
// 认证状态
export type VerificationStatus = 'not_submitted' | 'pending' | 'approved' | 'rejected';
// 认证记录
export interface VerificationRecordDTO {
id: string;
user_id: string;
identity: UserIdentity;
status: VerificationStatus;
real_name: string;
student_id?: string;
employee_id?: string;
department?: string;
proof_materials: string;
reviewed_at?: string;
reviewed_by?: string;
reject_reason?: string;
created_at: string;
updated_at: string;
}
// 认证状态响应
export interface VerificationStatusDTO {
identity: UserIdentity;
verification_status: VerificationStatus;
has_pending_request: boolean;
latest_record?: VerificationRecordDTO;
}
// 提交认证请求
export interface SubmitVerificationRequest {
identity: UserIdentity;
real_name: string;
student_id?: string;
employee_id?: string;
department?: string;
proof_materials: string;
}
// 管理端认证列表项
export interface AdminVerificationListItemDTO {
id: string;
user_id: string;
username: string;
nickname: string;
avatar: string;
identity: UserIdentity;
status: VerificationStatus;
real_name: string;
student_id?: string;
employee_id?: string;
department?: string;
created_at: string;
reviewed_at?: string;
reviewer_name?: string;
}
// 管理端认证详情
export interface AdminVerificationDetailDTO extends AdminVerificationListItemDTO {
proof_materials: string;
reviewed_by?: string;
reject_reason?: string;
}
// 审核认证请求
export interface ReviewVerificationRequest {
approve: boolean;
reject_reason?: string;
}
// 投票选项
export interface VoteOptionDTO {