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="terms" options={{ title: '用户协议' }} />
<Stack.Screen name="privacy" options={{ title: '隐私政策' }} />
<Stack.Screen name="verification" options={{ title: '身份认证' }} />
</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="(auth)" 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
name="post/[postId]"
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_',
} 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;
};
}

View File

@@ -109,25 +109,31 @@ class DatabaseManager {
const isWeb = typeof window !== 'undefined';
return LockManager.withWebLock(`${DB_CONFIG.LOCK_PREFIX}${dbName}`, async () => {
let lastError: unknown;
for (let attempt = 0; attempt <= DB_CONFIG.MAX_RETRIES; attempt++) {
try {
const database = await SQLite.openDatabaseAsync(dbName);
console.log(`[DatabaseManager] 成功打开数据库 (尝试 ${attempt + 1}/${DB_CONFIG.MAX_RETRIES + 1})`);
return database;
} catch (error) {
lastError = error;
const msg = String(error);
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) {
const delay = isWeb ? DB_CONFIG.WEB_RETRY_DELAY_BASE * (attempt + 1) : DB_CONFIG.NATIVE_RETRY_DELAY;
console.warn(`[DatabaseManager] ${delay}ms 后重试...`);
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 {
return '/profile/terms';
return '/terms';
}
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 [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [agreedToTerms, setAgreedToTerms] = useState(false);
// 动画值
const fadeAnim = useRef(new Animated.Value(0)).current;
@@ -84,6 +85,10 @@ export const LoginScreen: React.FC = () => {
showPrompt({ title: '提示', message: '请输入密码', type: 'warning' });
return false;
}
if (!agreedToTerms) {
showPrompt({ title: '提示', message: '请同意用户协议和隐私政策', type: 'warning' });
return false;
}
return true;
};
@@ -211,6 +216,37 @@ export const LoginScreen: React.FC = () => {
<Text style={styles.forgotPasswordText}></Text>
</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
style={[styles.loginButton, loading && styles.loginButtonDisabled]}
@@ -234,15 +270,7 @@ export const LoginScreen: React.FC = () => {
</TouchableOpacity>
</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>
</ScrollView>
</KeyboardAvoidingView>
@@ -394,6 +422,26 @@ function createLoginStyles(colors: AppColors) {
color: THEME_COLORS.primary,
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 [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [agreedToTerms, setAgreedToTerms] = useState(true);
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const validateForm = (): boolean => {

View File

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

View File

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

View File

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

View File

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

View File

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