乐趣区

Flutter-新闻客户端-14-headless-strapi-graphql-快速构建新闻后台

本节指标

  • strapi + graphql 插件 + docker 装置
  • strapi 治理数据结构、内容
  • flutter + graphql 插件 实现查问

视频

https://www.bilibili.com/vide…

代码

https://github.com/ducafecat/…

注释

后盾开发步骤

采纳 strapi + nodejs + 网关 的计划

1. strapi 装置

1.1 docker-compose 形式装置

  • .env
PASSWORD=123456
  • docker-compose.yml
version: "3"
services:
  mongo:
    image: mongo
    container_name: mongo
    restart: always
    ports:
      - 27017:27017
    environment:
      - TZ=Asia/Shanghai
      - MONGO_INITDB_ROOT_USERNAME=root
      - MONGO_INITDB_ROOT_PASSWORD=${PASSWORD}
    volumes:
      - ./docker-data/mongo:/data/db
    networks:
      docker_net:
        ipv4_address: 172.22.0.11

  # starpi
  # admin / 123456 / admin@ducafecat.tech
  strapi-app:
    image: strapi/strapi
    container_name: strapi-app
    restart: always
    ports:
      - 1337:1337
    # command: strapi build
    # command: strapi start
    environment:
      - TZ=Asia/Shanghai
      - DATABASE_CLIENT=mongo
      - DATABASE_HOST=mongo
      - DATABASE_PORT=27017
      - DATABASE_NAME=strapi
      - DATABASE_USERNAME=root
      - DATABASE_PASSWORD=${PASSWORD}
      - DATABASE_AUTHENTICATION_DATABASE=strapi
      # - NODE_ENV=production
    depends_on:
      - mongo
    volumes:
      - ./docker-data/strapi-app:/srv/app
    networks:
      docker_net:
        ipv4_address: 172.22.0.12

networks:
  docker_net:
    driver: bridge
    ipam:
      config:
        - subnet: 172.22.0.0/16

http://localhost:1337/admin

1.2 装置 graphql 插件

2. 构建新闻数据结构

2.1 创立数据类型

  • 增加类型

  • 增加字段

  • 字段列表

2.2 调整数据编辑界面

2.3 调整数据列表界面

2.4 保护数据

  • 列表

  • 增加

3. 调试 graphql 申请

3.3 graphql 语法

  • 类型

    • query 查问
    • mutate 操作

3.4 调试新闻列表

http://localhost:1337/graphql

4. 编写 flutter 代码

4.1 退出 graphql 插件

https://pub.flutter-io.cn/pac…

  • pubspec.yaml
dependencies:
  # graphql
  graphql: ^3.0.2

4.2 封装 graphql client 工具类

  • lib/common/utils/graphql_client.dart
import 'package:flutter/material.dart';
import 'package:flutter_ducafecat_news/common/values/values.dart';
import 'package:flutter_ducafecat_news/common/widgets/widgets.dart';
import 'package:graphql/client.dart';

class GraphqlClientUtil {
  static OptimisticCache cache = OptimisticCache(dataIdFromObject: typenameDataIdFromObject,);

  static client() {
    HttpLink _httpLink = HttpLink(uri: '$SERVER_STRAPI_GRAPHQL_URL/graphql',);

    // final AuthLink _authLink = AuthLink(//   getToken: () =>
    //       'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjVlZmMzNDdhYzgzOTVjMDAwY2ViYzE5NyIsImlhdCI6MTU5MzY1NDcwNiwiZXhwIjoxNTk2MjQ2NzA2fQ.RYDmNSDJxcZLLPHAf4u59IER7Bs5VoWfBo1_t-TR5yY',
    // );

    // final Link _link = _authLink.concat(_httpLink);

    return GraphQLClient(
      cache: cache,
      link: _httpLink,
    );
  }

  // 查问
  static Future query({
    @required BuildContext context,
    @required String schema,
    Map<String, dynamic> variables,
  }) async {
    QueryOptions options = QueryOptions(documentNode: gql(schema),
      variables: variables,
    );

    QueryResult result = await client().query(options);

    if (result.hasException) {toastInfo(msg: result.exception.toString());
      throw result.exception;
    }

    return result;
  }

  // 操作
  static Future mutate({
    @required BuildContext context,
    @required String schema,
    Map<String, dynamic> variables,
  }) async {
    QueryOptions options = QueryOptions(documentNode: gql(schema),
      variables: variables,
    );

    QueryResult result = await client().mutate(options);

    if (result.hasException) {toastInfo(msg: result.exception.toString());
      throw result.exception;
    }

    return result;
  }
}

4.3 编写 graphql 查问申请

  • lib/common/graphql/news_content.dart
const String GQL_NEWS_LIST = r'''
  query News {
    newsContents {
      title
      category
      author
      url
      addtime
      thumbnail {url}
    }
  }
''';

4.4 编写数据实体

lib/common/entitys/gql_news.dart

class GqlNewsResponseEntity {
  GqlNewsResponseEntity({
    this.id,
    this.title,
    this.category,
    this.author,
    this.url,
    this.addtime,
    this.thumbnail,
  });

  String id;
  String title;
  String category;
  String author;
  String url;
  DateTime addtime;
  Thumbnail thumbnail;

  factory GqlNewsResponseEntity.fromJson(Map<String, dynamic> json) =>
      GqlNewsResponseEntity(id: json["id"],
        title: json["title"],
        category: json["category"],
        author: json["author"],
        url: json["url"],
        addtime: DateTime.parse(json["addtime"]),
        thumbnail: Thumbnail.fromJson(json["thumbnail"]),
      );

  Map<String, dynamic> toJson() => {
        "id": id,
        "title": title,
        "category": category,
        "author": author,
        "url": url,
        "addtime":
            "${addtime.year.toString().padLeft(4,'0')}-${addtime.month.toString().padLeft(2,'0')}-${addtime.day.toString().padLeft(2,'0')}",
        "thumbnail": thumbnail.toJson(),};
}

class Thumbnail {
  Thumbnail({this.url,});

  String url;

  factory Thumbnail.fromJson(Map<String, dynamic> json) => Thumbnail(url: json["url"],
      );

  Map<String, dynamic> toJson() => {"url": url,};
}

4.5 编写 API 拜访

  • lib/common/apis/gql_news.dart
import 'package:flutter/material.dart';
import 'package:flutter_ducafecat_news/common/entitys/entitys.dart';
import 'package:flutter_ducafecat_news/common/graphql/graphql.dart';
import 'package:flutter_ducafecat_news/common/utils/utils.dart';
import 'package:graphql/client.dart';

/// 新闻
class GqlNewsAPI {
  /// 翻页
  static Future<List<GqlNewsResponseEntity>> newsPageList({
    @required BuildContext context,
    Map<String, dynamic> params,
  }) async {
    QueryResult response =
        await GraphqlClientUtil.query(context: context, schema: GQL_NEWS_LIST);

    return response.data['newsContents']
        .map<GqlNewsResponseEntity>((item) => GqlNewsResponseEntity.fromJson(item))
        .toList();}
}

4.6 批改新闻列表页

  • lib/pages/main/main.dart
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_ducafecat_news/common/apis/apis.dart';
import 'package:flutter_ducafecat_news/common/entitys/entitys.dart';
import 'package:flutter_ducafecat_news/common/utils/utils.dart';
import 'package:flutter_ducafecat_news/common/values/values.dart';
import 'package:flutter_ducafecat_news/common/widgets/widgets.dart';
import 'package:flutter_ducafecat_news/pages/main/ad_widget.dart';
import 'package:flutter_ducafecat_news/pages/main/categories_widget.dart';
import 'package:flutter_ducafecat_news/pages/main/channels_widget.dart';
import 'package:flutter_ducafecat_news/pages/main/news_item_widget.dart';
import 'package:flutter_ducafecat_news/pages/main/newsletter_widget.dart';
import 'package:flutter_ducafecat_news/pages/main/recommend_widget.dart';
import 'package:flutter_easyrefresh/easy_refresh.dart';

class MainPage extends StatefulWidget {MainPage({Key key}) : super(key: key);

  @override
  _MainPageState createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
  EasyRefreshController _controller; // EasyRefresh 控制器

  // NewsPageListResponseEntity _newsPageList; // 新闻翻页
  List<GqlNewsResponseEntity> _newsPageList; // 新闻翻页

  NewsItem _newsRecommend; // 新闻举荐
  List<CategoryResponseEntity> _categories; // 分类
  List<ChannelResponseEntity> _channels; // 频道

  String _selCategoryCode; // 选中的分类 Code

  @override
  void initState() {super.initState();
    _controller = EasyRefreshController();
    _loadAllData();
    _loadLatestWithDiskCache();}

  // 如果有磁盘缓存,提早 3 秒拉取更新档案
  _loadLatestWithDiskCache() {if (CACHE_ENABLE == true) {var cacheData = StorageUtil().getJSON(STORAGE_INDEX_NEWS_CACHE_KEY);
      if (cacheData != null) {Timer(Duration(seconds: 3), () {_controller.callRefresh();
        });
      }
    }
  }

  // 读取所有数据
  _loadAllData() async {
    _categories = await NewsAPI.categories(
      context: context,
      cacheDisk: true,
    );
    _channels = await NewsAPI.channels(
      context: context,
      cacheDisk: true,
    );
    // _newsRecommend = await NewsAPI.newsRecommend(
    //   context: context,
    //   cacheDisk: true,
    // );

    // _newsPageList = await NewsAPI.newsPageList(
    //   context: context,
    //   cacheDisk: true,
    // );
    _newsPageList = await GqlNewsAPI.newsPageList(context: context,);

    _selCategoryCode = _categories.first.code;

    if (mounted) {setState(() {});
    }
  }

  // 拉取举荐、新闻
  _loadNewsData(
    categoryCode, {bool refresh = false,}) async {
    _selCategoryCode = categoryCode;
    _newsRecommend = await NewsAPI.newsRecommend(
      context: context,
      params: NewsRecommendRequestEntity(categoryCode: categoryCode),
      refresh: refresh,
      cacheDisk: true,
    );
    // _newsPageList = await NewsAPI.newsPageList(
    //   context: context,
    //   params: NewsPageListRequestEntity(categoryCode: categoryCode),
    //   refresh: refresh,
    //   cacheDisk: true,
    // );
    _newsPageList = await GqlNewsAPI.newsPageList(context: context,);

    if (mounted) {setState(() {});
    }
  }

  // 分类菜单
  Widget _buildCategories() {
    return _categories == null
        ? Container()
        : newsCategoriesWidget(
            categories: _categories,
            selCategoryCode: _selCategoryCode,
            onTap: (CategoryResponseEntity item) {_loadNewsData(item.code);
            },
          );
  }

  // 举荐浏览
  Widget _buildRecommend() {
    return _newsRecommend == null // 数据没到位,能够用骨架图展现
        ? Container()
        : recommendWidget(_newsRecommend);
  }

  // 频道
  Widget _buildChannels() {
    return _channels == null
        ? Container()
        : newsChannelsWidget(
            channels: _channels,
            onTap: (ChannelResponseEntity item) {},);
  }

  // 新闻列表
  Widget _buildNewsList() {
    return _newsPageList == null
        ? Container(height: duSetHeight(161 * 5 + 100.0),
          )
        : Column(children: _newsPageList.map((item) {
              // 新闻行
              List<Widget> widgets = <Widget>[newsItem(item),
                Divider(height: 1),
              ];

              // 每 5 条 显示广告
              int index = _newsPageList.indexOf(item);
              if (((index + 1) % 5) == 0) {
                widgets.addAll(<Widget>[adWidget(),
                  Divider(height: 1),
                ]);
              }

              // 返回
              return Column(children: widgets,);
            }).toList(),);
  }

  // ad 广告条
  // 邮件订阅
  Widget _buildEmailSubscribe() {return newsletterWidget();
  }

  @override
  Widget build(BuildContext context) {
    return _newsPageList == null
        ? cardListSkeleton()
        : EasyRefresh(
            enableControlFinishRefresh: true,
            controller: _controller,
            header: ClassicalHeader(),
            onRefresh: () async {
              await _loadNewsData(
                _selCategoryCode,
                refresh: true,
              );
              _controller.finishRefresh();},
            child: SingleChildScrollView(
              child: Column(
                children: <Widget>[_buildCategories(),
                  Divider(height: 1),
                  _buildRecommend(),
                  Divider(height: 1),
                  _buildChannels(),
                  Divider(height: 1),
                  _buildNewsList(),
                  Divider(height: 1),
                  _buildEmailSubscribe(),],
              ),
            ),
          );
  }
}

4.7 批改新闻详情页

  • lib/pages/main/news_item_widget.dart
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_ducafecat_news/common/entitys/entitys.dart';
import 'package:flutter_ducafecat_news/common/utils/utils.dart';
import 'package:flutter_ducafecat_news/common/values/values.dart';
import 'package:flutter_ducafecat_news/common/widgets/widgets.dart';
import 'package:flutter_ducafecat_news/common/router/router.gr.dart';

/// 新闻行 Item
Widget newsItem(GqlNewsResponseEntity item) {
  return Container(height: duSetHeight(161),
    padding: EdgeInsets.all(duSetWidth(20)),
    child: Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        // 图
        InkWell(onTap: () {
            ExtendedNavigator.rootNavigator.pushNamed(
              Routes.detailsPageRoute,
              arguments: DetailsPageArguments(item: item),
            );
          },
          child: imageCached('$SERVER_STRAPI_GRAPHQL_URL${item.thumbnail.url}',
            width: duSetWidth(121),
            height: duSetWidth(121),
          ),
        ),
        // 右侧
        SizedBox(width: duSetWidth(194),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              // 作者
              Container(margin: EdgeInsets.all(0),
                child: Text(
                  item.author,
                  style: TextStyle(
                    fontFamily: 'Avenir',
                    fontWeight: FontWeight.normal,
                    color: AppColors.thirdElementText,
                    fontSize: duSetFontSize(14),
                    height: 1,
                  ),
                ),
              ),
              // 题目
              InkWell(onTap: () {
                  ExtendedNavigator.rootNavigator.pushNamed(
                    Routes.detailsPageRoute,
                    arguments: DetailsPageArguments(item: item),
                  );
                },
                child: Container(margin: EdgeInsets.only(top: duSetHeight(10)),
                  child: Text(
                    item.title,
                    style: TextStyle(
                      fontFamily: 'Montserrat',
                      fontWeight: FontWeight.w500,
                      color: AppColors.primaryText,
                      fontSize: duSetFontSize(16),
                      height: 1,
                    ),
                    overflow: TextOverflow.clip,
                    maxLines: 3,
                  ),
                ),
              ),
              // Spacer
              Spacer(),
              // 一行 3 列
              Container(
                child: Row(
                  crossAxisAlignment: CrossAxisAlignment.center,
                  children: <Widget>[
                    // 分类
                    ConstrainedBox(
                      constraints: BoxConstraints(maxWidth: duSetWidth(60),
                      ),
                      child: Text(
                        item.category,
                        style: TextStyle(
                          fontFamily: 'Avenir',
                          fontWeight: FontWeight.normal,
                          color: AppColors.secondaryElementText,
                          fontSize: duSetFontSize(14),
                          height: 1,
                        ),
                        overflow: TextOverflow.clip,
                        maxLines: 1,
                      ),
                    ),
                    // 增加工夫
                    Container(width: duSetWidth(15),
                    ),
                    ConstrainedBox(
                      constraints: BoxConstraints(maxWidth: duSetWidth(100),
                      ),
                      child: Text('• ${duTimeLineFormat(item.addtime)}',
                        style: TextStyle(
                          fontFamily: 'Avenir',
                          fontWeight: FontWeight.normal,
                          color: AppColors.thirdElementText,
                          fontSize: duSetFontSize(14),
                          height: 1,
                        ),
                        overflow: TextOverflow.clip,
                        maxLines: 1,
                      ),
                    ),
                    // 更多
                    Spacer(),
                    InkWell(
                      child: Icon(
                        Icons.more_horiz,
                        color: AppColors.primaryText,
                        size: 24,
                      ),
                      onTap: () {},
                    ),
                  ],
                ),
              ),
            ],
          ),
        ),
      ],
    ),
  );
}

资源

设计稿蓝湖预览

https://lanhuapp.com/url/lYuz1
明码: gSKl

蓝湖当初免费了,所以查看标记还请本人上传 xd 设计稿
商业设计稿文件不好间接分享, 能够加微信分割 ducafecat

参考

  • https://pub.flutter-io.cn/pac…
  • https://strapi.io/documentati…

© 猫哥

https://ducafecat.tech

退出移动版