Уточнение базы знаний FlutterGo: midway+Typescript+mysql(sequelize)

Node.js Flutter
Уточнение базы знаний FlutterGo: midway+Typescript+mysql(sequelize)

предисловие

оFlutterGoМожет быть, это не нуждается в большом представлении.

Если у вас есть друг, который впервые слышит об этом, вы можете переехатьОфициальный сайт FlutterGoОзнакомьтесь с кратким введением.

В этой итерации у FlutterGo было много обновлений, в этом обновлении автор отвечает за разработку бэкенда и соответствующей клиентской части. Вот краткое введение в реализацию нескольких функциональных модулей в бэкенд-коде FlutterGo.

В целом, бэкэнда мелки не сложна. Эта статья, вероятно, описана со следующей функцией (интерфейс) реализации:

  • Функция входа во FlutterGo
  • Функция сбора компонентов
  • Любимая функция
  • Функция обратной связи с предложениями

Информация об окружающей среде

Облачный сервер Alibaba Cloud ECS

Linux iz2ze3gw3ipdpbha0mstybz 3.10.0-957.21.3.el7.x86_64 #1 SMP Tue Jun 18 16:35:19 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux

MySQL:mysql Ver 8.0.16 for Linux on x86_64 (MySQL Community Server - GPL)

node:v12.5.0

Язык разработки:midway + typescript + mysql

Структура кода:

src
├─ app
│    ├─ class 定义表结构
│    │    ├─ app_config.ts 
│    │    ├─ cat.ts
│    │    ├─ collection.ts
│    │    ├─ user.ts
│    │    ├─ user_collection.ts
│    │    └─ widget.ts
│    ├─ constants 常量
│    │    └─ index.ts
│    ├─ controller 
│    │    ├─ app_config.ts
│    │    ├─ auth.ts
│    │    ├─ auth_collection.ts
│    │    ├─ cat_widget.ts
│    │    ├─ home.ts
│    │    ├─ user.ts
│    │    └─ user_setting.ts
│    ├─ middleware 中间件
│    │    └─ auth_middleware.ts
│    ├─ model
│    │    ├─ app_config.ts
│    │    ├─ cat.ts
│    │    ├─ collection.ts
│    │    ├─ db.ts
│    │    ├─ user.ts
│    │    ├─ user_collection.ts
│    │    └─ widget.ts
│    ├─ public
│    │    └─ README.md
│    ├─ service
│    │    ├─ app_config.ts
│    │    ├─ cat.ts
│    │    ├─ collection.ts
│    │    ├─ user.ts
│    │    ├─ user_collection.ts
│    │    ├─ user_setting.ts
│    │    └─ widget.ts
│    └─ util 工具集
│           └─ index.ts
├─ config 应用的配置信息
│    ├─ config.default.ts
│    ├─ config.local.ts
│    ├─ config.prod.ts
│    └─ plugin.ts
└─ interface.ts

Функция входа

первый вclass/user.tsопределитьuserСтруктура таблицы, примерно обязательные поля и вinterface.tsОбъявите соответствующий интерфейс в . вотmidwayа такжеtsБазовая конфигурация , не будет представлена.

FlutterGo предоставляет два метода входа в систему:

  • Логин и пароль для входа
  • GitHubOAuthСертификация

Потому что это мобильный клиентGitHubOauthАутентификация, так что здесь на самом деле есть некоторые подводные камни, о которых речь пойдет позже. Здесь мы начинаем с простого

Вход по логину/паролю

Поскольку мы используем метод входа в систему с именем пользователя/паролем github, нам нужно указать API github здесь:developer.github.com/v3/auth/,

Основная часть документации:curl -u username https://api.github.com/user(Вы можете проверить это сами на терминале), нажмите Enter и введите пароль. Итак, здесь мы можем полностью аутентифицировать githu после получения имени пользователя и пароля, введенных пользователем.

Основное использование midway здесь повторяться не будет. Весь процесс по-прежнему очень прост и понятен, как показано ниже:

Соответствующая реализация кода (соответствующая информация была десенсибилизирована: xxx):

serviceчасть

    //获取 userModel
    @inject()
    userModel
    
    // 获取 github 配置信息
    @config('githubConfig')
    GITHUB_CONFIG;

    //获取请求上下文
    @inject()
    ctx;
    //githubAuth 认证
    async githubAuth(username: string, password: string, ctx): Promise<any> {
        return await ctx.curl(GITHUB_OAUTH_API, {
            type: 'GET',
            dataType: 'json',
            url: GITHUB_OAUTH_API,
            headers: {
                'Authorization': ctx.session.xxx
            }
        });
    }
    // 查找用户 
    async find(options: IUserOptions): Promise<IUserResult> {
        const result = await this.userModel.findOne(
            {
                attributes: ['xx', 'xx', 'xx', 'xx', 'xx', "xx"],//相关信息脱敏
                where: { username: options.username, password: options.password }
            })
            .then(userModel => {
                if (userModel) {
                    return userModel.get({ plain: true });
                }
                return userModel;
            });
        return result;
    }
    // 通过 URLName 查找用户
    async findByUrlName(urlName: string): Promise<IUserResult> {
        return await this.userModel.findOne(
            {
                attributes: ['xxx', 'xxx', 'xxx', 'xxx', 'xxx', "xxx"],
                where: { url_name: urlName }
            }
        ).then(userModel => {
            if (userModel) {
                return userModel.get({ plain: true });
            }
            return userModel;
        });
    }
    // 创建用户
    async create(options: IUser): Promise<any> {
        const result = await this.userModel.create(options);
        return result;
    }
    
    // 更新用户信息
    async update(id: number, options: IUserOptions): Promise<any> {
        return await this.userModel.update(
            {
                username: options.username,
                password: options.password
            },
            {
                where: { id },
                plain: true
            }
        ).then(([result]) => {
            return result;
        });
    }

controller

    // inject 获取 service 和加密字符串
    @inject('userService')
    service: IUserService

    @config('random_encrypt')
    RANDOM_STR;
流程图中逻辑的代码实现

GitHubOAuth-аутентификация

Здесь есть дыра! Я вернусь и представлю

Аутентификация githubOAuth — это то, что мы часто называем приложением github, здесь я прямо выбрасываю документ:creating-a-github-app

笔者还是觉得文档类的无需介绍

Конечно, я должен был собрать все здесь, а затем записать некоторую базовую информацию в конфигурацию на стороне сервера.

Или следуйте приведенной выше процедуре, давайте сначала представим процесс. Затем речь идет о том, где находится дыра.

Клиентская часть

Код клиентской части достаточно прост, открываем новый webView и сразу переходим кgithub.com/login/oauth/authorizeприноситьclient_idВот и все.

серверная часть

Общий процесс аналогичен предыдущему, показана часть кода:

service

    //获取 github access_token
    async getOAuthToken(code: string): Promise<any> {
        return await this.ctx.curl(GITHUB_TOKEN_URL, {
            type: "POST",
            dataType: "json",
            data: {
                code,
                client_id: this.GITHUB_CONFIG.client_id,
                client_secret: this.GITHUB_CONFIG.client_secret
            }
        });
    }

controllerЛогика кода заключается в вызове данных в службе для получения информации из приведенной выше блок-схемы.

Подводные камни в OAuth

Фактически, метод аутентификации приложения github очень подходит для среды браузера, но в флаттере, потому что мы являемся недавно открытым веб-представлением для запроса адреса входа в github. Когда наш бэкэнд успешно возвращается, мы не можем уведомить слой Flutter. В результате код, написанный dart в моем родном Flutter, не может получить отдачу интерфейса.

В середине мозга есть много решений, и, наконец, я смотрю вверхflutter_webview_pluginХороший метод находится в API:onUrlChanged

Короче говоря, клиентская часть Flutter открывает новый webView для запросаgithub.com/login,github.com/loginэкзаменclient_idПосле этого он придет к серверной части с грязными вещами, такими как код.После успешной проверки серверной части,redirectНовый веб-представление Flutter, затемflutter_webview_pluginДля прослушивания изменений в URL-адресе страницы. Отправьте соответствующие события, чтобы позволить Flutter уничтожить текущий веб-представление и обработать оставшуюся логику.

Частичный код FLUTTER

//定义相关 OAuth event
class UserGithubOAuthEvent{
  final String loginName;
  final String token;
  final bool isSuccess;
  UserGithubOAuthEvent(this.loginName,this.token,this.isSuccess);
}

webView page:

    //在 initState 中监听 url 变化,并emit event
    flutterWebviewPlugin.onUrlChanged.listen((String url) {
      if (url.indexOf('loginSuccess') > -1) {
        String urlQuery = url.substring(url.indexOf('?') + 1);
        String loginName, token;
        List<String> queryList = urlQuery.split('&');
        for (int i = 0; i < queryList.length; i++) {
          String queryNote = queryList[i];
          int eqIndex = queryNote.indexOf('=');
          if (queryNote.substring(0, eqIndex) == 'loginName') {
            loginName = queryNote.substring(eqIndex + 1);
          }
          if (queryNote.substring(0, eqIndex) == 'accessToken') {
            token = queryNote.substring(eqIndex + 1);
          }
        }
        if (ApplicationEvent.event != null) {
          ApplicationEvent.event
              .fire(UserGithubOAuthEvent(loginName, token, true));
        }
        print('ready close');

        flutterWebviewPlugin.close();
        // 验证成功
      } else if (url.indexOf('${Api.BASE_URL}loginFail') == 0) {
        // 验证失败
        if (ApplicationEvent.event != null) {
          ApplicationEvent.event.fire(UserGithubOAuthEvent('', '', true));
        }
        flutterWebviewPlugin.close();
      }
    });

login page:

    //event 的监听、页面跳转以及提醒信息的处理
    ApplicationEvent.event.on<UserGithubOAuthEvent>().listen((event) {
      if (event.isSuccess == true) {
        //  oAuth 认证成功
        if (this.mounted) {
          setState(() {
            isLoading = true;
          });
        }
        DataUtils.getUserInfo(
                {'loginName': event.loginName, 'token': event.token})
            .then((result) {
          setState(() {
            isLoading = false;
          });
          Navigator.of(context).pushAndRemoveUntil(
              MaterialPageRoute(builder: (context) => AppPage(result)),
              (route) => route == null);
        }).catchError((onError) {
          print('获取身份信息 error:::$onError');
          setState(() {
            isLoading = false;
          });
        });
      } else {
        Fluttertoast.showToast(
            msg: '验证失败',
            toastLength: Toast.LENGTH_SHORT,
            gravity: ToastGravity.CENTER,
            timeInSecForIos: 1,
            backgroundColor: Theme.of(context).primaryColor,
            textColor: Colors.white,
            fontSize: 16.0);
      }
    });

Получение дерева компонентов

Структура таблицы

Прежде чем говорить о реализации интерфейса, давайте сначала разберемся, как выглядит наш дизайн табличного механизма в отношении компонентов.

На вкладке виджетов FlutterGO есть много категорий.Щелкните категорию, чтобы войти в категорию, затем щелкните, чтобы перейти к компоненту, и щелкните компонент, чтобы перейти на страницу сведений.

上图模块点进去就是组件 widget

上图是 widget,点进去是详情页

Итак, здесь нам нужны две таблицы для записи их отношений: cat (категория) и таблица виджетов.

В таблице cat у нас будет по одному на строку данныхparent_idполе, поэтому в таблице есть отношение родитель-потомок, иwidgetдля каждой строки данных в таблицеparent_idЗначение поля должно бытьcatПоследний слой в таблице. НапримерCheckbox widgetизparent_idЗначениеcatстолButtonя бы.

Реализация спроса

При входе в систему мы надеемся получить все деревья компонентов.На стороне спроса требуется следующая структура:

[
   {
    "name": "Element",
      "type": "root",
      "child": [
        {
          "name": "Form",
            "type": "group",
            "child": [
              {
                "name": "input",
                  "type": "page",
                  "display": "old",
                  "extends": {},
                  "router": "/components/Tab/Tab"
               },
               {
                "name": "input",
                  "type": "page",
                  "display": "standard",
                  "extends": {},
                  "pageId": "page1_hanxu_172ba42f_0520_401e_b568_ba7f7f6835e4"
               }
            ]
         }
      ],
   }
]

Поскольку существуют трехсторонние компоненты совместной разработки, и наша страница сведений была значительно изменена по сравнению с версией FlutterGo 1.0, теперь есть только одна страница сведений о компонентах, и весь контент отображается с помощью md, а демонстрационная реализация компонента написано в мд. Итак, чтобы быть совместимым со старыми версиями виджетов, у нас естьdisplayразличать старые и новыеwidgetсоответственно черезpageIdа такжеrouterчтобы перейти на страницу.

Идентификатор страницы нового виджета проходит через скаффолдинг FlutterGo.goCliСгенерировано

Текущая реализация фактически возвращает:

{
    "success": true,
    "data": [
        {
            "id": "3",
            "name": "Element",
            "parentId": 0,
            "type": "root",
            "children": [
                {
                    "id": "6",
                    "name": "Form",
                    "parentId": 3,
                    "type": "category",
                    "children": [
                        {
                            "id": "9",
                            "name": "Input",
                            "parentId": 6,
                            "type": "category",
                            "children": [
                                {
                                    "id": "2",
                                    "name": "TextField",
                                    "parentId": "9",
                                    "type": "widget",
                                    "display": "old",
                                    "path": "/Element/Form/Input/TextField"
                                }
                            ]
                        },
                        {
                            "id": "12",
                            "name": "Text",
                            "parentId": 6,
                            "type": "category",
                            "children": [
                                {
                                    "id": "3",
                                    "name": "Text",
                                    "parentId": "12",
                                    "type": "widget",
                                    "display": "old",
                                    "path": "/Element/Form/Text/Text"
                                },
                                {
                                    "id": "4",
                                    "name": "RichText",
                                    "parentId": "12",
                                    "type": "widget",
                                    "display": "old",
                                    "path": "/Element/Form/Text/RichText"
                                }
                            ]
                        },
                        {
                            "id": "13",
                            "name": "Radio",
                            "parentId": 6,
                            "type": "category",
                            "children": [
                                {
                                    "id": "5",
                                    "name": "TestNealya",
                                    "parentId": "13",
                                    "type": "widget",
                                    "display": "standard",
                                    "pageId": "page1_hanxu_172ba42f_0520_401e_b568_ba7f7f6835e4"
                                }
                            ]
                        }
                    ]
                }
            ]
        }
        {
            "id": "5",
            "name": "Themes",
            "parentId": 0,
            "type": "root",
            "children": []
        }
    ]
}

Простой пример, сохранить 99% данных

Код

На самом деле этот интерфейс тоже очень простой, это двухцикловый обход, если быть точным, немного похожий на обход в глубину. Просто посмотрите на код

Получить все категории с одним и тем же parentId (далее — cat)

async getAllNodeByParentIds(parentId?: number) {
    if (!!!parentId) {
        parentId = 0;
    }

    return await this.catService.getCategoryByPId(parentId);
}

Преобразовать первую букву в нижний регистр

firstLowerCase(str){
    return str[0].toLowerCase()+str.slice(1);
}

Нам нужно только внешне поддерживать дерево компонентов, а затемcatкаждый читается в таблицеparent_idявляется узлом. токidничего большеcatсоответствующийparent_idЭто означает, что его следующий уровень - "лист"widget, так что изwidgetможно поинтересоваться. легко~

    //删除部分不用代码
   @get('/xxx')
    async getCateList(ctx) {
        const resultList: IReturnCateNode[] = [];
        let buidList = async (parentId: number, containerList: Partial<IReturnCateNode>[] | Partial<IReturnWidgetNode>[], path: string) => {
            let list: IReturnCateNode[] = await this.getAllNodeByParentIds(parentId);
            if (list.length > 0) {
                for (let i = 0; i < list.length; i++) {
                    let catNode: IReturnCateNode;
                    catNode = {
                        xxx:xxx
                    }
                    containerList.push(catNode);
                    await buidList(list[i].id, containerList[i].children, `${path}/${this.firstLowerCase(containerList[i].name)}`);
                }
            } else {
                // 没有 cat 表下 children,判断是否存在 widget
                const widgetResult = await this.widgetService.getWidgetByPId(parentId);
                if (widgetResult.length > 0) {
                    widgetResult.map((instance) => {
                        let tempWidgetNode: Partial<IReturnWidgetNode> = {};
                        tempWidgetNode.xxx = instance.xxx;
                        if (instance.display === 'old') {
                            tempWidgetNode.path = `${path}/${this.firstLowerCase(instance.name)}`;
                        } else {
                            tempWidgetNode.pageId = instance.pageId;
                        }
                        containerList.push(tempWidgetNode);
                    });
                } else {
                    return null;
                }

            }
        }
        await buidList(0, resultList, '');
        ctx.body = { success: true, data: resultList, status: 200 };
    }

пасхальные яйца

Во FlutterGo есть функция поиска компонентов, потому что мы хранимwidget, приносить не обязательноwidgetмаршрут, что также неразумно (для старых комплектующих), поэтому вwidgetНайдите таблицу, а также обратный поиск, чтобы получить «старый», как описано выше.widgetизrouterполе

Моя личная реализация кода выглядит примерно так:

    @get('/xxx')
    async searchWidget(ctx){
        let {name} = ctx.query;
        name = name.trim();
        if(name){
            let resultWidgetList = await this.widgetService.searchWidgetByStr(name);
            if(xxx){
                for(xxx){
                    if(xxx){
                        let flag = true;
                        xxx
                        while(xxx){
                            let catResult = xxx;
                            if(xxx){
                               xxx
                                if(xxx){
                                    flag = false;
                                }
                            }else{
                                flag = false;
                            }
                        }
                        resultWidgetList[i].path = path;
                    }
                }
                ctx.body={success:true,data:resultWidgetList,message:'查询成功'};
            }else{
                ctx.body={success:true,data:[],message:'查询成功'};
            }
        }else{
            ctx.body={success:false,data:[],message:'查询字段不能为空'};
        }
        
    }

Просите у великого Бога самую простую реализацию~🤓

Любимая функция

Функция сбора должна быть связана с пользователем. Тогда как избранные компоненты должны быть связаны с пользователем? Компоненты и пользователи多对多Отношение.

Вот создаю новыйcollectionтаблица, используемая для всех избранных компонентов. Почему бы просто не использоватьwidgetЧто касается таблицы, то лично мне не хочется, чтобы таблица была слишком сложной, со слишком большим количеством бесполезных полей и ни одной функции.

Так как это любимый компонент и пользовательмногие ко многимотношения, поэтому здесь нам нужна промежуточная таблицаuser_collectionЧтобы сохранить отношения между двумя, отношения между тремя следующие:

Идеи реализации функций

  • Проверить избранное

    • отcollectionПроверьте информацию о компоненте, переданную пользователем в таблице.collectionидентификатор в таблице
    • отsessionполучить идентификатор пользователя
    • использоватьcollection_idа такжеuser_idизвлекатьuser_collectionЕсть ли это поле в таблице
  • Добавить в избранное

    • Получить информацию о компоненте от пользователя
    • findOrCrateпоискcollectionтаблицу и возвращаетcollection_id
    • потомuser_idа такжеcollection_idдепозит вuser_collectionВ таблице (принцип взаимного недоверия, проверить наличие)
  • удалить избранное

    • Шаги такие же, как указано выше, получитьcollectionв таблицеcollection_id
    • удалятьuser_collectionсоответствующее поле
  • Получить все избранное

    • забратьcollectionВсе в таблицеuser_idдля текущего пользователяcollection_id
    • полученоcollection_ids, чтобы получить список любимых компонентов

Часть реализации кода

В целом, идея все еще очень ясна. Итак, здесь мы используем только сбор и проверку, чтобы показать следующую часть кода:

serviceРеализация кода слоя

    @inject()
    userCollectionModel;
        async add(params: IuserCollection): Promise<IuserCollection> {
        return await this.userCollectionModel.findOrCreate({
            where: {
                user_id: params.user_id, collection_id: params.collection_id
            }
        }).then(([model, created]) => {
            return model.get({ plain: true })
        })
    }

    async checkCollected(params: IuserCollection): Promise<boolean> {
        return await this.userCollectionModel.findAll({
            where: { user_id: params.user_id, collection_id: params.collection_id }
        }).then(instanceList => instanceList.length > 0);
    }

controllerРеализация кода слоя

    @inject('collectionService')
    collectionService: ICollectionService;

    @inject()
    userCollectionService: IuserCollectionService

    @inject()
    ctx;
    
    // 校验组件是否收藏
    @post('/xxx')
    async checkCollected(ctx) {
        if (ctx.session.userInfo) {
            // 已登录
            const collectionId = await this.getCollectionId(ctx.request.body);
            const userCollection: IuserCollection = {
                user_id: this.ctx.session.userInfo.id,
                collection_id: collectionId
            }
            const hasCollected = await this.userCollectionService.checkCollected(userCollection);
            ctx.body={status:200,success:true,hasCollected};

        } else {
            ctx.body={status:200,success:true,hasCollected:false};
        }
    }
    
    async addCollection(requestBody): Promise<IuserCollection> {

        const collectionId = await this.getCollectionId(requestBody);

        const userCollection: IuserCollection = {
            user_id: this.ctx.session.userInfo.id,
            collection_id: collectionId
        }

        return await this.userCollectionService.add(userCollection);
    }

потому что частоcollectionв таблицеcollection_idполе, поэтому оно извлекается здесь как общедоступный метод

    async getCollectionId(requestBody): Promise<number> {
        const { url, type, name } = requestBody;
        const collectionOptions: ICollectionOptions = {
            url, type, name
        };
        const collectionResult: ICollection = await this.collectionService.findOrCreate(collectionOptions);
        return collectionResult.id;
    }

функция обратной связи

Функция обратной связи заключается в том, чтобы напрямую отправить вопрос в личные настройки FLUTTERGO.Alibaba/flutter-goВниз. Это в основном для вызова api интерфейса github для решения проблем.issues API.

Реализация внутреннего кода очень проста, просто получите данные и вызовите API github.

serviceПол

    @inject()
    ctx;

    async feedback(title: string, body: string): Promise<any> {
        return await this.ctx.curl(GIHTUB_ADD_ISSUE, {
            type: "POST",
            dataType: "json",
            headers: {
                'Authorization': this.ctx.session.headerAuth,
            },
            data: JSON.stringify({
                title,
                body,
            })
        });
    }

controllerПол

    @inject('userSettingService')
    settingService: IUserSettingService;

    @inject()
    ctx;

    async feedback(title: string, body: string): Promise<any> {
        return await this.settingService.feedback(title, body);
    }

пасхальные яйца

Угадайте, какой компонент используется для этой обратной связи во FlutterGo ~ вот введение

pubspec.yaml

  zefyr:
    path: ./zefyr

Потому что во время разработки флаттер был обновлен, в результате чегоzefyrВыполнение ошибки. В то время также поднимался вопрос:chould not Launch FIle(увидела ответ только когда писала этот пост)

Но на тот момент, в связи с выходом разработки функции, я долго ждал без нее.zefyrОтвет автора. Эта ошибка исправлена ​​локально, и пакет напрямую импортирован в локальный пакет.

План совместного строительства

Кашель, постучи по доске~~

Flutter по-прежнему постоянно обновляется, но нам по-прежнему очень сложно поддерживать FlutterGo вне работы. Итак, мы искренне приглашаем всех энтузиастов Flutter в отрасли принять участие в совместном строительстве FlutterGo!

Еще раз спасибо всем здесьДрузья, отправившие pr

Инструкции по совместному строительству

из-заВерсия Flutter работает быстрее, производит больше контента, а мыОграниченная рабочая силаНевозможно более всесторонне и быстро поддерживать итерацию ежедневного обслуживания Flutter Go.Если вы заинтересованы в совместной разработке Flutter Go, вы можете принять участие в совместной разработке этого проекта.

Всем, кто участвует в совместном строительстве, мы разместим ваш аватар и личный адрес github на нашем официальном сайте.

Метод совместного строительства

  1. Компоненты совместной сборки
  • Это обновление открытоКоллекция содержимого виджетафункция, вам нужно пройтиgoCliИнструменты, создавайте стандартизированные компоненты, пишите уценочный код.

  • Чтобы лучше фиксировать цель ваших изменений, информацию о содержании, процесс коммуникации, каждый PR должен соответствовать одномуIssue, отправьте то, что вы нашлиBUGили хотите увеличить新功能, или хотите добавить новыйКомпоненты совместной сборки,

  • Выберите свой первыйissueв типе, а затем передатьPull RequestВ интерфейс виджета добавляется содержание статьи, описание API, использование компонентов и т. д.

  1. Публикуйте статьи и исправляйте ошибки
  • Вы также можете использовать, например.Ежедневные баги. будущие функциии т.д. функциональный PR, отправьте заявку в наш основной репозиторий.

Участвовать в совместном строительстве

Чтобы узнать, как подать PR, сначала прочтите следующие документы.

Руководство по взносам

Этот проект следуетКодекс поведения авторов. Участвуя в этом проекте, вы соглашаетесь соблюдать его условия.

FlutterGo с нетерпением ждет совместной работы с вами и мной~

Для получения конкретных сведений о pr и процедурах см.FlutterGoREADME или напрямуюДин ДинОтсканируйте код, чтобы присоединиться к группе

учеба по обмену

Обратите внимание на официальный аккаунт: [Full-stack front-end selection] Получайте хорошие рекомендации статей каждый день. Вы также можете присоединиться к группе и учиться и общаться вместе~~