项目

移动应用开发教程 - React Native

React Native 移动选项适用于团队版或更高级别的许可证。因此,如果您没有商业许可证,建议通过下载示例应用程序的源代码来按照本文进行学习,具体方法将在下一章节中描述。

关于本教程

您必须拥有 ABP 团队版或更高级别的许可证 才能创建移动应用程序。

  • 本教程假设您已完成 Web 应用程序开发教程,并使用 React Native 作为移动选项构建了一个名为 Acme.BookStore 的基于 ABP 的应用程序。因此,如果您尚未完成 Web 应用程序开发教程,您需要先完成它,或从下方下载源代码并按照本教程操作。
  • 在本教程中,我们将仅关注 Acme.BookStore 应用程序的用户界面方面,并实现增删改查操作。
  • 在开始之前,请确保您的计算机上已准备好 React Native 开发环境

下载源代码

您可以使用以下链接下载本文中描述的应用程序的源代码:

如果您在 Windows 上遇到“文件名过长”或“解压”错误,请参阅 本指南

书籍列表页面

React Native 应用程序没有动态代理生成功能,因此我们需要在 ./src/api 文件夹下手动创建 BookAPI 代理。

//./src/api/BookAPI.ts
import api from './API';

export const getList = () => api.get('/api/app/book').then(({ data }) => data);

export const get = id => api.get(`/api/app/book/${id}`).then(({ data }) => data);

export const create = input => api.post('/api/app/book', input).then(({ data }) => data);

export const update = (input, id) => api.put(`/api/app/book/${id}`, input).then(({ data }) => data);

export const remove = id => api.delete(`/api/app/book/${id}`).then(({ data }) => data);

书店 菜单项添加到导航

要创建菜单项,请导航到 ./src/navigators/DrawerNavigator.tsx 文件,并将 BookStoreStack 添加到 Drawer.Navigator 组件中。

//其他导入..
import BookStoreStackNavigator from './BookStoreNavigator';

const Drawer = createDrawerNavigator();

export default function DrawerNavigator() {
  return (
    <Drawer.Navigator
      initialRouteName="Home"
      drawerContent={DrawerContent}
      defaultStatus="closed"
    >
      {/* 已添加的屏幕 */}
      <Drawer.Screen
        name="BookStoreStack"
        component={BookStoreStackNavigator}
        options={{ header: () => null }}
      />
      {/* 已添加的屏幕 */}
    </Drawer.Navigator>
  );
}

./src/navigators/BookStoreNavigator.tsx 中创建 BookStoreStackNavigator,此导航器将用于 BookStore 菜单项。

import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { Button } from 'react-native-paper';
import i18n from 'i18n-js';

import { BookStoreScreen, CreateUpdateAuthorScreen, CreateUpdateBookScreen } from '../screens';

import { HamburgerIcon } from '../components';
import { useThemeColors } from '../hooks';

const Stack = createNativeStackNavigator();

export default function BookStoreStackNavigator() {
  const { background, onBackground } = useThemeColors();

  return (
    <Stack.Navigator initialRouteName="BookStore">
      <Stack.Screen
        name="BookStore"
        component={BookStoreScreen}
        options={({ navigation }) => ({
          title: i18n.t('BookStore::Menu:BookStore'),
          headerLeft: () => <HamburgerIcon navigation={navigation} />,
          headerStyle: { backgroundColor: background },
          headerTintColor: onBackground,
          headerShadowVisible: false,
        })}
      />
      <Stack.Screen
        name="CreateUpdateBook"
        component={CreateUpdateBookScreen}
        options={({ route, navigation }) => ({
          title: i18n.t(route.params?.bookId ? 'BookStore::Edit' : 'BookStore::NewBook'),
          headerRight: () => (
            <Button mode="text" onPress={() => navigation.navigate('BookStore')}>
              {i18n.t('AbpUi::Cancel')}
            </Button>
          ),
          headerStyle: { backgroundColor: background },
          headerTintColor: onBackground,
          headerShadowVisible: false,
        })}
      />
    </Stack.Navigator>
  );
}
  • BookStoreScreen 将用于存放 booksauthors 页面

./src/components/DrawerContent/DrawerContent.tsx 文件的 screens 对象中添加 BookStoreStack。DrawerContent 组件将用于渲染菜单项。

// 导入..
const screens = {
  HomeStack: { label: "::Menu:Home", iconName: "home" },
  DashboardStack: {
    label: "::Menu:Dashboard",
    requiredPolicy: "BookStore.Dashboard",
    iconName: "chart-areaspline",
  },
  UsersStack: {
    label: "AbpIdentity::Users",
    iconName: "account-supervisor",
    requiredPolicy: "AbpIdentity.Users",
  },
  // 添加此属性
  BookStoreStack: {
    label: "BookStore::Menu:BookStore",
    iconName: "book",
  },
  // 添加此属性
  TenantsStack: {
    label: "Saas::Tenants",
    iconName: "book-outline",
    requiredPolicy: "Saas.Tenants",
  },
  SettingsStack: {
    label: "AbpSettingManagement::Settings",
    iconName: "cog",
    navigation: null,
  },
};
// 其他代码..

书店 菜单项

创建书籍列表页面

在创建书籍列表页面之前,我们需要在 ./src/screens/BookStore 文件夹下创建 BookStoreScreen.tsx 文件。此文件将用于存放 booksauthors 页面。

import { useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import i18n from 'i18n-js';
import { BottomNavigation } from 'react-native-paper';

import { BooksScreen } from '../../screens';
import { useThemeColors } from '../../hooks';

const BooksRoute = nav => <BooksScreen navigation={nav} />;

function BookStoreScreen({ navigation }) {
  const [index, setIndex] = React.useState(0);
  const [routes] = React.useState([
    {
      key: "books",
      title: i18n.t("BookStore::Menu:Books"),
      focusedIcon: "book",
      unfocusedIcon: "book-outline",
    },
  ]);

  const renderScene = BottomNavigation.SceneMap({
    books: BooksRoute,
  });

  return (
    <BottomNavigation
      navigationState={{ index, routes }}
      onIndexChange={setIndex}
      renderScene={renderScene}
    />
  );
}
export default BookStoreScreen;

./src/screens/BookStore/Books 文件夹下创建 BooksScreen.tsx 文件。

import { useSelector } from "react-redux";
import { View } from "react-native";
import { List } from "react-native-paper";
import { getBooks } from "../../api/BookAPI";
import i18n from "i18n-js";
import DataList from "../../components/DataList/DataList";
import { createAppConfigSelector } from "../../store/selectors/AppSelectors";
import { useThemeColors } from '../../../hooks';

function BooksScreen({ navigation }) {
  const { background, primary } = useThemeColors();
  const currentUser = useSelector(createAppConfigSelector())?.currentUser;

  return (
    <View style={{ flex: 1, backgroundColor: background }}>
      {currentUser?.isAuthenticated && (
        <DataList
          navigation={navigation}
          fetchFn={getBooks}
          render={({ item }) => (
            <List.Item
              key={item.id}
              title={item.name}
              description={i18n.t("BookStore::Enum:BookType." + item.type)}
            />
          )}
        />
      )}
    </View>
  );
}
export default BooksScreen;
  • getBooks 函数用于从服务器获取书籍。
  • i18n API 用于本地化给定的键。它使用来自 application-localization 端点的传入资源。
  • DataList 组件接收我们将传递给 API 请求函数的 fetchFn 属性,它用于获取数据并维护懒加载等逻辑。

书籍列表页面

创建新书

为日期功能添加 @react-native-community/datetimepicker

yarn expo install @react-native-community/datetimepicker

// 或

npx expo install @react-native-community/datetimepicker

CreateUpdateBook 屏幕添加到 BookStoreNavigator

BookStoreScreen 类似,我们需要将 CreateUpdateBookScreen 添加到 ./src/navigators/BookStoreNavigator.tsx 文件中。

//其他代码

import { Button } from "react-native-paper"; // 添加此行

import { CreateUpdateBookScreen } from '../screens'; // 添加此行

//其他代码

export default function BookStoreStackNavigator() {
  return (
    <Stack.Navigator initialRouteName="BookStore">
      {/* 其他屏幕 */}
      {/* 已添加此屏幕 */}
      <Stack.Screen
        name="CreateUpdateBook"
        component={CreateUpdateBookScreen}
        options={({ route, navigation }) => ({
          title: i18n.t(
            route.params?.bookId ? "BookStore::Edit" : "BookStore::NewBook"
          ),
          headerRight: () => (
            <Button
              mode="text"
              onPress={() => navigation.navigate("BookStore")}
            >
              {i18n.t("AbpUi::Cancel")}
            </Button>
          ),
          headerStyle: { backgroundColor: background },
          headerTintColor: onBackground,
          headerShadowVisible: false,
        })}
      />
    </Stack.Navigator>
  );
}

为了导航到 CreateUpdateBookScreen,我们需要在 BooksScreen.tsx 文件中添加 CreateUpdateBook 按钮。

//其他导入..

import {
  // 其他导入..,
  StyleSheet,
} from "react-native";

import {
  // 其他导入..,
  AnimatedFAB,
} from "react-native-paper";

function BooksScreen({ navigation }) {
  //其他代码..

  return (
    <View style={{ flex: 1, backgroundColor: background }}>
      {/* 其他代码..*/}

      {/* 包含的代码 */}
      {currentUser?.isAuthenticated && (
        <AnimatedFAB
          icon={"plus"}
          label={i18n.t("BookStore::NewBook")}
          color="white"
          extended={false}
          onPress={() => navigation.navigate("CreateUpdateBook")}
          visible={true}
          animateFrom={"right"}
          iconMode={"static"}
          style={[styles.fabStyle, { backgroundColor: primary }]}
        />
      )}
      {/* 包含的代码 */}
    </View>
  );
}

// 添加的行
const styles = StyleSheet.create({
  container: {
    flexGrow: 1,
  },
  fabStyle: {
    bottom: 16,
    right: 16,
    position: "absolute",
  },
});
// 添加的行

export default BooksScreen;

添加 CreateUpdateBook 按钮后,我们需要在 ./src/screens/BookStore/Books/CreateUpdateBook 文件夹下添加 CreateUpdateBookScreen.tsx 文件。

import PropTypes from "prop-types";

import { create } from "../../../../api/BookAPI";
import LoadingActions from "../../../../store/actions/LoadingActions";
import { createLoadingSelector } from "../../../../store/selectors/LoadingSelectors";
import { connectToRedux } from "../../../../utils/ReduxConnect";
import CreateUpdateBookForm from "./CreateUpdateBookForm";

function CreateUpdateBookScreen({ navigation, startLoading, clearLoading }) {
  const submit = (data) => {
    startLoading({ key: "save" });

    create(data)
      .then(() => navigation.goBack())
      .finally(() => clearLoading());
  };

  return <CreateUpdateBookForm submit={submit} />;
}

CreateUpdateBookScreen.propTypes = {
  startLoading: PropTypes.func.isRequired,
  clearLoading: PropTypes.func.isRequired,
};

export default connectToRedux({
  component: CreateUpdateBookScreen,
  stateProps: (state) => ({ loading: createLoadingSelector()(state) }),
  dispatchProps: {
    startLoading: LoadingActions.start,
    clearLoading: LoadingActions.clear,
  },
});
  • 在此页面中,我们将存储逻辑、发送 POST/PUT 请求、获取选定的书籍数据等。
  • 此页面将包装 CreateUpdateBookFrom 组件,并传递 submit 函数及其他属性。

./src/screens/BookStore/Books/CreateUpdateBook 文件夹下创建一个 CreateUpdateBookForm.tsx 文件,并向其中添加以下代码。

import * as Yup from 'yup';
import { useRef, useState } from 'react';
import { Platform, KeyboardAvoidingView, StyleSheet, View, ScrollView } from 'react-native';
import { useFormik } from 'formik';
import i18n from 'i18n-js';
import PropTypes from 'prop-types';
import { TextInput, Portal, Modal, Text, Divider, Button } from 'react-native-paper';
import DateTimePicker from '@react-native-community/datetimepicker';

import { FormButtons, ValidationMessage, AbpSelect } from '../../../../components';
import { useThemeColors } from '../../../../hooks';


const validations = {
  name: Yup.string().required("AbpValidation::ThisFieldIsRequired."),
  price: Yup.number().required("AbpValidation::ThisFieldIsRequired."),
  type: Yup.string().nullable().required("AbpValidation::ThisFieldIsRequired."),
  publishDate: Yup.string()
    .nullable()
    .required("AbpValidation::ThisFieldIsRequired."),
};

const props = {
  underlineStyle: { backgroundColor: "transparent" },
  underlineColor: "#333333bf",
};

function CreateUpdateBookForm({ submit }) {
  const { primaryContainer, background, onBackground } = useThemeColors();

  const [bookTypeVisible, setBookTypeVisible] = useState(false);
  const [publishDateVisible, setPublishDateVisible] = useState(false);

  const nameRef = useRef(null);
  const priceRef = useRef(null);
  const typeRef = useRef(null);
  const publishDateRef = useRef(null);

  const inputStyle = {
    ...styles.input,
    backgroundColor: primaryContainer,
  };
  const bookTypes = new Array(8).fill(0).map((_, i) => ({
    id: i + 1,
    displayName: i18n.t(`BookStore::Enum:BookType.${i + 1}`),
  }));

  const onSubmit = (values) => {
    if (!bookForm.isValid) {
      return;
    }

    submit({ ...values });
  };

  const bookForm = useFormik({
    enableReinitialize: true,
    validateOnBlur: true,
    validationSchema: Yup.object().shape({
      ...validations,
    }),
    initialValues: {
      name: "",
      price: "",
      type: "",
      publishDate: null,
    },
    onSubmit,
  });

  const isInvalidControl = (controlName = null) => {
    if (!controlName) {
      return;
    }

    return (
      ((!!bookForm.touched[controlName] && bookForm.submitCount > 0) ||
        bookForm.submitCount > 0) &&
      !!bookForm.errors[controlName]
    );
  };

  const onChange = (event, selectedDate) => {
    if (!selectedDate) {
      return;
    }

    setPublishDateVisible(false);

    if (event && event.type !== "dismissed") {
      bookForm.setFieldValue("publishDate", selectedDate, true);
    }
  };

  return (
    <View style={{ flex: 1, backgroundColor: background }}>
      <AbpSelect
        key="typeSelect"
        title={i18n.t("BookStore::Type")}
        visible={bookTypeVisible}
        items={bookTypes}
        hasDefualtItem={true}
        hideModalFn={() => setBookTypeVisible(false)}
        selectedItem={bookForm.values.type}
        setSelectedItem={(id) => {
          bookForm.setFieldValue("type", id, true);
          bookForm.setFieldValue(
            "typeDisplayName",
            bookTypes.find((f) => f.id === id)?.displayName || null,
            false
          );
        }}
      />

      <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
        <ScrollView keyboardShouldPersistTaps="handled">
          <View style={styles.inputContainer}>
            <TextInput
              mode="flat"
              ref={nameRef}
              error={isInvalidControl('name')}
              onSubmitEditing={() => priceRef.current.focus()}
              returnKeyType="next"
              onChangeText={bookForm.handleChange('name')}
              onBlur={bookForm.handleBlur('name')}
              value={bookForm.values.name}
              autoCapitalize="none"
              label={i18n.t('BookStore::Name')}
              style={inputStyle}
              {...props}
            />
            {isInvalidControl('name') && (
              <ValidationMessage>{bookForm.errors.name as string}</ValidationMessage>
            )}
          </View>

          <View style={styles.inputContainer}>
            <TextInput
              mode="flat"
              ref={priceRef}
              error={isInvalidControl('price')}
              onSubmitEditing={() => typeRef.current.focus()}
              returnKeyType="next"
              onChangeText={bookForm.handleChange('price')}
              onBlur={bookForm.handleBlur('price')}
              value={bookForm.values.price}
              autoCapitalize="none"
              label={i18n.t('BookStore::Price')}
              style={inputStyle}
              {...props}
            />
            {isInvalidControl('price') && (
              <ValidationMessage>{bookForm.errors.price as string}</ValidationMessage>
            )}
          </View>

          <View style={styles.inputContainer}>
            <TextInput
              ref={typeRef}
              error={isInvalidControl('type')}
              label={i18n.t('BookStore::Type')}
              right={<TextInput.Icon onPress={() => setBookTypeVisible(true)} icon="menu-down" />}
              style={inputStyle}
              editable={false}
              value={bookForm.values.typeDisplayName}
              {...props}
            />
            {isInvalidControl('type') && (
              <ValidationMessage>{bookForm.errors.type as string}</ValidationMessage>
            )}
          </View>

          <View style={styles.inputContainer}>
            <TextInput
              ref={publishDateRef}
              error={isInvalidControl('publishDate')}
              label={i18n.t('BookStore::PublishDate')}
              right={
                <TextInput.Icon 
                  onPress={() => setPublishDateVisible(true)} 
                  icon="calendar" 
                  iconColor={bookForm.values.publishDate ? '#4CAF50' : '#666'}
                />
              }
              style={inputStyle}
              editable={false}
              value={formatDate(bookForm.values.publishDate)}
              placeholder="Select publish date"
              {...props}
            />
            {isInvalidControl('publishDate') && (
              <ValidationMessage>{bookForm.errors.publishDate as string}</ValidationMessage>
            )}
          </View>

          <Portal>
            <Modal
              visible={publishDateVisible}
              onDismiss={handleDateCancel}
              contentContainerStyle={[styles.dateModal, { backgroundColor: background }]}>
              <Text variant="titleLarge" style={styles.modalTitle}>
                {i18n.t('BookStore::PublishDate')}
              </Text>
              <Divider style={styles.divider} />
              <DateTimePicker
                testID="publishDatePicker"
                value={bookForm.values.publishDate || new Date()}
                mode="date"
                display={Platform.OS === 'ios' ? 'spinner' : 'default'}
                onChange={onChange}
                maximumDate={new Date()}
                textColor={onBackground}
              />
              <View style={styles.modalButtons}>
                <Button onPress={handleDateCancel} mode="text">
                  {i18n.t('AbpUi::Cancel')}
                </Button>
                <Button onPress={handleDateConfirm} mode="contained">
                  {i18n.t('AbpUi::Ok')}
                </Button>
              </View>
            </Modal>
          </Portal>

          <FormButtons style={styles.button} submit={bookForm.handleSubmit} />
        </ScrollView>
      </KeyboardAvoidingView>
    </View>
  );
}

const styles = StyleSheet.create({
  inputContainer: {
    margin: 8,
    marginLeft: 16,
    marginRight: 16,
  },
  input: {
    borderRadius: 8,
    borderTopLeftRadius: 8,
    borderTopRightRadius: 8,
  },
  button: {
    marginLeft: 16,
    marginRight: 16,
  },
  dateModal: {
    padding: 20,
    margin: 20,
    borderRadius: 12,
    elevation: 5,
    shadowColor: '#000',
    shadowOffset: {
      width: 0,
      height: 2,
    },
    shadowOpacity: 0.25,
    shadowRadius: 3.84,
  },
  modalTitle: {
    textAlign: 'center',
    marginBottom: 16,
    fontWeight: '600',
  },
  divider: {
    marginBottom: 16,
  },
  modalButtons: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    marginTop: 20,
    paddingHorizontal: 8,
  },
});

CreateUpdateBookForm.propTypes = {
  book: PropTypes.object,
  authors: PropTypes.array.isRequired,
  submit: PropTypes.func.isRequired,
};

export default CreateUpdateBookForm;
  • formik 将管理表单状态、验证和值变更。
  • Yup 允许构建验证模式。
  • AbpSelect 组件用于选择书籍类型。
  • submit 方法将表单值传递给 CreateUpdateBookScreen 组件。

创建新书图标

创建新书

更新书籍

我们需要导航参数来获取 bookId,然后在创建和更新操作后再次导航。这就是为什么我们将导航参数传递给 BooksScreen 组件。

//导入..

// 添加导航参数
const BooksRoute = (nav) => <BooksScreen navigation={nav} />;

function BookStoreScreen({ navigation }) {
  //其他代码..

  const renderScene = BottomNavigation.SceneMap({
    books: () => BooksRoute(navigation), // 使用这种方式
  });

  //其他代码..
}

export default BookStoreScreen;

替换 BookScreen.tsx 文件中 ./src/screens/BookStore/Books 文件夹下的代码。

import { useState } from 'react';
import { useSelector } from 'react-redux';
import { Alert, View, StyleSheet } from 'react-native';
import { List, IconButton, AnimatedFAB } from 'react-native-paper';
import { useActionSheet } from '@expo/react-native-action-sheet';
import i18n from 'i18n-js';

import { getList, remove } from '../../../api/BookAPI';
import { DataList } from '../../../components';
import { createAppConfigSelector } from '../../../store/selectors/AppSelectors';
import { useThemeColors } from '../../../hooks';

function BooksScreen({ navigation }) {
  const { background, primary } = useThemeColors();
  const currentUser = useSelector(createAppConfigSelector())?.currentUser;
  const policies = useSelector(createAppConfigSelector())?.auth?.grantedPolicies;

  const [refresh, setRefresh] = useState(null);
  const { showActionSheetWithOptions } = useActionSheet();

  const openContextMenu = (item: { id: string }) => {
    const options = [];

    if (policies['BookStore.Books.Delete']) {
      options.push(i18n.t('AbpUi::Delete'));
    }

    if (policies['BookStore.Books.Edit']) {
      options.push(i18n.t('AbpUi::Edit'));
    }

    options.push(i18n.t('AbpUi::Cancel'));

    showActionSheetWithOptions(
      {
        options,
        cancelButtonIndex: options.length - 1,
        destructiveButtonIndex: options.indexOf(i18n.t('AbpUi::Delete')),
      },
      index => {
        switch (options[index]) {
          case i18n.t('AbpUi::Edit'):
            edit(item);
            break;
          case i18n.t('AbpUi::Delete'):
            removeOnClick(item);
            break;
        }
      },
    );
  };

  const removeOnClick = (item: { id: string }) => {
    Alert.alert('Warning', i18n.t('BookStore::AreYouSureToDelete'), [
      {
        text: i18n.t('AbpUi::Cancel'),
        style: 'cancel',
      },
      {
        style: 'default',
        text: i18n.t('AbpUi::Ok'),
        onPress: () => {
          remove(item.id).then(() => {
            setRefresh((refresh ?? 0) + 1);
          });
        },
      },
    ]);
  };

  const edit = (item: { id: string }) => {
    navigation.navigate('CreateUpdateBook', { bookId: item.id });
  };

  return (
    <View style={{ flex: 1, backgroundColor: background }}>
      {currentUser?.isAuthenticated && (
        <DataList
          navigation={navigation}
          fetchFn={getList}
          trigger={refresh}
          render={({ item }) => (
            <List.Item
              key={item.id}
              title={item.name}
              description={`${item.authorName} | ${i18n.t(
                'BookStore::Enum:BookType.' + item.type,
              )}`}
              right={props => (
                <IconButton
                  {...props}
                  icon="dots-vertical"
                  rippleColor={'#ccc'}
                  size={20}
                  onPress={() => openContextMenu(item)}
                />
              )}
            />
          )}
        />
      )}

      {currentUser?.isAuthenticated && !!policies['BookStore.Books.Create'] && (
        <AnimatedFAB
          icon={'plus'}
          label={i18n.t('BookStore::NewBook')}
          color="white"
          extended={false}
          onPress={() => navigation.navigate('CreateUpdateBook')}
          visible={true}
          animateFrom={'right'}
          iconMode={'static'}
          style={[styles.fabStyle, { backgroundColor: primary }]}
        />
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flexGrow: 1,
  },
  fabStyle: {
    bottom: 16,
    right: 16,
    position: 'absolute',
  },
});

export default BooksScreen;

替换 CreateUpdateBookScreen.tsx 文件中 ./src/screens/BookStore/Books/CreateUpdateBook/ 文件夹下的代码。

import PropTypes from 'prop-types';
import { useEffect, useState } from 'react';

import { getAuthorLookup, get, create, update } from '../../../../api/BookAPI';
import LoadingActions from '../../../../store/actions/LoadingActions';
import { createLoadingSelector } from '../../../../store/selectors/LoadingSelectors';
import { connectToRedux } from '../../../../utils/ReduxConnect';
import CreateUpdateBookForm from './CreateUpdateBookForm';

function CreateUpdateBookScreen({ navigation, route, startLoading, clearLoading }) {
  const { bookId } = route.params || {};
  const [book, setBook] = useState(null);

  const submit = (data: any) => {
    startLoading({ key: 'save' });

    (data.id ? update(data, data.id) : create(data))
      .then(() => navigation.goBack())
      .finally(() => clearLoading());
  };

  useEffect(() => {
    if (bookId) {
      startLoading({ key: 'fetchBookDetail' });

      get(bookId)
        .then((response: any) => setBook(response))
        .finally(() => clearLoading());
    }
  }, [bookId]);

  return <CreateUpdateBookForm submit={submit} book={book} />;
}

CreateUpdateBookScreen.propTypes = {
  startLoading: PropTypes.func.isRequired,
  clearLoading: PropTypes.func.isRequired,
};

export default connectToRedux({
  component: CreateUpdateBookScreen,
  stateProps: state => ({ loading: createLoadingSelector()(state) }),
  dispatchProps: {
    startLoading: LoadingActions.start,
    clearLoading: LoadingActions.clear,
  },
});
  • get 方法用于从服务器获取书籍详情。
  • update 方法用于更新服务器上的书籍。
  • route 参数将用于从导航中获取 bookId。

CreateUpdateBookForm.tsx 文件替换为下面的代码。我们将使用此文件进行创建和更新操作。

//导入..

//验证模式

//props

function CreateUpdateBookForm({
  submit,
  book = null, // 添加带有默认值的 book 参数
}) {
  //其他代码..

  const bookForm = useFormik({
    enableReinitialize: true,
    validateOnBlur: true,
    validationSchema: Yup.object().shape({
      ...validations,
    }),
    initialValues: {
      // 更新初始值
      ...book,
      name: book?.name || "",
      price: book?.price.toString() || "",
      type: book?.type || "",
      typeDisplayName:
        book?.type && i18n.t("BookStore::Enum:BookType." + book.type),
      publishDate: (book?.publishDate && new Date(book?.publishDate)) || null,
      // 更新初始值
    },
    onSubmit,
  });

  //其他代码..
}

//其他代码..
  • book 是一个可空的属性。如果 book 参数为 null,我们将创建一个新书。

带选项的书籍列表

更新书籍页面

删除书籍

替换 BooksScreen.tsx 文件中 ./src/screens/BookStore/Books 文件夹下的代码。

import { useState } from 'react';
import { useSelector } from 'react-redux';
import { Alert, View, StyleSheet } from 'react-native';
import { List, IconButton, AnimatedFAB } from 'react-native-paper';
import { useActionSheet } from '@expo/react-native-action-sheet';
import i18n from 'i18n-js';

import { getList, remove } from '../../../api/BookAPI';
import { DataList } from '../../../components';
import { createAppConfigSelector } from '../../../store/selectors/AppSelectors';
import { useThemeColors } from '../../../hooks';

function BooksScreen({ navigation }) {
  const { background, primary } = useThemeColors();
  const currentUser = useSelector(createAppConfigSelector())?.currentUser;
  const policies = useSelector(createAppConfigSelector())?.auth?.grantedPolicies;

  const [refresh, setRefresh] = useState(null);
  const { showActionSheetWithOptions } = useActionSheet();

  const openContextMenu = (item: { id: string }) => {
    const options = [];

    if (policies['BookStore.Books.Delete']) {
      options.push(i18n.t('AbpUi::Delete'));
    }

    if (policies['BookStore.Books.Edit']) {
      options.push(i18n.t('AbpUi::Edit'));
    }

    options.push(i18n.t('AbpUi::Cancel'));

    showActionSheetWithOptions(
      {
        options,
        cancelButtonIndex: options.length - 1,
        destructiveButtonIndex: options.indexOf(i18n.t('AbpUi::Delete')),
      },
      index => {
        switch (options[index]) {
          case i18n.t('AbpUi::Edit'):
            edit(item);
            break;
          case i18n.t('AbpUi::Delete'):
            removeOnClick(item);
            break;
        }
      },
    );
  };

  const removeOnClick = (item: { id: string }) => {
    Alert.alert('Warning', i18n.t('BookStore::AreYouSureToDelete'), [
      {
        text: i18n.t('AbpUi::Cancel'),
        style: 'cancel',
      },
      {
        style: 'default',
        text: i18n.t('AbpUi::Ok'),
        onPress: () => {
          remove(item.id).then(() => {
            setRefresh((refresh ?? 0) + 1);
          });
        },
      },
    ]);
  };

  const edit = (item: { id: string }) => {
    navigation.navigate('CreateUpdateBook', { bookId: item.id });
  };

  return (
    <View style={{ flex: 1, backgroundColor: background }}>
      {currentUser?.isAuthenticated && (
        <DataList
          navigation={navigation}
          fetchFn={getList}
          trigger={refresh}
          render={({ item }) => (
            <List.Item
              key={item.id}
              title={item.name}
              description={`${item.authorName} | ${i18n.t(
                'BookStore::Enum:BookType.' + item.type,
              )}`}
              right={props => (
                <IconButton
                  {...props}
                  icon="dots-vertical"
                  rippleColor={'#ccc'}
                  size={20}
                  onPress={() => openContextMenu(item)}
                />
              )}
            />
          )}
        />
      )}

      {currentUser?.isAuthenticated && !!policies['BookStore.Books.Create'] && (
        <AnimatedFAB
          icon={'plus'}
          label={i18n.t('BookStore::NewBook')}
          color="white"
          extended={false}
          onPress={() => navigation.navigate('CreateUpdateBook')}
          visible={true}
          animateFrom={'right'}
          iconMode={'static'}
          style={[styles.fabStyle, { backgroundColor: primary }]}
        />
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flexGrow: 1,
  },
  fabStyle: {
    bottom: 16,
    right: 16,
    position: 'absolute',
  },
});

export default BooksScreen;
  • 删除 选项已添加到上下文菜单列表中
  • removeOnClick 方法将处理删除过程。在删除操作之前会显示一个警告框。

删除书籍

删除书籍警告

授权

在标签页中隐藏 Books 项

appConfig 存储中添加 grantedPolicies 到 policies 变量

//其他导入..
import { useSelector } from "react-redux";

function BookStoreScreen({ navigation }) {
  const [index, setIndex] = React.useState(0);
  const [routes, setRoutes] = React.useState([]);

  const currentUser = useSelector((state) => state.app.appConfig.currentUser);
  const policies = useSelector(
    (state) => state.app.appConfig.auth.grantedPolicies
  );

  const renderScene = BottomNavigation.SceneMap({
    books: () => BooksRoute(navigation),
  });

  React.useEffect(() => {
    if (!currentUser?.isAuthenticated || !policies) {
      setRoutes([]);
      return;
    }

    let _routes = [];

    if (!!policies["BookStore.Books"]) {
      _routes.push({
        key: "books",
        title: i18n.t("BookStore::Menu:Books"),
        focusedIcon: "book",
        unfocusedIcon: "book-outline",
      });
    }

    setRoutes([..._routes]);
  }, [Object.keys(policies)?.filter((f) => f.startsWith("BookStore")).length]);

  return (
    routes?.length > 0 && (
      <BottomNavigation
        navigationState={{ index, routes }}
        onIndexChange={setIndex}
        renderScene={renderScene}
      />
    )
  );
}

export default BookStoreScreen;
  • useEffect 函数中,我们将检查 currentUserpolicies 变量。
  • useEffect 的条件将是 BookStore 权限组的策略。
  • 如果用户拥有 BookStore.Books 权限,则 Books 标签页将被显示

Books 菜单项

隐藏新建书籍按钮

新建书籍 按钮在 BooksScreen 中作为 + 图标按钮放置。要切换此按钮的可见性,我们需要像 BookStoreScreen 组件一样,将 policies 变量添加到 BooksScreen 组件中。打开 ./src/screens/BookStore/Books 文件夹中的 BooksScreen.tsx 文件,并包含以下代码。

//导入..

function BooksScreen({ navigation }) {
  const policies = useSelector(createAppConfigSelector())?.auth?.grantedPolicies;

  //其他代码..

  return (
    {/*其他代码..*/}

    {currentUser?.isAuthenticated &&
      !!policies['BookStore.Books.Create'] && // 添加此行
      (
        <AnimatedFAB
          icon={'plus'}
          label={i18n.t('BookStore::NewBook')}
          color="white"
          extended={false}
          onPress={() => navigation.navigate('CreateUpdateBook')}
          visible={true}
          animateFrom={'right'}
          iconMode={'static'}
          style={[styles.fabStyle, { backgroundColor: primary }]}
        />
      )
    }
  )
}
  • 现在,如果用户拥有 BookStore.Books.Create 权限,则 + 图标按钮将被显示。

新建书籍按钮权限

隐藏编辑和删除操作

./src/screens/BookStore/Books/BooksScreen.tsx 文件中按如下更新您的代码。我们将为 编辑删除 操作检查 policies 变量。

function BooksScreen() {
  //...

  const openContextMenu = (item) => {
    const options = [];

    if (policies["BookStore.Books.Delete"]) {
      options.push(i18n.t("AbpUi::Delete"));
    }

    if (policies["BookStore.Books.Update"]) {
      options.push(i18n.t("AbpUi::Edit"));
    }

    options.push(i18n.t("AbpUi::Cancel"));
  };

  //...
}

新建书籍按钮权限

作者

创建 API 代理

//./src/api/AuthorAPI.ts

import api from './API';

export const getList = () => api.get('/api/app/author').then(({ data }) => data);

export const get = id => api.get(`/api/app/author/${id}`).then(({ data }) => data);

export const create = input => api.post('/api/app/author', input).then(({ data }) => data);

export const update = (input, id) => api.put(`/api/app/author/${id}`, input).then(({ data }) => data);

export const remove = id => api.delete(`/api/app/author/${id}`).then(({ data }) => data);

作者列表页面

将 Authors 标签页添加到 BookStoreScreen

打开 ./src/screens/BookStore/BookStoreScreen.tsx 文件并用下面的代码更新它。

//其他导入
import AuthorsScreen from "./Authors/AuthorsScreen";

//其他路由..
const AuthorsRoute = (nav) => <AuthorsScreen navigation={nav} />;

function BookStoreScreen({ navigation }) {
  //其他代码..

  const renderScene = BottomNavigation.SceneMap({
    books: () => BooksRoute(navigation),
    authors: () => AuthorsRoute(navigation), // 添加此行
  });

  // 添加此部分
  if (!!policies["BookStore.Authors"]) {
    _routes.push({
      key: "authors",
      title: i18n.t("BookStore::Menu:Authors"),
      focusedIcon: "account-supervisor",
      unfocusedIcon: "account-supervisor-outline",
    });
  }
  // 添加此部分
}

export default BookStoreScreen;

./src/screens/BookStore/Authors 文件夹下创建一个 AuthorsScreen.tsx 文件,并向其中添加以下代码。

import { useState } from 'react';
import { useSelector } from 'react-redux';
import { Alert, View, StyleSheet } from 'react-native';
import { List, IconButton, AnimatedFAB } from 'react-native-paper';
import { useActionSheet } from '@expo/react-native-action-sheet';
import i18n from 'i18n-js';

import { getList, remove } from '../../../api/AuthorAPI';
import { DataList } from '../../../components';
import { createAppConfigSelector } from '../../../store/selectors/AppSelectors';
import { useThemeColors } from '../../../hooks';

function AuthorsScreen({ navigation }) {
  const { background, primary } = useThemeColors();
  const currentUser = useSelector(createAppConfigSelector())?.currentUser;
  const policies = useSelector(createAppConfigSelector())?.auth?.grantedPolicies;

  const [refresh, setRefresh] = useState(null);
  const { showActionSheetWithOptions } = useActionSheet();

  const openContextMenu = (item: { id: string }) => {
    const options = [];

    if (policies['BookStore.Authors.Delete']) {
      options.push(i18n.t('AbpUi::Delete'));
    }

    if (policies['BookStore.Authors.Edit']) {
      options.push(i18n.t('AbpUi::Edit'));
    }

    options.push(i18n.t('AbpUi::Cancel'));

    showActionSheetWithOptions(
      {
        options,
        cancelButtonIndex: options.length - 1,
        destructiveButtonIndex: options.indexOf(i18n.t('AbpUi::Delete')),
      },
      (index: number) => {
        switch (options[index]) {
          case i18n.t('AbpUi::Edit'):
            edit(item);
            break;
          case i18n.t('AbpUi::Delete'):
            removeOnClick(item);
            break;
        }
      },
    );
  };

  const removeOnClick = ({ id }: { id: string }) => {
    Alert.alert('Warning', i18n.t('BookStore::AreYouSureToDelete'), [
      {
        text: i18n.t('AbpUi::Cancel'),
        style: 'cancel',
      },
      {
        style: 'default',
        text: i18n.t('AbpUi::Ok'),
        onPress: () => {
          remove(id).then(() => {
            setRefresh((refresh ?? 0) + 1);
          });
        },
      },
    ]);
  };

  const edit = ({ id }: { id: string }) => {
    navigation.navigate('CreateUpdateAuthor', { authorId: id });
  };

  return (
    <View style={{ flex: 1, backgroundColor: background }}>
      {currentUser?.isAuthenticated && (
        <DataList
          navigation={navigation}
          fetchFn={getList}
          trigger={refresh}
          render={({ item }) => (
            <List.Item
              key={item.id}
              title={item.name}
              description={item.shortBio || new Date(item.birthDate)?.toLocaleDateString()}
              right={(props: any) => (
                <IconButton
                  {...props}
                  icon="dots-vertical"
                  rippleColor={'#ccc'}
                  size={20}
                  onPress={() => openContextMenu(item)}
                />
              )}
            />
          )}
        />
      )}

      {currentUser?.isAuthenticated && policies['BookStore.Authors.Create'] && (
        <AnimatedFAB
          icon={'plus'}
          label={i18n.t('BookStore::NewAuthor')}
          color="white"
          extended={false}
          onPress={() => navigation.navigate('CreateUpdateAuthor')}
          visible={true}
          animateFrom={'right'}
          iconMode={'static'}
          style={[styles.fabStyle, { backgroundColor: primary }]}
        />
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flexGrow: 1,
  },
  fabStyle: {
    bottom: 16,
    right: 16,
    position: 'absolute',
  },
});

export default AuthorsScreen;

./src/screens/BookStore/Authors/CreateUpdateAuthor 文件夹下创建一个 CreateUpdateAuthorScreen.tsx 文件,并向其中添加以下代码。

import PropTypes from 'prop-types';
import { useEffect, useState } from 'react';

import { get, create, update } from '../../../../api/AuthorAPI';
import LoadingActions from '../../../../store/actions/LoadingActions';
import { createLoadingSelector } from '../../../../store/selectors/LoadingSelectors';
import { connectToRedux } from '../../../../utils/ReduxConnect';
import CreateUpdateAuthorForm from './CreateUpdateAuthorForm';  

function CreateUpdateAuthorScreen({ navigation, route, startLoading, clearLoading }) {
  const { authorId } = route.params || {};
  const [ author, setAuthor ] = useState(null);

  const submit = (data: any) => {
    startLoading({ key: 'save' });

    (data.id ? update(data, data.id) : create(data))
      .then(() => navigation.goBack())
      .finally(() => clearLoading());
  };

  useEffect(() => {
    if (authorId) {
      startLoading({ key: 'fetchAuthorDetail' });

      get(authorId)
        .then((response: any) => setAuthor(response))
        .finally(() => clearLoading());
    }
  }, [authorId]);

  return <CreateUpdateAuthorForm submit={submit} author={author} />;
}

CreateUpdateAuthorScreen.propTypes = {
  startLoading: PropTypes.func.isRequired,
  clearLoading: PropTypes.func.isRequired,
};

export default connectToRedux({
  component: CreateUpdateAuthorScreen,
  stateProps: (state: any) => ({ loading: createLoadingSelector()(state) }),
  dispatchProps: {
    startLoading: LoadingActions.start,
    clearLoading: LoadingActions.clear,
  },
});

./src/screens/BookStore/Authors/CreateUpdateAuthor 文件夹下创建一个 CreateUpdateAuthorForm.tsx 文件,并向其中添加以下代码。

import { useRef, useState } from 'react';
import { Platform, KeyboardAvoidingView, StyleSheet, View, ScrollView } from 'react-native';

import { useFormik } from 'formik';
import i18n from 'i18n-js';
import PropTypes from 'prop-types';
import * as Yup from 'yup';
import { Divider, Portal, TextInput, Text, Button, Modal } from 'react-native-paper';
import DateTimePicker from '@react-native-community/datetimepicker';

import { useThemeColors } from '../../../../hooks';
import { FormButtons, ValidationMessage } from '../../../../components';  

const validations = {
  name: Yup.string().required('AbpValidation::ThisFieldIsRequired.'),
  birthDate: Yup.string().nullable().required('AbpValidation::ThisFieldIsRequired.'),
};

const props = {
  underlineStyle: { backgroundColor: 'transparent' },
  underlineColor: '#333333bf',
};

function CreateUpdateAuthorForm({ submit, author = null }) {
  const { primaryContainer, background, onBackground } = useThemeColors();

  const [birthDateVisible, setPublishDateVisible] = useState(false);

  const nameRef = useRef(null);
  const birthDateRef = useRef(null);
  const shortBioRef = useRef(null);

  const inputStyle = { ...styles.input, backgroundColor: primaryContainer };

  const onSubmit = (values: any) => {
    if (!authorForm.isValid) {
      return;
    }

    submit({ ...values });
  };

  const authorForm = useFormik({
    enableReinitialize: true,
    validateOnBlur: true,
    validationSchema: Yup.object().shape({
      ...validations,
    }),
    initialValues: {
      ...author,
      name: author?.name || '',
      birthDate: (author?.birthDate && new Date(author?.birthDate)) || null,
      shortBio: author?.shortBio || '',
    },
    onSubmit,
  });

  const isInvalidControl = (controlName = null) => {
    if (!controlName) {
      return;
    }

    return (
      ((!!authorForm.touched[controlName] && authorForm.submitCount > 0) ||
        authorForm.submitCount > 0) &&
      !!authorForm.errors[controlName]
    );
  };

  const onChange = (event: any, selectedDate: any) => {
    if (!selectedDate) {
      return;
    }

    setPublishDateVisible(false);

    if (event && event.type !== 'dismissed') {
      authorForm.setFieldValue('birthDate', selectedDate, true);
    }
  };

  return (
    <View style={{ flex: 1, backgroundColor: background }}>
      {birthDateVisible && (
        <DateTimePicker
          testID="birthDatePicker"
          value={authorForm.values.birthDate || new Date()}
          mode={'date'}
          is24Hour={true}
          onChange={onChange}
        />
      )}

      <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'}>
        <ScrollView keyboardShouldPersistTaps="handled">
          <View style={styles.inputContainer}>
            <TextInput
              mode="flat" 
              ref={nameRef}
              error={isInvalidControl('name')}
              onSubmitEditing={() => birthDateRef.current.focus()}
              returnKeyType="next"
              onChangeText={authorForm.handleChange('name')}
              onBlur={authorForm.handleBlur('name')}
              value={authorForm.values.name}
              autoCapitalize="none"
              label={i18n.t('BookStore::Name')}
              style={inputStyle}
              {...props}
            />
            {isInvalidControl('name') && (
              <ValidationMessage>{authorForm.errors.name as string}</ValidationMessage>
            )}
          </View>

          <View style={styles.inputContainer}>
            <TextInput
              ref={birthDateRef}
              label={i18n.t('BookStore::BirthDate')}
              onSubmitEditing={() => shortBioRef.current.focus()}
              right={
                <TextInput.Icon onPress={() => setPublishDateVisible(true)} icon="calendar" />
              }
              style={inputStyle}
              editable={false}
              value={authorForm.values.birthDate?.toLocaleDateString()}
              {...props}
            />
            {isInvalidControl('birthDate') && (
              <ValidationMessage>{authorForm.errors.birthDate as string}</ValidationMessage>
            )}
          </View>

          <Portal>
            <Modal
              visible={birthDateVisible}
              contentContainerStyle={[styles.dateModal, { backgroundColor: background }]}>
              <Text variant="titleLarge" style={styles.modalTitle}>
                {i18n.t('BookStore::BirthDate')}
              </Text>
              <Divider style={styles.divider} />
              <DateTimePicker
                testID="birthDatePicker"
                value={authorForm.values.birthDate || new Date()}
                mode="date"
                display={Platform.OS === 'ios' ? 'spinner' : 'default'}
                onChange={onChange}
                maximumDate={new Date()}
                textColor={onBackground}
              />
              <View style={styles.modalButtons}>
                <Button onPress={() => setPublishDateVisible(false)} mode="text">
                  {i18n.t('AbpUi::Cancel')}
                </Button>
                <Button onPress={() => setPublishDateVisible(false)} mode="contained">
                  {i18n.t('AbpUi::Ok')}
                </Button>
              </View>
            </Modal>
          </Portal>

          <View style={styles.inputContainer}>
            <TextInput
              mode="flat"
              ref={shortBioRef}
              error={isInvalidControl('shortBio')}
              onSubmitEditing={() => authorForm.handleSubmit()}
              returnKeyType="next"
              onChangeText={authorForm.handleChange('shortBio')}
              onBlur={authorForm.handleBlur('shortBio')}
              value={authorForm.values.shortBio}
              autoCapitalize="none"
              label={i18n.t('BookStore::ShortBio')}
              style={inputStyle}
              {...props}
            />
          </View>

          <FormButtons style={styles.button} submit={authorForm.handleSubmit} />
        </ScrollView>
      </KeyboardAvoidingView>
    </View>
  );
}

const styles = StyleSheet.create({
  inputContainer: {
    margin: 8,
      marginLeft: 16,
      marginRight: 16,
  },
  input: {
    borderRadius: 8,
    borderTopLeftRadius: 8,
    borderTopRightRadius: 8,
  },
  button: {
    marginLeft: 16,
    marginRight: 16,
  },
  divider: {
    marginBottom: 16,
  },
  modalButtons: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    marginTop: 20,
    paddingHorizontal: 8,
  },
  dateModal: {
    padding: 20,
    margin: 20,
    borderRadius: 12,
    elevation: 5,
    shadowColor: '#000',
    shadowOffset: {
      width: 0,
      height: 2,
    },
    shadowOpacity: 0.25,
    shadowRadius: 3.84,
  },
  modalTitle: {
    textAlign: 'center',
    marginBottom: 16,
    fontWeight: '600',
  },
});

CreateUpdateAuthorForm.propTypes = {
  author: PropTypes.object,
  submit: PropTypes.func.isRequired,
};

export default CreateUpdateAuthorForm;

作者列表

作者创建页面

带选项的作者列表

作者更新页面

作者删除警告

作者 关联到书籍

更新 BookAPI 代理文件并包含 getAuthorLookup 方法

import api from "./API";

export const getList = () => api.get("/api/app/book").then(({ data }) => data);

// 添加此方法
export const getAuthorLookup = () =>
  api.get("/api/app/book/author-lookup").then(({ data }) => data);
// 添加此方法

export const get = (id) =>
  api.get(`/api/app/book/${id}`).then(({ data }) => data);

export const create = (input) =>
  api.post("/api/app/book", input).then(({ data }) => data);

export const update = (input, id) =>
  api.put(`/api/app/book/${id}`, input).then(({ data }) => data);

export const remove = (id) =>
  api.delete(`/api/app/book/${id}`).then(({ data }) => data);

作者姓名 添加到书籍列表

打开 BooksScreen.tsx 文件(位于 ./src/screens/BookStore/Books 下)并更新以下代码。

//导入

function BooksScreen({ navigation }) {
  //其他代码..

  return (
    //其他代码
    <DataList
      navigation={navigation}
      fetchFn={getList}
      trigger={refresh}
      render={({ item }) => (
        <List.Item
          key={item.id}
          title={item.name}
          // 在此处更新
          description={`${item.authorName} | ${i18n.t(
            "BookStore::Enum:BookType." + item.type
          )}`}
          // 在此处更新
          right={(props) => (
            <IconButton
              {...props}
              icon="dots-vertical"
              rippleColor={"#ccc"}
              size={20}
              onPress={() => openContextMenu(item)}
            />
          )}
        />
      )}
    />
    //其他代码
  );
}
  • item.authorName 放置在书籍列表中书籍类型的旁边。

将作者列表传递给 CreateUpdateBookForm

import {
  getAuthorLookup, // 添加此行
  get,
  create,
  update,
} from "../../../../api/BookAPI";
import CreateUpdateBookForm from "./CreateUpdateBookForm";

function CreateUpdateBookScreen({
  navigation,
  route,
  startLoading,
  clearLoading,
}) {
  // 添加此变量
  const [authors, setAuthors] = useState([]);

  // 从 author-lookup 端点获取作者
  useEffect(() => {
    getAuthorLookup().then(({ items } = {}) => setAuthors(items));
  }, []);

  // 将作者列表传递给表单
  return <CreateUpdateBookForm submit={submit} book={book} authors={authors} />;
}
//其他代码..
  • 我们将在 CreateUpdateBookForm 组件中定义 authors 属性,它将用于作者下拉列表。
  • 在 useEffect 函数中,我们将从服务器获取作者并设置 authors 变量。

authorId 字段添加到书籍表单

const validations = {
  authorId: Yup.string()
    .nullable()
    .required("AbpValidation::ThisFieldIsRequired."),
  // 其他验证器
};

// 添加 `authors` 参数
function CreateUpdateBookForm({ submit, book = null, authors = [] }) {
  // 为作者列表添加此变量
  const [authorSelectVisible, setAuthorSelectVisible] = useState(false);

  const authorIdRef = useRef(); // 添加此行

  // 更新表单
  const bookForm = useFormik({
    enableReinitialize: true,
    validateOnBlur: true,
    validationSchema: Yup.object().shape({
      ...validations,
    }),
    initialValues: {
      // 添加这些
      authorId: book?.authorId || "",
      author: authors.find((f) => f.id === book?.authorId)?.name || "",
      // 添加这些
    },
    onSubmit,
  });

  // 其他代码..

  // 为作者添加 `AbpSelect` 组件和 TextInput
  return (
    <View style={{ flex: 1, backgroundColor: background }}>
      <AbpSelect
        key="authorSelect"
        title={i18n.t("BookStore::Authors")}
        visible={authorSelectVisible}
        items={authors.map(({ id, name }) => ({ id, displayName: name }))}
        hasDefualtItem={true}
        hideModalFn={() => setAuthorSelectVisible(false)}
        selectedItem={bookForm.values.authorId}
        setSelectedItem={(id) => {
          bookForm.setFieldValue("authorId", id, true);
          bookForm.setFieldValue(
            "author",
            authors.find((f) => f.id === id)?.name || null,
            false
          );
        }}
      />

      <KeyboardAvoidingView
        behavior={Platform.OS === "ios" ? "padding" : "height"}
      >
        <ScrollView keyboardShouldPersistTaps="handled">
          <View style={styles.input.container}>
            <TextInput
              ref={authorIdRef}
              error={isInvalidControl("authorId")}
              label={i18n.t("BookStore::Author")}
              right={
                <TextInput.Icon
                  onPress={() => setAuthorSelectVisible(true)}
                  icon="menu-down"
                />
              }
              style={inputStyle}
              editable={false}
              value={bookForm.values.author}
              {...props}
            />
            {isInvalidControl("authorId") && (
              <ValidationMessage>{bookForm.errors.authorId}</ValidationMessage>
            )}
          </View>
        </ScrollView>
      </KeyboardAvoidingView>
    </View>
  );
}
CreateUpdateBookForm.propTypes = {
  authors: PropTypes.array.isRequired, // 包含此项
};

export default CreateUpdateBookForm;
  • 使用 AbpSelect 组件创建作者下拉输入框。
  • TextInput 中显示选定的作者。

带作者的书籍列表

书籍表单中的作者输入框

书籍表单中的作者列表

以上就是全部内容。只需运行应用程序并尝试创建或编辑作者即可。

在本文档中