feat(auth): add user identity verification system with new screens
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 4m54s
Frontend CI / ota-android (push) Failing after 7m28s
Frontend CI / build-android-apk (push) Has been cancelled

Add comprehensive identity verification flow including:
- New verification guide screen at /verification-guide for selecting identity type
- New verification form screen at /verification-form for submitting verification documents
- Verification settings screen at /profile/verification for managing verification status
- Move terms and privacy policy to root-level routes (/terms, /privacy)
- Update login to require active terms agreement checkbox
- Database: add in-memory fallback when OPFS is unavailable on web

BREAKING CHANGE: Terms and privacy policy routes moved from /profile/terms and /profile/privacy to /terms and /privacy
This commit is contained in:
lafay
2026-04-07 15:58:51 +08:00
parent accf7c04e8
commit 445c1c5561
17 changed files with 152 additions and 51 deletions

View File

@@ -40,6 +40,7 @@ export default function ProfileStackLayout() {
<Stack.Screen name="about" options={{ title: '关于我们' }} /> <Stack.Screen name="about" options={{ title: '关于我们' }} />
<Stack.Screen name="terms" options={{ title: '用户协议' }} /> <Stack.Screen name="terms" options={{ title: '用户协议' }} />
<Stack.Screen name="privacy" options={{ title: '隐私政策' }} /> <Stack.Screen name="privacy" options={{ title: '隐私政策' }} />
<Stack.Screen name="verification" options={{ title: '身份认证' }} />
</Stack> </Stack>
); );
} }

View File

@@ -0,0 +1,5 @@
import { VerificationSettingsScreen } from '@/screens/profile/VerificationSettingsScreen';
export default function VerificationSettingsPage() {
return <VerificationSettingsScreen />;
}

View File

@@ -0,0 +1,5 @@
import { VerificationFormScreen } from '@/screens/auth/VerificationFormScreen';
export default function VerificationFormPage() {
return <VerificationFormScreen />;
}

View File

@@ -0,0 +1,5 @@
import { VerificationGuideScreen } from '@/screens/auth/VerificationGuideScreen';
export default function VerificationGuidePage() {
return <VerificationGuideScreen />;
}

View File

@@ -175,6 +175,30 @@ function ThemedStack() {
<Stack.Screen name="index" options={{ headerShown: false }} /> <Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} /> <Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(app)" options={{ headerShown: false }} /> <Stack.Screen name="(app)" options={{ headerShown: false }} />
<Stack.Screen
name="terms"
options={{
headerShown: true,
title: '用户协议',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerShadowVisible: false,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
}}
/>
<Stack.Screen
name="privacy"
options={{
headerShown: true,
title: '隐私政策',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerShadowVisible: false,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
}}
/>
<Stack.Screen <Stack.Screen
name="post/[postId]" name="post/[postId]"
options={{ options={{

5
app/privacy.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { PrivacyPolicyScreen } from '../src/screens/profile/PrivacyPolicyScreen';
export default function PrivacyRoute() {
return <PrivacyPolicyScreen />;
}

5
app/terms.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { TermsOfServiceScreen } from '../src/screens/profile/TermsOfServiceScreen';
export default function TermsRoute() {
return <TermsOfServiceScreen />;
}

View File

@@ -9,6 +9,6 @@ export const DB_CONFIG = {
DB_NAME_PREFIX: 'carrot_bbs_', DB_NAME_PREFIX: 'carrot_bbs_',
} as const; } as const;
export const getDbName = (userId: string | null): string => { export function getDbName(userId: string | null): string {
return userId ? `${DB_CONFIG.DB_NAME_PREFIX}${userId}.db` : DB_CONFIG.DEFAULT_DB_NAME; return userId ? `${DB_CONFIG.DB_NAME_PREFIX}${userId}.db` : DB_CONFIG.DEFAULT_DB_NAME;
}; }

View File

@@ -109,25 +109,31 @@ class DatabaseManager {
const isWeb = typeof window !== 'undefined'; const isWeb = typeof window !== 'undefined';
return LockManager.withWebLock(`${DB_CONFIG.LOCK_PREFIX}${dbName}`, async () => { return LockManager.withWebLock(`${DB_CONFIG.LOCK_PREFIX}${dbName}`, async () => {
let lastError: unknown;
for (let attempt = 0; attempt <= DB_CONFIG.MAX_RETRIES; attempt++) { for (let attempt = 0; attempt <= DB_CONFIG.MAX_RETRIES; attempt++) {
try { try {
const database = await SQLite.openDatabaseAsync(dbName); const database = await SQLite.openDatabaseAsync(dbName);
console.log(`[DatabaseManager] 成功打开数据库 (尝试 ${attempt + 1}/${DB_CONFIG.MAX_RETRIES + 1})`); console.log(`[DatabaseManager] 成功打开数据库 (尝试 ${attempt + 1}/${DB_CONFIG.MAX_RETRIES + 1})`);
return database; return database;
} catch (error) { } catch (error) {
lastError = error;
const msg = String(error); const msg = String(error);
console.error(`[DatabaseManager] 打开失败 (尝试 ${attempt + 1}/${DB_CONFIG.MAX_RETRIES + 1}):`, msg); console.error(`[DatabaseManager] 打开失败 (尝试 ${attempt + 1}/${DB_CONFIG.MAX_RETRIES + 1}):`, msg);
if (isWeb && msg.includes('cannot create file')) {
console.log('[DatabaseManager] OPFS 不可用,回退到内存数据库');
const memoryDb = await SQLite.openDatabaseAsync(':memory:');
return memoryDb;
}
if (attempt < DB_CONFIG.MAX_RETRIES) { if (attempt < DB_CONFIG.MAX_RETRIES) {
const delay = isWeb ? DB_CONFIG.WEB_RETRY_DELAY_BASE * (attempt + 1) : DB_CONFIG.NATIVE_RETRY_DELAY; const delay = isWeb ? DB_CONFIG.WEB_RETRY_DELAY_BASE * (attempt + 1) : DB_CONFIG.NATIVE_RETRY_DELAY;
console.warn(`[DatabaseManager] ${delay}ms 后重试...`); console.warn(`[DatabaseManager] ${delay}ms 后重试...`);
await new Promise(r => setTimeout(r, delay)); await new Promise(r => setTimeout(r, delay));
} else {
throw error;
} }
} }
} }
throw new Error('打开数据库失败'); throw lastError;
}); });
} }

View File

@@ -185,9 +185,24 @@ export function hrefProfileAbout(): string {
} }
export function hrefProfileTerms(): string { export function hrefProfileTerms(): string {
return '/profile/terms'; return '/terms';
} }
export function hrefProfilePrivacy(): string { export function hrefProfilePrivacy(): string {
return '/profile/privacy'; return '/privacy';
}
export function hrefProfileVerification(): string {
return '/profile/verification';
}
export function hrefVerificationGuide(): string {
return '/verification-guide';
}
export function hrefVerificationForm(identity?: string): string {
if (identity) {
return `/verification-form?identity=${encodeURIComponent(identity)}`;
}
return '/verification-form';
} }

View File

@@ -53,6 +53,7 @@ export const LoginScreen: React.FC = () => {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [agreedToTerms, setAgreedToTerms] = useState(false);
// 动画值 // 动画值
const fadeAnim = useRef(new Animated.Value(0)).current; const fadeAnim = useRef(new Animated.Value(0)).current;
@@ -84,6 +85,10 @@ export const LoginScreen: React.FC = () => {
showPrompt({ title: '提示', message: '请输入密码', type: 'warning' }); showPrompt({ title: '提示', message: '请输入密码', type: 'warning' });
return false; return false;
} }
if (!agreedToTerms) {
showPrompt({ title: '提示', message: '请同意用户协议和隐私政策', type: 'warning' });
return false;
}
return true; return true;
}; };
@@ -211,6 +216,37 @@ export const LoginScreen: React.FC = () => {
<Text style={styles.forgotPasswordText}></Text> <Text style={styles.forgotPasswordText}></Text>
</TouchableOpacity> </TouchableOpacity>
{/* 服务条款勾选 */}
<View style={styles.termsContainer}>
<TouchableOpacity
onPress={() => setAgreedToTerms(!agreedToTerms)}
activeOpacity={0.8}
style={styles.checkboxWrapper}
>
<MaterialCommunityIcons
name={agreedToTerms ? 'checkbox-marked' : 'checkbox-blank-outline'}
size={22}
color={agreedToTerms ? THEME_COLORS.primary : colors.text.hint}
/>
</TouchableOpacity>
<Text style={styles.termsText}>
<Text
style={styles.termsLink}
onPress={() => router.push(hrefs.hrefProfileTerms())}
>
</Text>
<Text
style={styles.termsLink}
onPress={() => router.push(hrefs.hrefProfilePrivacy())}
>
</Text>
</Text>
</View>
{/* 登录按钮 */} {/* 登录按钮 */}
<TouchableOpacity <TouchableOpacity
style={[styles.loginButton, loading && styles.loginButtonDisabled]} style={[styles.loginButton, loading && styles.loginButtonDisabled]}
@@ -234,15 +270,7 @@ export const LoginScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* 协议提示 */}
<View style={styles.policyFooter}>
<Text style={styles.policyText}>
<Text style={styles.policyLink} onPress={() => router.push(hrefs.hrefProfileTerms())}></Text>
<Text style={styles.policyLink} onPress={() => router.push(hrefs.hrefProfilePrivacy())}></Text>
</Text>
</View>
</Animated.View> </Animated.View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
@@ -394,6 +422,26 @@ function createLoginStyles(colors: AppColors) {
color: THEME_COLORS.primary, color: THEME_COLORS.primary,
fontWeight: '500', fontWeight: '500',
}, },
termsContainer: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 20,
marginTop: 4,
},
checkboxWrapper: {
paddingTop: 2,
},
termsText: {
fontSize: 14,
color: colors.text.secondary,
marginLeft: 10,
flex: 1,
lineHeight: 22,
},
termsLink: {
color: THEME_COLORS.primary,
fontWeight: '500',
},
}); });
} }

View File

@@ -38,7 +38,7 @@ export const RegisterStep3Profile: React.FC<RegisterStepProps> = ({
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [agreedToTerms, setAgreedToTerms] = useState(true); const [agreedToTerms, setAgreedToTerms] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const validateForm = (): boolean => { const validateForm = (): boolean => {

View File

@@ -37,7 +37,7 @@ const IDENTITY_OPTIONS = [
key: 'teacher', key: 'teacher',
title: '教职工', title: '教职工',
description: '需要提交工作证或教师资格证', description: '需要提交工作证或教师资格证',
icon: 'chalkboard-teacher', icon: 'account-tie',
}, },
{ {
key: 'staff', key: 'staff',
@@ -82,10 +82,7 @@ export const VerificationGuideScreen: React.FC = () => {
return; return;
} }
// 导航到认证表单页面 // 导航到认证表单页面
router.push({ router.push(hrefs.hrefVerificationForm(selectedIdentity));
pathname: '/auth/verification-form',
params: { identity: selectedIdentity },
});
}; };
const renderIdentityCard = (option: typeof IDENTITY_OPTIONS[0]) => { const renderIdentityCard = (option: typeof IDENTITY_OPTIONS[0]) => {
@@ -148,7 +145,7 @@ export const VerificationGuideScreen: React.FC = () => {
<View style={styles.benefitsContainer}> <View style={styles.benefitsContainer}>
<View style={styles.benefitItem}> <View style={styles.benefitItem}>
<MaterialCommunityIcons <MaterialCommunityIcons
name="verified" name="check-decagram"
size={20} size={20}
color={THEME_COLORS.primary} color={THEME_COLORS.primary}
/> />
@@ -164,7 +161,7 @@ export const VerificationGuideScreen: React.FC = () => {
</View> </View>
<View style={styles.benefitItem}> <View style={styles.benefitItem}>
<MaterialCommunityIcons <MaterialCommunityIcons
name="account-group" name="account-group-outline"
size={20} size={20}
color={THEME_COLORS.primary} color={THEME_COLORS.primary}
/> />

View File

@@ -7,6 +7,8 @@ export { LoginScreen } from './LoginScreen';
export { RegisterScreen } from './RegisterScreen'; export { RegisterScreen } from './RegisterScreen';
export { ForgotPasswordScreen } from './ForgotPasswordScreen'; export { ForgotPasswordScreen } from './ForgotPasswordScreen';
export { WelcomeScreen } from './WelcomeScreen'; export { WelcomeScreen } from './WelcomeScreen';
export { VerificationGuideScreen } from './VerificationGuideScreen';
export { VerificationFormScreen } from './VerificationFormScreen';
// 注册步骤组件 // 注册步骤组件
export { RegisterStep1Email } from './RegisterStep1Email'; export { RegisterStep1Email } from './RegisterStep1Email';

View File

@@ -254,7 +254,7 @@ export const SettingsScreen: React.FC = () => {
router.push(hrefs.hrefProfileEdit()); router.push(hrefs.hrefProfileEdit());
break; break;
case 'verification': case 'verification':
router.push('/profile/verification'); router.push(hrefs.hrefProfileVerification());
break; break;
case 'notification_settings': case 'notification_settings':
router.push(hrefs.hrefProfileNotifications()); router.push(hrefs.hrefProfileNotifications());

View File

@@ -84,7 +84,7 @@ export const VerificationSettingsScreen: React.FC = () => {
}, []); }, []);
const handleStartVerification = () => { const handleStartVerification = () => {
router.push('/auth/verification-guide'); router.push(hrefs.hrefVerificationGuide());
}; };
const handleViewRecords = () => { const handleViewRecords = () => {
@@ -109,27 +109,10 @@ export const VerificationSettingsScreen: React.FC = () => {
const identityText = IDENTITY_TEXT[status?.identity || 'general']; const identityText = IDENTITY_TEXT[status?.identity || 'general'];
return ( return (
<SafeAreaView style={styles.container}> <ScrollView
<View style={styles.header}> contentContainerStyle={styles.scrollContent}
<TouchableOpacity showsVerticalScrollIndicator={false}
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.statusCard}>
<View style={[styles.statusIconContainer, { backgroundColor: statusConfig.color + '20' }]}> <View style={[styles.statusIconContainer, { backgroundColor: statusConfig.color + '20' }]}>
@@ -167,7 +150,7 @@ export const VerificationSettingsScreen: React.FC = () => {
<View style={styles.benefitItem}> <View style={styles.benefitItem}>
<View style={styles.benefitIconContainer}> <View style={styles.benefitIconContainer}>
<MaterialCommunityIcons <MaterialCommunityIcons
name="verified" name="check-decagram"
size={24} size={24}
color={THEME_COLORS.primary} color={THEME_COLORS.primary}
/> />
@@ -183,7 +166,7 @@ export const VerificationSettingsScreen: React.FC = () => {
<View style={styles.benefitItem}> <View style={styles.benefitItem}>
<View style={styles.benefitIconContainer}> <View style={styles.benefitIconContainer}>
<MaterialCommunityIcons <MaterialCommunityIcons
name="lock-open-variant" name="lock-open-outline"
size={24} size={24}
color={THEME_COLORS.primary} color={THEME_COLORS.primary}
/> />
@@ -199,7 +182,7 @@ export const VerificationSettingsScreen: React.FC = () => {
<View style={styles.benefitItem}> <View style={styles.benefitItem}>
<View style={styles.benefitIconContainer}> <View style={styles.benefitIconContainer}>
<MaterialCommunityIcons <MaterialCommunityIcons
name="account-group" name="account-group-outline"
size={24} size={24}
color={THEME_COLORS.primary} color={THEME_COLORS.primary}
/> />
@@ -278,7 +261,6 @@ export const VerificationSettingsScreen: React.FC = () => {
</Text> </Text>
</View> </View>
</ScrollView> </ScrollView>
</SafeAreaView>
); );
}; };

View File

@@ -17,6 +17,7 @@ export { AccountSecurityScreen } from './AccountSecurityScreen';
export { AboutScreen } from './AboutScreen'; export { AboutScreen } from './AboutScreen';
export { TermsOfServiceScreen } from './TermsOfServiceScreen'; export { TermsOfServiceScreen } from './TermsOfServiceScreen';
export { PrivacyPolicyScreen } from './PrivacyPolicyScreen'; export { PrivacyPolicyScreen } from './PrivacyPolicyScreen';
export { VerificationSettingsScreen } from './VerificationSettingsScreen';
// 导出 Hook 供需要自定义的场景使用 // 导出 Hook 供需要自定义的场景使用
export { useUserProfile, createSharedProfileStyles, TABS, TAB_ICONS } from './useUserProfile'; export { useUserProfile, createSharedProfileStyles, TABS, TAB_ICONS } from './useUserProfile';