构建一个React Native身份证扫描应用

许多国家都采用身份证来证明一个人的身份。它们通常采用 8.6 厘米 x 5.4 厘米大小的卡片设计,许多还带有三行MRZ机读文本,比如德国和荷兰的身份证。

荷兰身份证样本:

在本文中,我们将讨论如何使用React Native编写一个身份证扫描应用。它可以捕获身份证的正面和背面,并通过使用OCR识别MRZ来提取持卡人的信息。本文使用Dynamsoft Label Recognizer提供OCR功能。

演示视频

新建项目

创建一个新的React Native项目:

bash 复制代码
npx react-native@latest init IDCardScanner

添加依赖项

  1. 安装react-native-vision-camera用于访问摄像头。

    bash 复制代码
    npm install react-native-vision-camera
  2. 安装vision-camera-dynamsoft-label-recognizer以识别MRZ。

    bash 复制代码
    npm install vision-camera-dynamsoft-label-recognizer
  3. 安装vision-camera-cropper以裁剪摄像头画面。

    bash 复制代码
    npm install vision-camera-cropper react-native-worklets-core
  4. 安装react-native-svg用于绘制表示裁剪区域的矩形框。

    bash 复制代码
    npm install react-native-svg
  5. 安装react-navigation用于导航和路由。

    bash 复制代码
    npm install @react-navigation/native @react-navigation/native-stack react-native-safe-area-context react-native-screens
  6. 安装@react-native-async-storage/async-storage以存储扫描的身份证。

    bash 复制代码
    npm install @react-native-async-storage/async-storage
  7. 安装mrz以解析MRZ。

    bash 复制代码
    npm install mrz

添加相机权限

对于iOS,将以下内容添加到Info.plist

xml 复制代码
<key>NSCameraUsageDescription</key>
<string>For document scanning</string>

对于 Android,在AndroidManifest.xml中添加以下内容。

xml 复制代码
<uses-permission android:name="android.permission.CAMERA" />

导航

该应用程序有三屏。

  1. 主屏幕页面。我们可以在此页面上管理扫描的身份证件。

  2. 身份证屏幕页面。我们可以扫描新的身份证件,也可以在此页面上查看和修改已有身份证件。

  3. 相机屏幕页面。我们可以在此页面打开摄像头,裁剪身份证件图片。

使用React Navigation来管理屏幕和导航。

  1. src/screens下创建屏幕页面文件:HomeScreen.tsxCardScreen.tsxCameraScreen.tsx

  2. 更新App.tsx以使用React Navigation。

    tsx 复制代码
    import React from 'react';
    import { NavigationContainer } from '@react-navigation/native';
    import { createNativeStackNavigator } from '@react-navigation/native-stack';
    import HomeScreen from './screens/HomeScreen';
    import CameraScreen from './screens/CameraScreen';
    import CardScreen from './screens/CardScreen';
    import { TextButton } from './components/TextButton';
    
    const Stack = createNativeStackNavigator();
    
    function App(): React.JSX.Element {
      return (
        <NavigationContainer>
          <Stack.Navigator>
            <Stack.Screen name="Home" component={HomeScreen} />
            <Stack.Screen name="Camera" component={CameraScreen} />
            <Stack.Screen name="Card" component={CardScreen} />
          </Stack.Navigator>
        </NavigationContainer>
      );
    }
    
    export default App;

数据存储方式

定义了一个ScannedCard接口来表示身份证。它包含其背面和正面图像的base64以及提取的信息。

ts 复制代码
export interface ParsedResult {
  Surname:string,
  GivenName:string,
  IDNumber:string,
  DateOfBirth:string,
  DateOfExpiry:string
}

export interface ScannedIDCard {
  backImage:string,
  frontImage:string,
  info:ParsedResult,
  timestamp:number
}

编写了一个IDCardManager用于使用async-storage存储身份证。使用时间戳作键值。

ts 复制代码
import AsyncStorage from '@react-native-async-storage/async-storage';
export class IDCardManager {

  static async saveIDCard(card:ScannedIDCard) {
    await AsyncStorage.setItem(card.timestamp.toString(),JSON.stringify(card));
  }

  static async deleteIDCard(key:string) {
    await AsyncStorage.removeItem(key);
  }

  static async getKeys(){
    return await AsyncStorage.getAllKeys();
  }

  static async getIDCard(key:string){
    let jsonStr:string|null = await AsyncStorage.getItem(key);
    if (jsonStr) {
      let card:ScannedIDCard = JSON.parse(jsonStr);
      return card;
    }else{
      return null;
    }
  }

  static async listIDCards(){
    let cards:ScannedIDCard[] = [];
    let keys = await this.getKeys();
    for (let index = 0; index < keys.length; index++) {
      const key = keys[index];
      let card = await this.getIDCard(key);
      if (card) {
        cards.push(card);
      }
    }
    return cards;
  }
}

主屏幕页面

  1. src/components/Card.tsx里定义一个Card组件。我们会在主屏幕用它列出扫描的身份证。

    tsx 复制代码
    {% raw %}export interface CardProps{
      cardKey:string;
      onPress?:()=>void;
    }
    export function Card(props:CardProps){
      const [card,setCard] = useState<ScannedIDCard|null>();
      const [pressed,setPressed] = useState(false);
      useEffect(() => {
        (async () => {
          console.log("mounted")
          const result = await IDCardManager.getIDCard(props.cardKey);
          setCard(result);
        })();
      }, []);
    
      const getDate = () => {
        if (card) {
          let timestamp = card.timestamp;
          let date = new Date(timestamp);
          return date.toUTCString();
        }
        return "";
      }
    
      const getCardDetailsText = () => {
        let text = "Name: "+card?.info.GivenName+" "+card?.info.Surname + "\n";
        text = text + "Scanned Date: " + getDate();
        return text;
      }
    
      return (
        <Pressable
          onPress={props.onPress}
          onPressIn={()=>setPressed(true)}
          onPressOut={()=>setPressed(false)}
        >
          <View style={[styles.card,pressed?styles.pressed:null]}>
            <Image
              style={styles.cardImage}
              source={{
                uri: 'data:image/jpeg;base64,'+card?.frontImage,
              }}
            />
            <View style={styles.cardDetails}>
              <Text>{getCardDetailsText()}</Text>
            </View>
          </View>
        </Pressable>
      )
    }
    
    const styles = StyleSheet.create({
      card:{
        flex:1,
        display:"flex",
        flexDirection:"row",
        margin: 10,
        padding:10,
        borderColor:"gray",
        borderWidth:0.2,
        borderRadius:3
      },
      pressed:{
        backgroundColor:"lightgray",
      },
      cardImage:{
        width: 100,
        height: 70,
        resizeMode:"cover"
      },
      cardDetails:{
        flex:1,
        padding:10,
        justifyContent:"center",
        flexDirection:"row"
      },
    });{% endraw %}
  2. 在主屏幕上列出现有的身份证。

    tsx 复制代码
    interface HomeScreenProps {
      route:any;
      navigation:any;
    }
    
    export default function HomeScreen(props:HomeScreenProps){
      const selectedCardKey = useRef("");
      const [cardKeys,setCardKeys] = useState<readonly string[]>([]);
      useEffect(() => {
        const unsubscribe = props.navigation.addListener('focus', async () => {
          console.log("screen focused");
          setCardKeys([]);//force rendering
          setCardKeys(await IDCardManager.getKeys());
        });
        return unsubscribe;
      }, [props.navigation]);
    
      const cardPressed = (key:string) => {
        selectedCardKey.current = key;
      }
    
      const renderCards = () => {
        let cards:React.ReactElement[] = [];
        if (cardKeys.length == 0) {
          return;
        }
        cardKeys.forEach(async cardKey =>  {
          let card = <Card key={cardKey} cardKey={cardKey} onPress={()=>cardPressed(cardKey)}></Card>;
          cards.push(card);
        });
        if (cards.length>0) {
          return cards;
        }
      }
    
      return (
        <View style={StyleSheet.absoluteFill}>
          <ScrollView style={styles.cardList}>
            {renderCards()}
          </ScrollView>
        </View>
      )
    }
  3. 身份证组件被点击时,显示一个模态框供用户选择打开或删除该身份证。

    JSX:

    tsx 复制代码
    {% raw %}<Modal
      animationType="slide"
      transparent={true}
      visible={modalVisible}
      onRequestClose={() => {
        setModalVisible(!modalVisible);
      }}>
      <View style={styles.centeredView}>
        <View style={styles.modalView}>
          <Text style={styles.modalText}>Please select an action:</Text>
          <View style={{flexDirection:"row"}}>
            <Pressable
              style={styles.button}
              onPress={() => performAction("open")}>
              <Text style={styles.textStyle}>Open</Text>
            </Pressable>
            <Pressable
              style={styles.button}
              onPress={() => performAction("delete")}>
              <Text style={styles.textStyle}>Delete</Text>
            </Pressable>
          </View>
        </View>
      </View>
    </Modal>{% endraw %}

    函数:

    tsx 复制代码
    const [modalVisible,setModalVisible] = useState(false);
    const goToCardScreen = () => {
      console.log("goToCardScreen");
      props.navigation.navigate('Card',{
        cardKey: selectedCardKey.current,
      });
    }
    
    const cardPressed = (key:string) => {
      selectedCardKey.current = key;
      setModalVisible(true);
    }
    
    const performAction = async (mode:"delete"|"open") => {
      if (mode === "delete") {
        await IDCardManager.deleteIDCard(selectedCardKey.current);
        setCardKeys(await IDCardManager.getKeys());
      }else{
        goToCardScreen();
      }
      setModalVisible(!modalVisible);
    }
  4. 添加一个底部的工具栏,在上面添加一个"Scan"按钮,用于导航到身份证屏幕。

    JSX:

    tsx 复制代码
    <View style={[styles.bottomBar, styles.elevation,styles.shadowProp]}>
      <Pressable onPress={()=>{selectedCardKey.current ="";goToCardScreen()}}>
        <View style={styles.circle}>
          <Text style={styles.buttonText}>SCAN</Text>
        </View>
      </Pressable>
    </View>

    样式:

    tsx 复制代码
    const styles = StyleSheet.create({
      bottomBar:{
        width: "100%",
        height: 45,
        marginTop: 5,
        flexDirection:"row",
        justifyContent:"center",
        backgroundColor:"white",
      },
      shadowProp: {
        shadowColor: '#171717',
        shadowOffset: {width: 2, height: 4},
        shadowOpacity: 0.2,
        shadowRadius: 3,
      },
      elevation: {
        elevation: 20,
        shadowColor: '#52006A',
      },
      circle: {
        width: 60,
        height: 60,
        borderRadius: 60 / 2,
        backgroundColor: "rgb(120,190,250)",
        top:-25,
        justifyContent:"center",
      },
      buttonText:{
        alignSelf:"center",
        color:"white",
      },
    });

相机屏幕页面

  1. 在相机屏幕上,使用Vision Camera打开相机,添加捕获按钮和一个标明裁剪的区域矩形。

    tsx 复制代码
    const [hasPermission, setHasPermission] = useState(false);
    const [isActive,setIsActive] = useState(true);
    const device = useCameraDevice("back");
    const format = useCameraFormat(device, [
      { videoResolution: { width: 1920, height: 1080 } },
      { fps: 30 }
    ])
    useEffect(() => {
      (async () => {
        const status = await Camera.requestCameraPermission();
        setHasPermission(status === 'granted');
        setIsActive(true);
      })();
    }, []);
    
    return (
      <View style={StyleSheet.absoluteFill}>
        {device != null &&
        hasPermission && (
        <>
          <Camera
            style={StyleSheet.absoluteFill}
            isActive={isActive}
            device={device}
            format={format}
            frameProcessor={frameProcessor}
            pixelFormat='yuv'
          />
           <Svg preserveAspectRatio={(Platform.OS == 'ios') ? '':'xMidYMid slice'} style={StyleSheet.absoluteFill} viewBox={getViewBox()}>
            <Rect
              x={cropRegion.left/100*getFrameSize().width}
              y={cropRegion.top/100*getFrameSize().height}
              width={cropRegion.width/100*getFrameSize().width}
              height={cropRegion.height/100*getFrameSize().height}
              strokeWidth="2"
              stroke="red"
              fillOpacity={0.0}
            />
          </Svg>
        </>
        )}
        <View style={[styles.bottomBar]}>
          <Pressable
            onPressIn={()=>{setPressed(true)}}
            onPressOut={()=>{setPressed(false)}}
            onPress={()=>{capture()}}>
            <View style={styles.outerCircle}>
            <View style={[styles.innerCircle, pressed ? styles.circlePressed:null]}></View>
            </View>
          </Pressable>
        </View>
      </View>
    )
  2. 根据身份证的比例设置裁剪区域。

    tsx 复制代码
    const [cropRegion,setCropRegion] = useState({
      left: 10,
      top: 20,
      width: 80,
      height: 30
    });
    const cropRegionShared = useSharedValue<undefined|CropRegion>(undefined);
    
    const adaptCropRegionForIDCard = () => {
      let size = getFrameSize();
      let regionWidth = 0.8*size.width;
      let desiredRegionHeight = regionWidth/(85.6/54);
      let height = Math.ceil(desiredRegionHeight/size.height*100);
      let region = {
        left:10,
        width:80,
        top:20,
        height:height
      };
      setCropRegion(region);
      cropRegionShared.value = region;
    }
    
    const getFrameSize = ():{width:number,height:number} => {
      let size = {width:1080,height:1920};
      return size;
    }
    
    useEffect(() => {
      (async () => {
        adaptCropRegionForIDCard();
      })();
    }, []);
  3. 定义帧处理器,以便在按下捕捉按钮时捕捉帧。捕获后将返回到上一个屏幕,并返回裁剪后的帧的base64。

    tsx 复制代码
    const shouldTake = useSharedValue(false);
    const [pressed,setPressed] = useState(false);
    const capture = () => {
      shouldTake.value=true;
    }
    
    const onCaptured = (base64:string) => {
      setIsActive(false);
      if (props) {
        if (props.navigation) {
          props.navigation.navigate({
            name: 'Card',
            params: { base64: base64 },
            merge: true,
          });
        }
      }
    }
    
    const onCapturedJS = Worklets.createRunInJsFn(onCaptured);
    const frameProcessor = useFrameProcessor((frame) => {
      'worklet';
      if (shouldTake.value == true && cropRegionShared.value != undefined) {
        shouldTake.value = false;
        const result = crop(frame,{cropRegion:cropRegion,includeImageBase64:true,saveAsFile:false});
        if (result.base64) {
          onCapturedJS(result.base64);
        }
      }
    }, []);

身份证屏幕页面

在身份证屏幕上,显示身份证的图像和信息,并允许编辑。

  1. 定义JSX:

    tsx 复制代码
    {% raw %}const isFrontRef = useRef(false);
    const goToCameraScreen = (isFront:boolean) => {
      isFrontRef.current = isFront;
      props.navigation.navigate('Camera');
    }
    const Card = (props:{isFront:boolean}) => {
      let base64;
      if (props.isFront) {
        base64 = frontImageBase64;
      }else{
        base64 = backImageBase64;
      }
      let innerControl;
      if (!base64) {
        innerControl =
          <View style={styles.buttonContainer}>
            <Button title="Add Image"
              onPress={()=>{goToCameraScreen(props.isFront)}}
            ></Button>
          </View>
      }else{
        innerControl =
          <View style={styles.imageContainer}>
            <Pressable
              onPress={()=>{goToCameraScreen(props.isFront)}}
            >
              <Image
                style={styles.cardImage}
                source={{
                uri: 'data:image/jpeg;base64,'+base64,
              }}></Image>
            </Pressable>
          </View>
      }
      return (
        <>
          <Text style={styles.header}>
            {props.isFront?"Front":"Back"} Image:
          </Text>
          {innerControl}
        </>
      )
    }
    
    const Fields = () => {
      const onChangeText = (key:string,text:string) => {
        console.log("onChangeText");
        let result:any = JSON.parse(JSON.stringify(parsedResult));
        result[key] = text;
        setParsedResult(result);
      }
      let fieldArray = [];
      let keys = Object.keys(parsedResult);
      for (let index = 0; index < keys.length; index++) {
        let key = keys[index];
        const value = (parsedResult as any)[key];
        let view =
        <View style={styles.infoField} key={"field-"+key}>
          <Text style={styles.fieldLabel}>{key+":"}</Text>
          <TextInput
            style={styles.fieldInput}
            onChangeText={(text)=>{onChangeText(key,text)}}
            value={value}/>
        </View>
        fieldArray.push(view);
      }
      return (
        fieldArray
      )
    }
    
    return (
      <View style={StyleSheet.absoluteFill}>
        <ScrollView>
          {Card({isFront:true})}
          {Card({isFront:false})}
          <Text style={styles.header}>
            Info
          </Text>
          {Fields()}
        </ScrollView>
      </View>
    ){% endraw %}
  2. 组件挂载后,如果指定了存储的键值,则加载该身份证信息。

    tsx 复制代码
    const cardKey = useRef("");
    const [frontImageBase64,setFrontImageBase64] = useState("");
    const [backImageBase64,setBackImageBase64] = useState("");
    const [parsedResult,setParsedResult] = useState<ParsedResult>(
      {
        Surname:"",
        GivenName:"",
        IDNumber:"",
        DateOfBirth:"",
        DateOfExpiry:""
      }
    );
    useEffect(() => {
      const init = async () => {
        console.log(props);
        let key = props.route.params.cardKey;
        if (key) {
          cardKey.current = key;
          let IDCard = await IDCardManager.getIDCard(key);
          if (IDCard) {
            setFrontImageBase64(IDCard.frontImage);
            setBackImageBase64(IDCard.backImage);
            setParsedResult(IDCard.info);
          }
        }
      }
      init()
    }, []);
  3. 拍摄背面图像后,尝试识别身份证的MRZ行并提取信息。

    tsx 复制代码
    useEffect(() => {
      if (props.route.params?.base64) {
        let base64 = props.route.params?.base64;
        if (isFrontRef.current === true) {
          setFrontImageBase64(base64);
        }else{
          setBackImageBase64(base64);
          recognizeIDCard(base64);
        }
      }
    }, [props.route.params?.base64]);
    
    const recognizeIDCard = async (base64:string) => {
      const result = await decodeBase64(base64)
      if (result.results.length>0) {
        let lineResults = result.results[0].lineResults;
        if (lineResults.length >= 3) {
          let MRZLines = [];
          MRZLines.push(lineResults[lineResults.length - 3].text);
          MRZLines.push(lineResults[lineResults.length - 2].text);
          MRZLines.push(lineResults[lineResults.length - 1].text);
          console.log(MRZLines);
          let parsed = parse(MRZLines);
          let result = {
            Surname:parsed.fields.lastName ?? "",
            GivenName:parsed.fields.firstName ?? "",
            IDNumber:parsed.fields.documentNumber ?? "",
            DateOfBirth:parsed.fields.birthDate ?? "",
            DateOfExpiry:parsed.fields.expirationDate ?? ""
          }
          setParsedResult(result);
          return;
        }
      }
      Alert.alert("","Failed to recognize the card.");
    }
  4. 按下标题栏上的"Save"按钮时,保存扫描的身份证。

    tsx 复制代码
    useEffect(() => {
      // Use `setOptions` to update the button that we previously specified
      // Now the button includes an `onPress` handler to update the count
      props.navigation.setOptions({
        headerRight: () => (
          <TextButton title="Save" onPress={()=>saveCard()}></TextButton>
        ),
      });
    }, [props.navigation,parsedResult,frontImageBase64,backImageBase64]);
    
    const saveCard = async () => {
      let complete = isInfoComplete();
      if (complete) {
        let key;
        if (cardKey.current) {
          key = parseInt(cardKey.current);
        }else{
          key = new Date().getTime();
          cardKey.current = key.toString();
        }
        let card:ScannedIDCard = {
          frontImage:frontImageBase64,
          backImage:backImageBase64,
          info:parsedResult,
          timestamp:key
        }
        await IDCardManager.saveIDCard(card);
        Alert.alert("","Saved");
      }else{
        Alert.alert("","Card info not complete");
      }
    }

以下是配置Dynamsoft Label Recognize以识别MRZ的步骤。

  1. 将MRZ模型文件放入项目中。可以在此处找到文件。

    对于Android,需要将MRZ模型文件放在assets下。

    对于iOS,将MRZ模型文件夹以reference的形式添加。

  2. 使用许可证初始化Dynamsoft Label Recognizer并更新其模板。可以在这里申请许可证。

    tsx 复制代码
    useEffect(() => {
      (async () => {
        let success = await initLicense("DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ==");
        if (!success) {
          Alert.alert("","License for the MRZ Reader is invalid.");
        }
        try {
          await useCustomModel({customModelFolder:"MRZ",customModelFileNames:["MRZ"]});
          await updateTemplate("{\"CharacterModelArray\":[{\"DirectoryPath\":\"\",\"Name\":\"MRZ\"}],\"LabelRecognizerParameterArray\":[{\"Name\":\"default\",\"ReferenceRegionNameArray\":[\"defaultReferenceRegion\"],\"CharacterModelName\":\"MRZ\",\"LetterHeightRange\":[5,1000,1],\"LineStringLengthRange\":[30,44],\"LineStringRegExPattern\":\"([ACI][A-Z<][A-Z<]{3}[A-Z0-9<]{9}[0-9][A-Z0-9<]{15}){(30)}|([0-9]{2}[(01-12)][(01-31)][0-9][MF<][0-9]{2}[(01-12)][(01-31)][0-9][A-Z<]{3}[A-Z0-9<]{11}[0-9]){(30)}|([A-Z<]{0,26}[A-Z]{1,3}[(<<)][A-Z]{1,3}[A-Z<]{0,26}<{0,26}){(30)}|([ACIV][A-Z<][A-Z<]{3}([A-Z<]{0,27}[A-Z]{1,3}[(<<)][A-Z]{1,3}[A-Z<]{0,27}){(31)}){(36)}|([A-Z0-9<]{9}[0-9][A-Z<]{3}[0-9]{2}[(01-12)][(01-31)][0-9][MF<][0-9]{2}[(01-12)][(01-31)][0-9][A-Z0-9<]{8}){(36)}|([PV][A-Z<][A-Z<]{3}([A-Z<]{0,35}[A-Z]{1,3}[(<<)][A-Z]{1,3}[A-Z<]{0,35}<{0,35}){(39)}){(44)}|([A-Z0-9<]{9}[0-9][A-Z<]{3}[0-9]{2}[(01-12)][(01-31)][0-9][MF<][0-9]{2}[(01-12)][(01-31)][0-9][A-Z0-9<]{14}[A-Z0-9<]{2}){(44)}\",\"MaxLineCharacterSpacing\":130,\"TextureDetectionModes\":[{\"Mode\":\"TDM_GENERAL_WIDTH_CONCENTRATION\",\"Sensitivity\":8}],\"Timeout\":9999}],\"LineSpecificationArray\":[{\"BinarizationModes\":[{\"BlockSizeX\":30,\"BlockSizeY\":30,\"Mode\":\"BM_LOCAL_BLOCK\",\"MorphOperation\":\"Close\"}],\"LineNumber\":\"\",\"Name\":\"defaultTextArea->L0\"}],\"ReferenceRegionArray\":[{\"Localization\":{\"FirstPoint\":[0,0],\"SecondPoint\":[100,0],\"ThirdPoint\":[100,100],\"FourthPoint\":[0,100],\"MeasuredByPercentage\":1,\"SourceType\":\"LST_MANUAL_SPECIFICATION\"},\"Name\":\"defaultReferenceRegion\",\"TextAreaNameArray\":[\"defaultTextArea\"]}],\"TextAreaArray\":[{\"Name\":\"defaultTextArea\",\"LineSpecificationNameArray\":[\"defaultTextArea->L0\"]}]}");
        } catch (error:any) {
          console.log(error);
          Alert.alert("Error","Failed to load model.");
        }
      })();
    }, []);

源代码

好了,我们已经介绍了React Native身份证扫描应用的关键部分。获取源代码来自己试用一下吧:

github.com/tony-xlh/re...

相关推荐
passerby60615 分钟前
完成前端时间处理的另一块版图
前端·github·web components
掘了13 分钟前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅16 分钟前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅37 分钟前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅1 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment1 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅1 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊1 小时前
jwt介绍
前端
爱敲代码的小鱼1 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
Cobyte2 小时前
AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用
前端·后端·aigc