ソースを参照

feat: 档案tab 新增实名认证入口

- 新增 app/certification 页:未认证显示姓名+身份证表单,已认证展示脱敏信息不可修改
- 复用 /api/myProfile/basicInfo/update 写入(后端自动置 realNameFlag=1),避免新增后端接口
- MenuCell 菜单加"实名认证"项;档案主页 user card 昵称旁新增已/未实名角标
zbb 2 日 前
コミット
c96e2a582a

+ 63 - 3
app/(tabs)/mine.tsx

@@ -122,6 +122,9 @@ export default function MineScreen() {
       case "4_5": // 设置
         router.push("/setting" as any);
         break;
+      case "4_10": // 实名认证
+        router.push("/certification" as any);
+        break;
       default:
         break;
     }
@@ -238,9 +241,34 @@ export default function MineScreen() {
 
             <View style={styles.userInfo}>
               <View style={styles.nicknameRow}>
-                <Text style={styles.nickname}>
-                  {userInfo?.nickname || "未授权访问"}
-                </Text>
+                <View style={styles.nicknameLeft}>
+                  <Text style={styles.nickname} numberOfLines={1}>
+                    {userInfo?.nickname || "未授权访问"}
+                  </Text>
+                  {userInfo && (
+                    <TouchableOpacity
+                      onPress={() => handleMenuPress("/certification")}
+                      style={[
+                        styles.realNameBadge,
+                        userInfo.realNameFlag === 1
+                          ? styles.realNameBadgeDone
+                          : styles.realNameBadgeTodo,
+                      ]}
+                      activeOpacity={0.7}
+                    >
+                      <Text
+                        style={[
+                          styles.realNameBadgeText,
+                          userInfo.realNameFlag === 1
+                            ? styles.realNameBadgeTextDone
+                            : styles.realNameBadgeTextTodo,
+                        ]}
+                      >
+                        {userInfo.realNameFlag === 1 ? "已实名" : "未实名"}
+                      </Text>
+                    </TouchableOpacity>
+                  )}
+                </View>
                 {userInfo && (
                   <TouchableOpacity
                     onPress={() => handleMenuPress("/profile")}
@@ -476,6 +504,38 @@ const styles = StyleSheet.create({
     color: "#fff",
     fontSize: 18,
     fontWeight: "bold",
+    maxWidth: 140,
+  },
+  nicknameLeft: {
+    flexDirection: "row",
+    alignItems: "center",
+    flex: 1,
+    flexWrap: "wrap",
+  },
+  realNameBadge: {
+    marginLeft: 8,
+    paddingHorizontal: 6,
+    paddingVertical: 2,
+    borderRadius: 4,
+    borderWidth: 1,
+  },
+  realNameBadgeDone: {
+    borderColor: Colors.neonBlue,
+    backgroundColor: "rgba(0, 243, 255, 0.12)",
+  },
+  realNameBadgeTodo: {
+    borderColor: Colors.neonPink,
+    backgroundColor: "rgba(255, 64, 129, 0.12)",
+  },
+  realNameBadgeText: {
+    fontSize: 10,
+    fontWeight: "600",
+  },
+  realNameBadgeTextDone: {
+    color: Colors.neonBlue,
+  },
+  realNameBadgeTextTodo: {
+    color: Colors.neonPink,
   },
   editBtn: {
     paddingHorizontal: 10,

+ 9 - 0
app/certification/_layout.tsx

@@ -0,0 +1,9 @@
+import { Stack } from "expo-router";
+
+export default function CertificationLayout() {
+  return (
+    <Stack screenOptions={{ headerShown: false }}>
+      <Stack.Screen name="index" />
+    </Stack>
+  );
+}

+ 346 - 0
app/certification/index.tsx

@@ -0,0 +1,346 @@
+import { Images } from "@/constants/images";
+import { getUserInfo, realNameAuth, UserInfo } from "@/services/user";
+import { useFocusEffect, useRouter } from "expo-router";
+import React, { useCallback, useState } from "react";
+import {
+  Alert,
+  ImageBackground,
+  KeyboardAvoidingView,
+  Platform,
+  ScrollView,
+  StatusBar,
+  StyleSheet,
+  Text,
+  TextInput,
+  TouchableOpacity,
+  View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+const NAME_REG = /^[\u4e00-\u9fa5]{2,10}$/;
+const ID_REG = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/;
+
+const maskName = (name: string) => {
+  if (!name) return "";
+  if (name.length <= 1) return name;
+  return name[0] + "*".repeat(name.length - 1);
+};
+
+const maskIdNum = (id: string) => {
+  if (!id) return "";
+  if (id.length <= 8) return id;
+  return id.slice(0, 3) + "*".repeat(id.length - 7) + id.slice(-4);
+};
+
+export default function CertificationScreen() {
+  const router = useRouter();
+  const insets = useSafeAreaInsets();
+  const [user, setUser] = useState<UserInfo | null>(null);
+  const [idName, setIdName] = useState("");
+  const [idNum, setIdNum] = useState("");
+  const [loading, setLoading] = useState(false);
+  const [submitting, setSubmitting] = useState(false);
+
+  const loadUser = useCallback(async () => {
+    setLoading(true);
+    try {
+      const info = await getUserInfo();
+      setUser(info);
+    } catch (e) {
+      console.error("获取用户信息失败:", e);
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  useFocusEffect(
+    useCallback(() => {
+      loadUser();
+    }, [loadUser]),
+  );
+
+  const isVerified = user?.realNameFlag === 1;
+
+  const handleBack = () => router.back();
+
+  const handleSubmit = async () => {
+    const trimName = idName.trim();
+    const trimId = idNum.trim();
+
+    if (!trimName) {
+      Alert.alert("提示", "请输入真实姓名");
+      return;
+    }
+    if (!NAME_REG.test(trimName)) {
+      Alert.alert("提示", "请输入正确的姓名格式(2-10个中文字符)");
+      return;
+    }
+    if (!trimId) {
+      Alert.alert("提示", "请输入身份证号");
+      return;
+    }
+    if (!ID_REG.test(trimId)) {
+      Alert.alert("提示", "请输入正确的身份证号码");
+      return;
+    }
+
+    try {
+      setSubmitting(true);
+      const ok = await realNameAuth(trimName, trimId);
+      if (ok) {
+        Alert.alert("提示", "提交成功", [
+          { text: "确定", onPress: () => router.back() },
+        ]);
+      }
+    } catch (e) {
+      console.error("提交实名认证失败:", e);
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  return (
+    <ImageBackground
+      source={{ uri: Images.mine.kaixinMineBg }}
+      style={styles.container}
+      resizeMode="cover"
+    >
+      <StatusBar barStyle="light-content" />
+
+      <View style={[styles.header, { paddingTop: insets.top }]}>
+        <TouchableOpacity style={styles.backBtn} onPress={handleBack}>
+          <Text style={styles.backIcon}>‹</Text>
+        </TouchableOpacity>
+        <Text style={styles.headerTitle}>实名认证</Text>
+        <View style={styles.placeholder} />
+      </View>
+
+      <KeyboardAvoidingView
+        style={{ flex: 1 }}
+        behavior={Platform.OS === "ios" ? "padding" : undefined}
+      >
+        <ScrollView
+          style={styles.scrollView}
+          showsVerticalScrollIndicator={false}
+          keyboardShouldPersistTaps="handled"
+        >
+          <View style={styles.content}>
+            <View style={styles.card}>
+              {loading ? (
+                <Text style={styles.loadingText}>加载中...</Text>
+              ) : isVerified ? (
+                <>
+                  <View style={styles.verifiedBanner}>
+                    <Text style={styles.verifiedBannerText}>✓ 已完成实名认证</Text>
+                  </View>
+
+                  <View style={styles.formItem}>
+                    <Text style={styles.label}>真实姓名</Text>
+                    <View style={styles.readonly}>
+                      <Text style={styles.readonlyText}>
+                        {maskName(user?.idName || "")}
+                      </Text>
+                    </View>
+                  </View>
+
+                  <View style={styles.formItem}>
+                    <Text style={styles.label}>身份证号</Text>
+                    <View style={styles.readonly}>
+                      <Text style={styles.readonlyText}>
+                        {maskIdNum(user?.idNum || "")}
+                      </Text>
+                    </View>
+                  </View>
+
+                  <View style={styles.tips}>
+                    <Text style={styles.tipsIcon}>ⓘ</Text>
+                    <Text style={styles.tipsText}>
+                      实名信息已提交,不可修改
+                    </Text>
+                  </View>
+                </>
+              ) : (
+                <>
+                  <View style={styles.formItem}>
+                    <Text style={styles.label}>真实姓名</Text>
+                    <TextInput
+                      style={styles.input}
+                      value={idName}
+                      onChangeText={setIdName}
+                      placeholder="请输入您的真实姓名"
+                      placeholderTextColor="#bbb"
+                      maxLength={20}
+                    />
+                  </View>
+
+                  <View style={styles.formItem}>
+                    <Text style={styles.label}>身份证号</Text>
+                    <TextInput
+                      style={styles.input}
+                      value={idNum}
+                      onChangeText={setIdNum}
+                      placeholder="请输入您的身份证号码"
+                      placeholderTextColor="#bbb"
+                      maxLength={18}
+                      autoCapitalize="characters"
+                    />
+                  </View>
+
+                  <View style={styles.tips}>
+                    <Text style={styles.tipsIcon}>⚠️</Text>
+                    <Text style={styles.tipsText}>
+                      请确保您填写的信息真实有效,提交后将无法修改
+                    </Text>
+                  </View>
+
+                  <TouchableOpacity
+                    style={[styles.submitBtn, submitting && styles.submitBtnDisabled]}
+                    onPress={handleSubmit}
+                    disabled={submitting}
+                    activeOpacity={0.8}
+                  >
+                    <Text style={styles.submitBtnText}>
+                      {submitting ? "提交中..." : "提交"}
+                    </Text>
+                  </TouchableOpacity>
+                </>
+              )}
+            </View>
+          </View>
+        </ScrollView>
+      </KeyboardAvoidingView>
+    </ImageBackground>
+  );
+}
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+  },
+  header: {
+    flexDirection: "row",
+    alignItems: "center",
+    justifyContent: "space-between",
+    paddingHorizontal: 10,
+    height: 80,
+  },
+  backBtn: {
+    width: 40,
+    height: 40,
+    justifyContent: "center",
+    alignItems: "center",
+  },
+  backIcon: {
+    fontSize: 32,
+    color: "#fff",
+    fontWeight: "bold",
+  },
+  headerTitle: {
+    fontSize: 16,
+    fontWeight: "bold",
+    color: "#fff",
+  },
+  placeholder: {
+    width: 40,
+  },
+  scrollView: {
+    flex: 1,
+  },
+  content: {
+    paddingHorizontal: 15,
+    paddingTop: 10,
+    paddingBottom: 30,
+  },
+  card: {
+    backgroundColor: "#fff",
+    borderRadius: 15,
+    padding: 20,
+  },
+  loadingText: {
+    textAlign: "center",
+    color: "#999",
+    paddingVertical: 40,
+  },
+  verifiedBanner: {
+    backgroundColor: "#e6f9ef",
+    borderRadius: 8,
+    paddingVertical: 10,
+    paddingHorizontal: 14,
+    marginBottom: 20,
+    alignItems: "center",
+  },
+  verifiedBannerText: {
+    color: "#1aad5a",
+    fontSize: 14,
+    fontWeight: "600",
+  },
+  formItem: {
+    marginBottom: 20,
+  },
+  label: {
+    fontSize: 14,
+    color: "#333",
+    fontWeight: "500",
+    marginBottom: 10,
+  },
+  input: {
+    width: "100%",
+    height: 40,
+    paddingHorizontal: 10,
+    borderWidth: 1,
+    borderColor: "#e5e5e5",
+    borderRadius: 5,
+    fontSize: 14,
+    color: "#333",
+    backgroundColor: "#f8f8f8",
+  },
+  readonly: {
+    width: "100%",
+    height: 40,
+    paddingHorizontal: 10,
+    borderWidth: 1,
+    borderColor: "#e5e5e5",
+    borderRadius: 5,
+    backgroundColor: "#f5f5f5",
+    justifyContent: "center",
+  },
+  readonlyText: {
+    fontSize: 14,
+    color: "#666",
+    letterSpacing: 1,
+  },
+  tips: {
+    flexDirection: "row",
+    alignItems: "flex-start",
+    padding: 10,
+    marginBottom: 20,
+    backgroundColor: "#fff9e6",
+    borderRadius: 5,
+    borderLeftWidth: 2,
+    borderLeftColor: "#ffdd02",
+  },
+  tipsIcon: {
+    fontSize: 14,
+    marginRight: 6,
+  },
+  tipsText: {
+    flex: 1,
+    fontSize: 12,
+    color: "#ff9900",
+    lineHeight: 18,
+  },
+  submitBtn: {
+    backgroundColor: "#FC7D2E",
+    borderRadius: 8,
+    paddingVertical: 13,
+    alignItems: "center",
+  },
+  submitBtnDisabled: {
+    opacity: 0.6,
+  },
+  submitBtnText: {
+    color: "#fff",
+    fontSize: 16,
+    fontWeight: "600",
+  },
+});

+ 5 - 0
components/mine/MenuCell.tsx

@@ -54,6 +54,11 @@ export function MenuCell({ onItemPress, showWallet = false, showExchange = false
       title: "地址",
       type: "4_3",
     },
+    {
+      icon: Images.mine.setting || "",
+      title: "实名认证",
+      type: "4_10",
+    },
     {
       icon: Images.mine.opinion || "",
       title: "意见反馈",

+ 3 - 1
services/user.ts

@@ -7,7 +7,7 @@ const apis = {
   UPDATE_INFO: '/api/myProfile/basicInfo/update',
   UPDATE_AVATAR: '/api/myProfile/avatar/update',
   UPDATE_NICKNAME: '/api/myProfile/nickname/update',
-  REAL_NAME: '/api/authentication/submit',
+  REAL_NAME: '/api/myProfile/basicInfo/update',
   SEND_CODE: '/api/verifycode/send',
   PARAM_CONFIG: '/param/paramConfig',
   WALLET_AMOUNT: '/api/wallet/ranking/walletAmount',
@@ -35,7 +35,9 @@ export interface UserInfo {
   phone?: string;
   mobile?: string;
   realName?: string;
+  idName?: string;
   idNum?: string;
+  realNameFlag?: number;
   balance?: number;
 }