提交 d7d2ea89 authored 作者: 王鹏飞's avatar 王鹏飞

feat: 新增产品门户、成果中心和统一登录

上级 928f5fa3
...@@ -54,3 +54,4 @@ app.*.map.json ...@@ -54,3 +54,4 @@ app.*.map.json
signing/ signing/
.agents/ .agents/
/output/
\ No newline at end of file
...@@ -23,6 +23,13 @@ ...@@ -23,6 +23,13 @@
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme"
/> />
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="ezijing" />
</intent-filter>
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
......
# App 产品模式
通过打包参数 `PORTAL_ENABLED` 选择版本,默认 `true`。
| 模式 | 启动首页 | 登录和退出 |
| --- | --- | --- |
| true | 公司产品门户 | passport/usercenter 统一登录,教材 API 换取教材 Token;统一退出 |
| false | 数字教材图书馆与原有 Tab | 原有教材密码/验证码登录、忘记密码和重置密码;只退出教材账号 |
学校教材版不加载门户首页配置,不调用 passport/usercenter 或统一账号转换。学校接口地址沿用项目现有接口配置。
开发运行:
```bash
flutter run --dart-define=PORTAL_ENABLED=false
```
学校版打包:
```bash
flutter build apk --release --dart-define=PORTAL_ENABLED=false
flutter build ios --release --no-codesign --dart-define=PORTAL_ENABLED=false
```
公司门户版沿用原有命令,或明确传入 `--dart-define=PORTAL_ENABLED=true`。
模式是编译配置,切换参数必须停止并重新运行 App。此次恢复了模型字段及登录页面,正在运行的 App 也需要热重启,不能只热重载。
本次无需新增数据库字段或修改 API;教材版复用原有教材登录接口。
# 应用链接配置
首页入口与 Banner 根据链接协议打开:HTTP/HTTPS 打开网页,`ezijing` 打开应用页面。Android 和 iOS 使用相同的应用链接格式。
| 目标 | 应用链接 |
| --- | --- |
| 公司产品首页 | `ezijing://home` |
| 数字教材首页(图书馆) | `ezijing://textbook` |
| 图书馆 | `ezijing://textbook?tab=library` |
| 课程 | `ezijing://textbook?tab=course` |
| 书架 | `ezijing://textbook?tab=bookshelf` |
| 我的 | `ezijing://textbook?tab=mine` |
| 教材详情 | `ezijing://book/detail?book_id=123` |
Tab 跳转保留原有底部导航。课程、书架、我的以及教材详情会先完成登录:门户版使用统一登录并换取教材 Token,教材版使用原有教材登录。图书馆可直接浏览,首页入口配置为需要登录时会先登录。
将 `123` 替换为目标教材的正整数 ID。教材详情进入时会完成必要的登录与自动会话兑换,不在链接中传用户身份、token 或订单权限。
首页配置中的入口和广告管理中的 Banner 均可填写以上地址。网页也可通过 `<a href="ezijing://book/detail?book_id=123">查看教材</a>` 打开 App 页面;网页中的新窗口链接同样拦截为 App 跳转,取消登录后留在原网页。未知页面、缺少或无效的教材 ID 不会跳到任意内部路由。
本次采用自定义 URL Scheme。HTTPS 形式的 Android App Links / iOS Universal Links 需要另外配置域名关联文件与应用签名,目前不宣称已启用;现有支付回调协议保持独立。
Android/iOS 原生协议配置更改后需要重新安装新构建,热重载无法注册协议。上线前需在设备上确认:已启动与未启动状态打开链接、首次隐私确认、未登录进入教材详情、取消登录后重试,以及 Banner 返回行为。
# APP 首页与统一登录上线说明
本次功能尚未上线,每个 API 各保留一份完整首次上线 SQL,不需要执行开发过程中的迁移链。
| 服务 | 完整 SQL | 内容 |
| --- | --- | --- |
| 管理 API com-ebook-pc-api | migrations/20261010_app_home_configuration.sql | APP配置单表、三个选项卡的查询和保存权限、首页广告位 |
| App API com-ebook-app-api | migrations/20261010_unified_login.sql | members_sso_binding 统一账号与教材会员映射表 |
## 首页配置
portal_home_config 仅 id、config、update_time、updated_id;id=1 的 config 包含 modules,模块下包含 items。后台整页保存 JSON;新环境初始化为空配置,运营内容由后台配置。SQL 重复执行不会覆盖已保存配置。
App 的 getPortal 接口独立读取首页配置及广告管理「首页-广告」,组合返回 modules、banners、product_banners 和 news。首页配置不包含广告位 ID,不依赖是否已有模块来读取 Banner。
## 发布顺序
1. 备份目标教材数据库,确认两端读取同一数据库。
2. 执行上述两份 SQL,没有顺序依赖。
3. 部署管理 API 与 App API,以及对应后台页面;为维护角色授予 APP配置菜单及首页、产品中心、成果中心的查询/保存权限。
4. 在后台配置模块、入口与首页广告,进行 App 联调后发布。
两份 SQL 均以现有教材基础数据库为前提,不是整个系统的数据库安装脚本。不包含本地角色 ID、本地账号数据、测试记录、废弃产品表或开发归档表。
应用链接配置见 [app-links.md](app-links.md)。
## 产品中心与成果中心
- 底部导航为:首页、产品中心、成果中心、我的;学校版仍使用教材导航。
- `portal_home_config` 继续保存 JSON:id=1 首页模块,id=2 独立产品目录(style、items),id=3 成果栏目(categories)。首页热门入口不与产品目录关联。
- 后台统一为“APP配置”,包含首页、产品中心、成果中心三个选项卡;完整菜单、权限及配置初始化 SQL 已合并到 `20261010_app_home_configuration.sql`。生产需在角色管理中授权新增菜单及保存权限。
- 产品目录维护图标、名称、简介、链接、登录要求、启用和排序。本地首次从热门入口复制了目录,后续独立维护;生产初始目录为空,需要配置。
- 成果文章仍在官方 CMS 维护。后台成果栏目填写 CMS 的 type_tag,使用既有官网 CMS 项目;仅返回已启用且有文章的栏目;文章分页使用 type_tag,不受后台栏目排序变化影响。CMS 查询并发执行。
- 成果文章详情复用 CMS 正文展示,详情和产品网页通过根导航全屏打开,关闭后返回原 Tab。
- 路由变更后需热重启 App;静态检查与本地接口读取不代表真机展示已验证。
## 开发与发布接口
`constants.dart` 中 Debug 使用局域网本地接口,Profile/Release 使用生产接口。手机联调需与电脑处于同一网络;电脑 IP 变化时修改 Debug 地址。发布前先部署 API 和执行 SQL,再发布 App。
...@@ -22,8 +22,18 @@ ...@@ -22,8 +22,18 @@
<string>$(FLUTTER_BUILD_NAME)</string> <string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>FlutterDeepLinkingEnabled</key>
<true/>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>ezijing.products</string>
<key>CFBundleURLSchemes</key>
<array><string>ezijing</string></array>
</dict>
<dict> <dict>
<key>CFBundleTypeRole</key> <key>CFBundleTypeRole</key>
<string>Editor</string> <string>Editor</string>
......
...@@ -28,7 +28,11 @@ abstract class AccountAPI { ...@@ -28,7 +28,11 @@ abstract class AccountAPI {
params['uuid'] = _getUUID(); params['uuid'] = _getUUID();
} }
final result = await HttpService.to.post('/v1/members/login/login', final result = await HttpService.to.post('/v1/members/login/login',
excludeToken: true, showLoading: true, params: params); excludeToken: true,
sensitive: true,
options: Options(extra: {'skipAuthRefresh': true}),
showLoading: true,
params: params);
return UserModel.fromJson(_responseData(result)); return UserModel.fromJson(_responseData(result));
} }
...@@ -56,6 +60,8 @@ abstract class AccountAPI { ...@@ -56,6 +60,8 @@ abstract class AccountAPI {
}) async { }) async {
final result = await HttpService.to.post( final result = await HttpService.to.post(
'/v1/members/login/sendCode', '/v1/members/login/sendCode',
sensitive: true,
options: Options(extra: {'skipAuthRefresh': true}),
params: { params: {
'phone': phone, 'phone': phone,
'types': type, 'types': type,
...@@ -76,6 +82,8 @@ abstract class AccountAPI { ...@@ -76,6 +82,8 @@ abstract class AccountAPI {
}) async { }) async {
final result = await HttpService.to.post( final result = await HttpService.to.post(
'/v1/members/login/editPassword', '/v1/members/login/editPassword',
sensitive: true,
options: Options(extra: {'skipAuthRefresh': true}),
params: { params: {
'phone': phone, 'phone': phone,
'code': code, 'code': code,
......
import '../services/index.dart';
abstract class ProductAPI {
static Future<Map<String, dynamic>> products() => _get('getProducts', {});
static Future<Map<String, dynamic>> achievementCategories() =>
_get('getAchievementCategories', {});
static Future<Map<String, dynamic>> achievements(String category, int page) =>
_get('getAchievementList', {'type_tag': category, 'page': page});
static Future<Map<String, dynamic>> _get(
String action, Map<String, dynamic> params) async {
final response = await HttpService.to.post('/v1/common/product/$action',
params: params,
excludeToken: true,
options: Options(extra: {'skipAuthRefresh': true}));
return response.data as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> newsList(int page) async {
final response = await HttpService.to.post('/v1/common/product/getNewsList',
params: {'page': page},
excludeToken: true,
options: Options(extra: {'skipAuthRefresh': true}));
return response.data as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> newsDetail(String id) async {
final response = await HttpService.to.post(
'/v1/common/product/getNewsDetail',
params: {'id': id},
excludeToken: true,
options: Options(extra: {'skipAuthRefresh': true}));
return response.data as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> portal() async {
final response = await HttpService.to.post('/v1/common/product/getPortal',
params: {},
excludeToken: true,
options: Options(extra: {'skipAuthRefresh': true}));
return response.data as Map<String, dynamic>;
}
}
...@@ -12,18 +12,14 @@ import 'package:oktoast/oktoast.dart'; ...@@ -12,18 +12,14 @@ import 'package:oktoast/oktoast.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'global.dart'; import 'global.dart';
import 'utils/app_mode.dart';
void main() { void main() {
Global.init().then((_) { Global.init().then((_) {
Future.wait([ UserStore.to.onSessionExpired = () => Routes.config
UserStore.to.profile(), .goNamed(AppMode.portalEnabled ? Routes.productHome : Routes.login);
]).whenComplete(() {
// 如果需要 ensureInitialized,请在这里运行。
WidgetsFlutterBinding.ensureInitialized();
UserStore.to.onSessionExpired = () => Routes.config.goNamed(Routes.login);
runApp(const MyApp()); runApp(const MyApp());
}); });
});
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.light); SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.light);
} }
......
class CompanyUser {
const CompanyUser(
{required this.id, required this.name, required this.avatar});
factory CompanyUser.fromJson(Map<String, dynamic> json) => CompanyUser(
id: json['id'] as String,
name: json['realname'] as String? ?? '',
avatar: json['avatar'] as String? ?? '',
);
final String id, name, avatar;
}
import 'index.dart';
import 'product.dart';
import 'portal_news.dart';
class PortalModule {
const PortalModule({
required this.name,
required this.style,
required this.products,
required this.sort,
this.moreName = '',
this.moreTarget = '',
});
factory PortalModule.fromJson(Map<String, dynamic> json) => PortalModule(
name: json['name'] as String,
style: json['style'] as String,
sort: json['sort'] as int,
moreName: json['more_name'] as String? ?? '',
moreTarget: json['more_target'] as String? ?? '',
products: (json['items'] as List)
.map((item) => ProductModel.fromJson(item as Map<String, dynamic>))
.toList()
..sort((a, b) => a.sort.compareTo(b.sort)),
);
final String name, style, moreName, moreTarget;
final int sort;
final List<ProductModel> products;
}
class ProductPortal {
const ProductPortal(
{this.modules = const [],
this.banners = const [],
this.productBanners = const [],
this.news = const []});
factory ProductPortal.fromJson(Map<String, dynamic> json) => ProductPortal(
modules: (json['modules'] as List)
.map((item) => PortalModule.fromJson(item as Map<String, dynamic>))
.where((module) => module.products.isNotEmpty)
.toList()
..sort((a, b) => a.sort.compareTo(b.sort)),
news: (json['news'] as List? ?? [])
.map((item) => PortalNews.fromJson(item as Map<String, dynamic>))
.toList(),
banners: (json['banners'] as List).map(AdModel.fromJson).toList(),
productBanners: (json['product_banners'] as List? ?? [])
.map(AdModel.fromJson)
.toList(),
);
final List<PortalModule> modules;
final List<PortalNews> news;
final List<AdModel> banners, productBanners;
List<ProductModel> get products =>
modules.expand((module) => module.products).toList();
}
class PortalNews {
const PortalNews(
{required this.id,
required this.title,
required this.image,
required this.startTime,
this.summary = ''});
factory PortalNews.fromJson(Map<String, dynamic> json) => PortalNews(
id: json['id'] as String,
title: json['title'] as String,
summary: json['abstract'] as String? ?? '',
image: (json['mobile_terminal_img_uri'] as String? ?? '').isNotEmpty
? json['mobile_terminal_img_uri'] as String
: json['web_img_uri'] as String? ?? '',
startTime: json['start_time'] as int,
);
final String id, title, image, summary;
final int startTime;
}
/// A product entry published by the administration backend.
class ProductModel {
const ProductModel({
required this.name,
required this.description,
required this.icon,
required this.target,
required this.requireLogin,
required this.sort,
});
factory ProductModel.fromJson(Map<String, dynamic> json) => ProductModel(
name: json['name'] as String,
description: json['description'] as String? ?? '',
icon: json['icon'] as String? ?? '',
target: json['target'] as String? ?? '',
sort: json['sort'] as int,
requireLogin: (json['require_login'] as int) == 1,
);
final String name, description, icon, target;
final bool requireLogin;
final int sort;
}
class ProductCatalog {
const ProductCatalog({required this.style, required this.items});
factory ProductCatalog.fromJson(Map<String, dynamic> json) => ProductCatalog(
style: json['style'] as String,
items: (json['items'] as List)
.map((item) => ProductModel.fromJson(item as Map<String, dynamic>))
.toList()
..sort((a, b) => a.sort.compareTo(b.sort)),
);
final String style;
final List<ProductModel> items;
}
...@@ -14,6 +14,20 @@ class AdController extends GetxController { ...@@ -14,6 +14,20 @@ class AdController extends GetxController {
startTime(); startTime();
} }
bool opening = false;
Future<void> openBanner(AdModel model) async {
if (opening) return;
opening = true;
_timer.cancel();
try {
await context.pushNamed(Routes.adDetail, extra: model);
} finally {
opening = false;
if (context.mounted) context.goNamed(Routes.home);
}
}
/// 开启定时器 /// 开启定时器
void startTime() { void startTime() {
_timer = Timer.periodic(const Duration(seconds: 1), (timer) { _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
...@@ -23,7 +37,7 @@ class AdController extends GetxController { ...@@ -23,7 +37,7 @@ class AdController extends GetxController {
} else { } else {
_timer.cancel(); _timer.cancel();
// context.pushReplacementNamed(Routes.splash); // context.pushReplacementNamed(Routes.splash);
context.goNamed(Routes.main); context.goNamed(Routes.home);
} }
}); });
} }
......
...@@ -18,9 +18,7 @@ class _AdPageState extends State<AdPage> { ...@@ -18,9 +18,7 @@ class _AdPageState extends State<AdPage> {
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
GestureDetector( GestureDetector(
onTap: () { onTap: () => controller.openBanner(widget.ads.first),
context.pushNamed(Routes.adDetail, extra: widget.ads.first);
},
child: Container( child: Container(
color: Colors.green, color: Colors.green,
child: CustomImage.network(url: widget.ads.first.pic ?? ''), child: CustomImage.network(url: widget.ads.first.pic ?? ''),
...@@ -31,7 +29,7 @@ class _AdPageState extends State<AdPage> { ...@@ -31,7 +29,7 @@ class _AdPageState extends State<AdPage> {
right: 20.w, right: 20.w,
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
context.goNamed(Routes.main); context.goNamed(Routes.home);
}, },
child: Container( child: Container(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
......
library ad_detail; library ad_detail;
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../../models/index.dart'; import '../../models/index.dart';
import '../../utils/app_link.dart';
import '../../services/app_link_navigator.dart';
part 'view.dart'; part 'view.dart';
...@@ -9,7 +9,56 @@ class AdDetailPage extends StatefulWidget { ...@@ -9,7 +9,56 @@ class AdDetailPage extends StatefulWidget {
} }
class _AdDetailPageState extends State<AdDetailPage> { class _AdDetailPageState extends State<AdDetailPage> {
String _pageTitle = '团书馆'; String _pageTitle = '产品与服务';
bool _linkFailed = false;
bool _openingWebLink = false;
Future<void> _openWebLink(String target) async {
if (_openingWebLink || !mounted) return;
if (AppLink.parse(target) == null) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('当前版本不支持此应用链接')));
return;
}
_openingWebLink = true;
try {
await AppLinkNavigator.open(context, target);
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('暂时无法打开,请重试')));
}
} finally {
_openingWebLink = false;
}
}
bool get _isAppLink =>
Uri.tryParse(widget.adModel.url ?? '')?.scheme == 'ezijing';
@override
void initState() {
super.initState();
if (_isAppLink) {
WidgetsBinding.instance.addPostFrameCallback((_) => _openAppLink());
}
}
Future<void> _openAppLink() async {
if (!mounted) return;
final url = widget.adModel.url ?? '';
if (AppLink.parse(url) == null) {
setState(() => _linkFailed = true);
return;
}
final opened = await AppLinkNavigator.open(context, url);
if (!mounted) return;
if (opened) {
Navigator.of(context).pop();
} else {
setState(() => _linkFailed = true);
}
}
void _updateTitle(String? title) { void _updateTitle(String? title) {
final value = title?.trim() ?? ''; final value = title?.trim() ?? '';
...@@ -22,6 +71,18 @@ class _AdDetailPageState extends State<AdDetailPage> { ...@@ -22,6 +71,18 @@ class _AdDetailPageState extends State<AdDetailPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_isAppLink) {
return Scaffold(
appBar: AppBar(title: Text(_pageTitle)),
body: Center(
child: _linkFailed
? Column(mainAxisSize: MainAxisSize.min, children: [
const Text('链接未打开,请返回或重试'),
TextButton(
onPressed: _openAppLink, child: const Text('重试')),
])
: const CircularProgressIndicator()));
}
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(_pageTitle)), appBar: AppBar(title: Text(_pageTitle)),
body: InAppWebView( body: InAppWebView(
...@@ -31,7 +92,28 @@ class _AdDetailPageState extends State<AdDetailPage> { ...@@ -31,7 +92,28 @@ class _AdDetailPageState extends State<AdDetailPage> {
), ),
initialSettings: InAppWebViewSettings( initialSettings: InAppWebViewSettings(
// http的请求也不做限制 // http的请求也不做限制
mixedContentMode: MixedContentMode.MIXED_CONTENT_ALWAYS_ALLOW), mixedContentMode: MixedContentMode.MIXED_CONTENT_ALWAYS_ALLOW,
supportMultipleWindows: true,
useShouldOverrideUrlLoading: true),
shouldOverrideUrlLoading: (controller, action) async {
final target = action.request.url;
if (target?.scheme == 'ezijing') {
unawaited(_openWebLink(target.toString()));
return NavigationActionPolicy.CANCEL;
}
return target?.scheme == 'https' || target?.scheme == 'http'
? NavigationActionPolicy.ALLOW
: NavigationActionPolicy.CANCEL;
},
onCreateWindow: (controller, action) async {
final target = action.request.url;
if (target?.scheme == 'ezijing') {
unawaited(_openWebLink(target.toString()));
} else if (target?.scheme == 'https' || target?.scheme == 'http') {
await controller.loadUrl(urlRequest: action.request);
}
return false;
},
onLoadStop: (controller, url) async { onLoadStop: (controller, url) async {
_updateTitle(await controller.getTitle()); _updateTitle(await controller.getTitle());
}, },
......
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../routes/index.dart';
import '../../services/app_link_navigator.dart';
import '../../utils/app_link.dart';
import '../../services/unified_session.dart';
import '../../widgets/portal_toast.dart';
/// Handles an OS link after the startup privacy flow has completed.
class AppLinkPage extends StatefulWidget {
const AppLinkPage({super.key, required this.target});
final String target;
@override
State<AppLinkPage> createState() => _AppLinkPageState();
}
class _AppLinkPageState extends State<AppLinkPage> {
bool _cancelled = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _open());
}
Future<void> _open() async {
if (!mounted) return;
final destination = AppLink.parse(widget.target)?.destination;
try {
final opened = await AppLinkNavigator.open(context, widget.target,
requireLogin:
destination == 'textbook' || destination == 'book_detail',
replace: true);
if (!mounted || opened) return;
} catch (error) {
if (!mounted) return;
PortalToast.show(context, UnifiedSession.errorMessage(error),
type: PortalToastType.error);
}
if (mounted) setState(() => _cancelled = true);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('打开产品')),
body: Center(
child: _cancelled
? Column(mainAxisSize: MainAxisSize.min, children: [
const Text('登录完成后可继续打开'),
FilledButton(onPressed: _open, child: const Text('继续')),
TextButton(
onPressed: () => context.goNamed(Routes.home),
child: const Text('返回全部产品')),
])
: const CircularProgressIndicator()));
}
...@@ -3,6 +3,7 @@ library book_shop; ...@@ -3,6 +3,7 @@ library book_shop;
import 'package:decimal/decimal.dart'; import 'package:decimal/decimal.dart';
import 'package:easy_refresh/easy_refresh.dart'; import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../widgets/product_home_button.dart';
import 'package:flutter_book/utils/index.dart'; import 'package:flutter_book/utils/index.dart';
import 'package:flutter_book/widgets/index.dart'; import 'package:flutter_book/widgets/index.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
......
...@@ -14,6 +14,7 @@ class _BookShopPageState extends State<BookShopPage> { ...@@ -14,6 +14,7 @@ class _BookShopPageState extends State<BookShopPage> {
init: BookshopController(), init: BookshopController(),
builder: (controller) => Scaffold( builder: (controller) => Scaffold(
appBar: AppBar( appBar: AppBar(
leading: const ProductHomeButton(),
title: const Text('书架'), title: const Text('书架'),
), ),
body: Column( body: Column(
......
...@@ -3,6 +3,7 @@ library course; ...@@ -3,6 +3,7 @@ library course;
import 'package:carousel_slider/carousel_slider.dart'; import 'package:carousel_slider/carousel_slider.dart';
import 'package:easy_refresh/easy_refresh.dart'; import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../widgets/product_home_button.dart';
import 'package:flutter_book/theme.dart'; import 'package:flutter_book/theme.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
......
...@@ -14,6 +14,7 @@ class _CoursePageState extends State<CoursePage> { ...@@ -14,6 +14,7 @@ class _CoursePageState extends State<CoursePage> {
init: CourseController(), init: CourseController(),
builder: (controller) => Scaffold( builder: (controller) => Scaffold(
appBar: CustomAppBar( appBar: CustomAppBar(
leading: const ProductHomeButton(),
titleSpacing: 0, titleSpacing: 0,
title: Padding( title: Padding(
padding: EdgeInsets.symmetric(horizontal: AppTheme.margin), padding: EdgeInsets.symmetric(horizontal: AppTheme.margin),
......
...@@ -4,6 +4,7 @@ import 'dart:convert'; ...@@ -4,6 +4,7 @@ import 'dart:convert';
import 'package:easy_refresh/easy_refresh.dart'; import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../widgets/product_home_button.dart';
import 'package:flutter_book/apis/index.dart'; import 'package:flutter_book/apis/index.dart';
import 'package:flutter_book/theme.dart'; import 'package:flutter_book/theme.dart';
import 'package:flutter_book/utils/index.dart'; import 'package:flutter_book/utils/index.dart';
...@@ -14,6 +15,7 @@ import 'package:go_router/go_router.dart'; ...@@ -14,6 +15,7 @@ import 'package:go_router/go_router.dart';
import '../../models/index.dart'; import '../../models/index.dart';
import '../../routes/index.dart'; import '../../routes/index.dart';
import '../../store/index.dart'; import '../../store/index.dart';
import '../../services/unified_session.dart';
import '../../widgets/index.dart'; import '../../widgets/index.dart';
import '../course/index.dart'; import '../course/index.dart';
......
...@@ -14,6 +14,7 @@ class _LibraryPageState extends State<LibraryPage> { ...@@ -14,6 +14,7 @@ class _LibraryPageState extends State<LibraryPage> {
init: LibraryController(context), init: LibraryController(context),
builder: (controller) => Scaffold( builder: (controller) => Scaffold(
appBar: AppBar( appBar: AppBar(
leading: const ProductHomeButton(),
title: const Text('图书馆'), title: const Text('图书馆'),
actions: [ actions: [
IconButton( IconButton(
......
...@@ -33,7 +33,12 @@ class LibraryContentPage extends StatelessWidget { ...@@ -33,7 +33,12 @@ class LibraryContentPage extends StatelessWidget {
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
final model = controller.books[index]; final model = controller.books[index];
return GestureDetector( return GestureDetector(
onTap: () { onTap: () async {
if (!UserStore.to.isLogin &&
!await UnifiedSession.instance.ensureTextbookSession(context)) {
return;
}
if (!context.mounted) return;
context.pushNamed( context.pushNamed(
Routes.bookDetail, Routes.bookDetail,
queryParameters: {'book_id': model.bookId.toString()}, queryParameters: {'book_id': model.bookId.toString()},
...@@ -43,8 +48,9 @@ class LibraryContentPage extends StatelessWidget { ...@@ -43,8 +48,9 @@ class LibraryContentPage extends StatelessWidget {
model: model, model: model,
onTap: () async { onTap: () async {
if (!UserStore.to.isLogin) { if (!UserStore.to.isLogin) {
context.pushNamed(Routes.login); final ready = await UnifiedSession.instance
return; .ensureTextbookSession(context);
if (!ready || !context.mounted) return;
} }
if (!controller.canTap) return; if (!controller.canTap) return;
......
library link; library link;
import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:convert'; import 'dart:convert';
...@@ -8,6 +10,7 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart'; ...@@ -8,6 +10,7 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../routes/index.dart'; import '../../routes/index.dart';
import '../../services/app_link_navigator.dart';
import '../../store/index.dart'; import '../../store/index.dart';
import '../../utils/index.dart'; import '../../utils/index.dart';
import '../../widgets/index.dart'; import '../../widgets/index.dart';
......
...@@ -46,12 +46,26 @@ class _LinkPageState extends State<LinkPage> { ...@@ -46,12 +46,26 @@ class _LinkPageState extends State<LinkPage> {
] ]
: [], : [],
), ),
initialSettings:
InAppWebViewSettings(useShouldOverrideUrlLoading: true),
shouldOverrideUrlLoading: (controller, action) async {
final target = action.request.url;
if (target?.scheme == 'ezijing') {
unawaited(AppLinkNavigator.open(context, target.toString()));
return NavigationActionPolicy.CANCEL;
}
return target?.scheme == 'https' || target?.scheme == 'http'
? NavigationActionPolicy.ALLOW
: NavigationActionPolicy.CANCEL;
},
onWebViewCreated: (controller) { onWebViewCreated: (controller) {
controller.addJavaScriptHandler( controller.addJavaScriptHandler(
handlerName: 'openWebview', handlerName: 'openWebview',
callback: (args) { callback: (args) async {
final url = args.isEmpty ? '' : args.first.toString(); final url = args.isEmpty ? '' : args.first.toString();
if (_isHttpUrl(url) && _isBookH5Url(url)) { if (Uri.tryParse(url)?.scheme == 'ezijing') {
await AppLinkNavigator.open(context, url);
} else if (_isHttpUrl(url) && _isBookH5Url(url)) {
controller.loadUrl( controller.loadUrl(
urlRequest: URLRequest(url: WebUri.uri(Uri.parse(url)))); urlRequest: URLRequest(url: WebUri.uri(Uri.parse(url))));
} else if (_isHttpUrl(url)) { } else if (_isHttpUrl(url)) {
...@@ -69,10 +83,13 @@ class _LinkPageState extends State<LinkPage> { ...@@ -69,10 +83,13 @@ class _LinkPageState extends State<LinkPage> {
callback: (args) => CustomToast.dismiss()); callback: (args) => CustomToast.dismiss());
controller.addJavaScriptHandler( controller.addJavaScriptHandler(
handlerName: 'openLinkCallback', handlerName: 'openLinkCallback',
callback: (args) { callback: (args) async {
if (args.isNotEmpty) { if (args.isEmpty) return;
context.pushNamed(Routes.link, final url = args.first.toString();
queryParameters: {'url': args.first.toString()}); if (Uri.tryParse(url)?.scheme == 'ezijing') {
await AppLinkNavigator.open(context, url);
} else if (_isHttpUrl(url)) {
context.pushNamed(Routes.link, queryParameters: {'url': url});
} }
}); });
controller.addJavaScriptHandler( controller.addJavaScriptHandler(
......
...@@ -234,23 +234,16 @@ class LoginController extends GetxController { ...@@ -234,23 +234,16 @@ class LoginController extends GetxController {
if ((result.token ?? '').isEmpty) return; if ((result.token ?? '').isEmpty) return;
// 存储用户信息 await UserStore.to.login(result);
await Future.wait([UserStore.to.login(result)]);
// await Future.wait([
// UserStore.to.setToken(result.token!),
// UserStore.to.setAccessToken(result.accessToken!),
//
// ]);
//
// await Future.wait([
// UserStore.to.setInfo(result),
// UserStore.to.profile(),
// ]);
if (context.mounted) { if (context.mounted) {
if (context.canPop()) {
context.pop();
} else {
context.goNamed(Routes.main); context.goNamed(Routes.main);
} }
} }
}
/// 发送验证码 /// 发送验证码
Future<void> sendCode(BuildContext context) async { Future<void> sendCode(BuildContext context) async {
......
part of main; part of main;
class MainController extends GetxController { class MainController extends GetxController {
MainController({this.initialPage = 0});
final int initialPage;
late final PageController pageController; late final PageController pageController;
// 默认显示 // 默认显示
int currentPage = 1; late int currentPage = initialPage;
@override @override
void onInit() { void onInit() {
......
...@@ -5,10 +5,8 @@ import 'package:flutter_book/pages/library/index.dart'; ...@@ -5,10 +5,8 @@ import 'package:flutter_book/pages/library/index.dart';
import 'package:flutter_book/pages/mine/index.dart'; import 'package:flutter_book/pages/mine/index.dart';
import 'package:flutter_book/theme.dart'; import 'package:flutter_book/theme.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:go_router/go_router.dart';
import '../../routes/index.dart'; import '../../services/app_session.dart';
import '../../store/index.dart';
import '../book_shop/index.dart'; import '../book_shop/index.dart';
import '../course/index.dart'; import '../course/index.dart';
......
part of main; part of main;
class MainPage extends StatefulWidget { class MainPage extends StatefulWidget {
const MainPage({Key? key}) : super(key: key); const MainPage({Key? key, this.initialPage = 0}) : super(key: key);
final int initialPage;
@override @override
State<MainPage> createState() => _MainPageState(); State<MainPage> createState() => _MainPageState();
} }
class _MainPageState extends State<MainPage> with WidgetsBindingObserver { class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
final String _controllerTag = UniqueKey().toString();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
...@@ -29,21 +33,23 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver { ...@@ -29,21 +33,23 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GetBuilder<MainController>( return GetBuilder<MainController>(
init: MainController(), tag: _controllerTag,
init: MainController(initialPage: widget.initialPage),
builder: (controller) => Scaffold( builder: (controller) => Scaffold(
body: PageView( body: PageView(
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
controller: controller.pageController, controller: controller.pageController,
onPageChanged: controller.onPageChanged, onPageChanged: controller.onPageChanged,
children: const [ children: const [
CoursePage(),
LibraryPage(), LibraryPage(),
CoursePage(),
BookShopPage(), BookShopPage(),
MinePage(), MinePage(),
], ],
), ),
bottomNavigationBar: GetBuilder<MainController>( bottomNavigationBar: GetBuilder<MainController>(
id: 'navigation', id: 'navigation',
tag: _controllerTag,
builder: (controller) => Container( builder: (controller) => Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
...@@ -58,44 +64,44 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver { ...@@ -58,44 +64,44 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
), ),
child: BottomNavigationBar( child: BottomNavigationBar(
currentIndex: controller.currentPage, currentIndex: controller.currentPage,
onTap: (page) { onTap: (page) async {
if (page != 1 && !UserStore.to.isLogin) { if (page != 0) {
context.pushNamed(Routes.login); final ready =
} else { await AppSession.ensureTextbookSession(context);
controller.pageController.jumpToPage(page); if (!ready || !context.mounted) return;
} }
// controller.pageController.jumpToPage(page); controller.pageController.jumpToPage(page);
}, },
items: [ items: [
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Image.asset( icon: Image.asset(
'assets/images/course.png', 'assets/images/library.png',
width: 25, width: 25,
height: 27.5, height: 27.5,
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
activeIcon: Image.asset( activeIcon: Image.asset(
'assets/images/course_select.png', 'assets/images/library_select.png',
width: 25, width: 25,
height: 27.5, height: 27.5,
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
label: '课程', label: '图书馆',
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Image.asset( icon: Image.asset(
'assets/images/library.png', 'assets/images/course.png',
width: 25, width: 25,
height: 27.5, height: 27.5,
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
activeIcon: Image.asset( activeIcon: Image.asset(
'assets/images/library_select.png', 'assets/images/course_select.png',
width: 25, width: 25,
height: 27.5, height: 27.5,
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
label: '图书馆', label: '课程',
), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Image.asset( icon: Image.asset(
......
library mine; library mine;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../widgets/product_home_button.dart';
import 'package:flutter_book/theme.dart'; import 'package:flutter_book/theme.dart';
import 'package:flutter_book/utils/index.dart'; import 'package:flutter_book/utils/index.dart';
import 'package:flutter_book/widgets/index.dart'; import 'package:flutter_book/widgets/index.dart';
......
...@@ -22,6 +22,7 @@ class _MinePageState extends State<MinePage> { ...@@ -22,6 +22,7 @@ class _MinePageState extends State<MinePage> {
Scaffold( Scaffold(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
appBar: CustomAppBar( appBar: CustomAppBar(
leading: const ProductHomeButton(),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
actions: [ actions: [
CustomButton.icon( CustomButton.icon(
......
差异被折叠。
import 'package:flutter/material.dart';
import '../../apis/product.dart';
import '../../theme.dart';
import 'news_list.dart';
class AchievementsPage extends StatefulWidget {
const AchievementsPage({super.key});
@override
State<AchievementsPage> createState() => _AchievementsPageState();
}
class _AchievementsPageState extends State<AchievementsPage> {
late var _categories = ProductAPI.achievementCategories();
@override
Widget build(BuildContext context) => FutureBuilder<Map<String, dynamic>>(
future: _categories,
builder: (context, snapshot) {
final categories = snapshot.data?['categories'] as List? ?? [];
if (categories.isEmpty) {
return Scaffold(
appBar: AppBar(
title: const Text('成果中心'),
automaticallyImplyLeading: false,
centerTitle: true),
body: Center(
child: snapshot.connectionState == ConnectionState.waiting
? const CircularProgressIndicator()
: snapshot.hasError
? TextButton(
onPressed: () => setState(() {
_categories =
ProductAPI.achievementCategories();
}),
child: const Text('重新加载'))
: const Text('暂无内容')),
);
}
return DefaultTabController(
length: categories.length,
child: Scaffold(
appBar: AppBar(
title: const Text('成果中心'),
automaticallyImplyLeading: false,
centerTitle: true,
bottom: TabBar(
isScrollable: true,
labelColor: AppTheme.primary,
unselectedLabelColor: const Color(0xFF606773),
indicatorColor: AppTheme.primary,
tabs: [
for (final category in categories)
Tab(text: category['name'] as String)
]),
),
body: TabBarView(children: [
for (var i = 0; i < categories.length; i++)
NewsListPage(
key: ValueKey(categories[i]['type_tag']),
category: categories[i]['type_tag'] as String,
embedded: true,
title: categories[i]['name'] as String),
]),
));
},
);
}
import 'dart:convert';
import 'package:get/get.dart';
import '../../apis/product.dart';
import '../../models/portal.dart';
import '../../models/product.dart';
import '../../services/index.dart';
class ProductHomeController extends GetxController {
static const _cacheKey = 'company_portal_v4';
ProductPortal portal = const ProductPortal();
List<ProductModel> get products => portal.products;
bool loading = true;
bool failed = false;
bool _refreshing = false;
@override
void onInit() {
super.onInit();
try {
final cache = StorageService.to.getString(_cacheKey);
if (cache.isNotEmpty) {
portal =
ProductPortal.fromJson(jsonDecode(cache) as Map<String, dynamic>);
}
} catch (_) {/* Fetch published data when the cache is invalid. */}
refreshProducts();
}
Future<void> refreshProducts() async {
if (_refreshing || isClosed) return;
_refreshing = true;
failed = false;
update();
try {
final result = await ProductAPI.portal();
if (isClosed) return;
portal = ProductPortal.fromJson(result);
await StorageService.to.setString(_cacheKey, jsonEncode(result));
} catch (_) {
failed = true;
} finally {
_refreshing = false;
loading = false;
if (!isClosed) update();
}
}
}
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import '../../apis/product.dart';
import '../../models/portal_news.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'product_widgets.dart';
class NewsDetailPage extends StatefulWidget {
const NewsDetailPage({super.key, required this.news, this.title = '最新动态'});
final String title;
final PortalNews news;
@override
State<NewsDetailPage> createState() => _NewsDetailPageState();
}
class _NewsDetailPageState extends State<NewsDetailPage> {
late final _detail = ProductAPI.newsDetail(widget.news.id);
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(widget.title)),
body: FutureBuilder<Map<String, dynamic>>(
future: _detail,
builder: (context, snapshot) {
if (snapshot.hasData) {
final article = snapshot.data!;
final title =
const HtmlEscape().convert(article['title'] as String);
return InAppWebView(
initialData: InAppWebViewInitialData(
baseUrl: WebUri('https://www.ezijing.com/'),
data:
'<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width, initial-scale=1">'
'<style>body{margin:0;padding:20px;font-family:-apple-system,sans-serif;color:#253041;line-height:1.8;overflow-wrap:anywhere}'
'h1{font-size:22px;line-height:1.5}img,video{max-width:100%;height:auto}table{max-width:100%;display:block;overflow:auto}</style>'
'</head><body><h1>$title</h1>${article['content']}</body></html>',
),
initialSettings: InAppWebViewSettings(
applicationNameForUserAgent: 'ZijingApp',
sharedCookiesEnabled: true,
useShouldOverrideUrlLoading: true,
),
shouldOverrideUrlLoading: (controller, action) async {
final url = action.request.url;
final tappedLink =
action.navigationType == NavigationType.LINK_ACTIVATED ||
action.hasGesture == true;
if (tappedLink &&
action.isForMainFrame &&
url != null &&
(url.scheme == 'http' ||
url.scheme == 'https' ||
url.scheme == 'ezijing')) {
unawaited(openPortalLink(context, url.toString()));
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
},
);
}
return Center(
child: snapshot.hasError
? const Text('动态加载失败,请稍后再试')
: const CircularProgressIndicator());
},
),
);
}
import 'package:flutter/material.dart';
import '../../apis/product.dart';
import '../../models/portal_news.dart';
import 'news_detail.dart';
import 'product_widgets.dart';
class NewsListPage extends StatefulWidget {
const NewsListPage(
{super.key, this.category, this.embedded = false, this.title = '最新动态'});
final String? category;
final bool embedded;
final String title;
@override
State<NewsListPage> createState() => _NewsListPageState();
}
class _NewsListPageState extends State<NewsListPage> {
final _items = <PortalNews>[];
int _page = 0;
bool _loading = false;
bool _hasMore = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load({bool refresh = false}) async {
if (_loading) return;
setState(() => _loading = true);
try {
final page = refresh ? 1 : _page + 1;
final data = widget.category == null
? await ProductAPI.newsList(page)
: await ProductAPI.achievements(widget.category!, page);
if (!mounted) return;
setState(() {
if (refresh) _items.clear();
_items.addAll((data['list'] as List)
.map((item) => PortalNews.fromJson(item as Map<String, dynamic>)));
_page = page;
_hasMore = data['has_more'] as bool;
});
} catch (_) {
// API errors are displayed by the shared request client.
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: const Color(0xFFF8FAFC),
appBar: widget.embedded
? null
: AppBar(title: Text(widget.title), centerTitle: true),
body: RefreshIndicator(
onRefresh: () => _load(refresh: true),
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: _items.length + 1,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
if (index == _items.length) {
return Center(
child: _loading
? const Padding(
padding: EdgeInsets.all(20),
child: CircularProgressIndicator())
: _hasMore
? TextButton(
onPressed: _load, child: const Text('加载更多'))
: _items.isEmpty
? Text(
widget.category == null ? '暂无动态' : '暂无内容')
: const SizedBox.shrink());
}
final news = _items[index];
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) =>
NewsDetailPage(news: news, title: widget.title))),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (news.image.isNotEmpty)
AspectRatio(
aspectRatio: 16 / 9,
child:
PortalImage(url: news.image, fit: BoxFit.cover),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(news.title,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w500)),
if (news.summary.isNotEmpty) ...[
const SizedBox(height: 8),
Text(news.summary,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
height: 1.5,
color: Color(0xFF85858E))),
],
const SizedBox(height: 10),
Text(
DateTime.fromMillisecondsSinceEpoch(
news.startTime * 1000)
.toString()
.substring(0, 10),
style: const TextStyle(
fontSize: 12, color: Color(0xFF85858E))),
],
),
),
],
),
),
);
},
),
),
);
}
import 'package:flutter/material.dart';
import '../../models/product.dart';
import '../../services/app_link_navigator.dart';
import '../../services/unified_session.dart';
import '../../theme.dart';
import '../../widgets/portal_toast.dart';
import '../product_web/view.dart';
Future<void> openPortalWebPage(BuildContext context,
{required String url, required String title}) =>
Navigator.of(context, rootNavigator: true).push(MaterialPageRoute<void>(
builder: (_) => ProductWebPage(url: url, title: title)));
Future<void> openPortalProduct(BuildContext context, ProductModel product) =>
openPortalLink(context, product.target,
title: product.name, requireLogin: product.requireLogin);
Future<void> openPortalLink(BuildContext context, String url,
{String title = '', bool requireLogin = false}) async {
final target = url.trim();
if (target.isEmpty) {
PortalToast.show(context, '暂未开放');
return;
}
try {
final scheme = Uri.tryParse(target)?.scheme.toLowerCase();
if (scheme == 'ezijing') {
await AppLinkNavigator.open(context, target, requireLogin: requireLogin);
} else if (scheme == 'http' || scheme == 'https') {
if (requireLogin && !await UnifiedSession.instance.ensureLogin(context)) {
return;
}
if (!context.mounted) return;
await openPortalWebPage(context, url: target, title: title);
} else {
PortalToast.show(context, '当前版本不支持此入口');
}
} catch (_) {
if (context.mounted) {
PortalToast.show(context, '暂时无法打开,请重试', type: PortalToastType.error);
}
}
}
class PortalImage extends StatelessWidget {
const PortalImage(
{super.key,
required this.url,
this.icon = Icons.apps_rounded,
this.fit = BoxFit.cover});
final String url;
final IconData icon;
final BoxFit fit;
@override
Widget build(BuildContext context) => url.isEmpty
? Icon(icon, color: AppTheme.primary)
: Image.network(url,
fit: fit,
errorBuilder: (_, __, ___) => Icon(icon, color: AppTheme.primary));
}
class PortalProductTile extends StatelessWidget {
const PortalProductTile(
{super.key,
required this.product,
required this.onTap,
this.background = const Color(0xFFFCF3F6)});
final ProductModel product;
final VoidCallback onTap;
final Color background;
@override
Widget build(BuildContext context) => Material(
color: background,
borderRadius: BorderRadius.circular(14),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 18),
child:
Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
SizedBox(
width: 38,
height: 42,
child: PortalImage(url: product.icon, fit: BoxFit.contain)),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.name,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
height: 1.4,
color: Color(0xFF202733))),
if (product.description.isNotEmpty) ...[
const SizedBox(height: 5),
Text(product.description,
style: const TextStyle(
fontSize: 10,
height: 1.5,
color: Color(0xFF85858E)))
]
]))
]))));
}
import 'package:flutter/material.dart';
import '../../apis/product.dart';
import '../../models/product.dart';
import 'product_widgets.dart';
class ProductsPage extends StatefulWidget {
const ProductsPage({super.key});
@override
State<ProductsPage> createState() => _ProductsPageState();
}
class _ProductsPageState extends State<ProductsPage> {
late Future<ProductCatalog> _products = _load();
bool _opening = false;
Future<ProductCatalog> _load() async {
final data = await ProductAPI.products();
return ProductCatalog.fromJson(data);
}
Future<void> _refresh() async {
final pending = _load();
setState(() => _products = pending);
await pending;
}
Future<void> _open(ProductModel product) async {
if (_opening) return;
_opening = true;
try {
await openPortalProduct(context, product);
} finally {
_opening = false;
}
}
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: const Color(0xFFF8FAFC),
appBar: AppBar(
title: const Text('产品中心'),
automaticallyImplyLeading: false,
centerTitle: true),
body: RefreshIndicator(
onRefresh: _refresh,
child: FutureBuilder<ProductCatalog>(
future: _products,
builder: (context, snapshot) => ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: [
if (snapshot.connectionState == ConnectionState.waiting)
const Padding(
padding: EdgeInsets.all(40),
child: Center(child: CircularProgressIndicator()))
else if (snapshot.hasError)
const Center(child: Text('产品加载失败'))
else if (snapshot.data!.items.isEmpty)
const Center(child: Text('暂无产品'))
else
LayoutBuilder(builder: (context, constraints) {
final catalog = snapshot.data!;
final cards = catalog.style == 'cards';
final columns = cards ? 2 : 4;
final width =
(constraints.maxWidth - (columns - 1) * 12) / columns;
return Wrap(spacing: 12, runSpacing: 16, children: [
for (final product in catalog.items)
SizedBox(
width: width,
child: cards
? PortalProductTile(
product: product,
background: Colors.white,
onTap: () => _open(product))
: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _open(product),
child: Padding(
padding: const EdgeInsets.all(6),
child: Column(children: [
Container(
width: 60,
height: 60,
padding:
const EdgeInsets.all(8),
decoration: BoxDecoration(
color:
const Color(0xFFFCF3F6),
borderRadius:
BorderRadius.circular(
14)),
child: PortalImage(
url: product.icon,
fit: BoxFit.contain)),
const SizedBox(height: 8),
Text(product.name,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 11,
height: 1.5,
color: Color(0xFF343B47))),
])))),
]);
}),
],
),
)),
);
}
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../theme.dart';
class PortalShell extends StatelessWidget {
const PortalShell({super.key, required this.navigationShell});
final StatefulNavigationShell navigationShell;
@override
Widget build(BuildContext context) => Scaffold(
body: navigationShell,
bottomNavigationBar: BottomNavigationBar(
currentIndex: navigationShell.currentIndex,
onTap: (index) => navigationShell.goBranch(index),
selectedItemColor: AppTheme.primary,
unselectedItemColor: const Color(0xFF666666),
unselectedIconTheme: const IconThemeData(size: 28),
selectedIconTheme: const IconThemeData(size: 28),
backgroundColor: Colors.white,
type: BottomNavigationBarType.fixed,
items: [
const BottomNavigationBarItem(
icon: Icon(Icons.home_outlined),
activeIcon: Icon(Icons.home_rounded),
label: '首页'),
const BottomNavigationBarItem(
icon: Icon(Icons.widgets_outlined),
activeIcon: Icon(Icons.widgets_rounded),
label: '产品中心'),
const BottomNavigationBarItem(
icon: Icon(Icons.workspace_premium_outlined),
activeIcon: Icon(Icons.workspace_premium_rounded),
label: '成果中心'),
BottomNavigationBarItem(
icon: Image.asset('assets/images/mine.png',
width: 25, height: 27.5, fit: BoxFit.cover),
activeIcon: Image.asset('assets/images/mine_select.png',
width: 25, height: 27.5, fit: BoxFit.cover),
label: '我的'),
],
),
);
}
差异被折叠。
差异被折叠。
...@@ -33,7 +33,6 @@ class ResetPwdController extends GetxController { ...@@ -33,7 +33,6 @@ class ResetPwdController extends GetxController {
if (ValidatorTool.isValidPassword(pwdInput.text) && if (ValidatorTool.isValidPassword(pwdInput.text) &&
ValidatorTool.isValidPassword(pwd2Input.text) && ValidatorTool.isValidPassword(pwd2Input.text) &&
(pwdInput.text == pwd2Input.text)) { (pwdInput.text == pwd2Input.text)) {
Console.log('-------${pwdInput.text}-------------${pwd2Input.text}');
_enable = true; _enable = true;
} else { } else {
_enable = false; _enable = false;
......
...@@ -29,6 +29,11 @@ class _SplashPageState extends State<SplashPage> { ...@@ -29,6 +29,11 @@ class _SplashPageState extends State<SplashPage> {
await Future.delayed(const Duration(seconds: 2)); await Future.delayed(const Duration(seconds: 2));
if (!mounted || _navigated) return; if (!mounted || _navigated) return;
if (Routes.completeLaunch(context)) {
_navigated = true;
return;
}
final netStatus = await Tools.checkCurrentNetStatus(); final netStatus = await Tools.checkCurrentNetStatus();
if (!mounted || _navigated) return; if (!mounted || _navigated) return;
...@@ -43,7 +48,7 @@ class _SplashPageState extends State<SplashPage> { ...@@ -43,7 +48,7 @@ class _SplashPageState extends State<SplashPage> {
} }
_navigated = true; _navigated = true;
context.pushReplacementNamed(Routes.main); context.goNamed(Routes.home);
} }
Future<bool> _showPrivacyNoticeIfNeeded() async { Future<bool> _showPrivacyNoticeIfNeeded() async {
......
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../routes/index.dart';
import '../../services/app_session.dart';
import '../../services/unified_session.dart';
import '../../widgets/portal_toast.dart';
/// Defers protected page creation until a textbook session is available.
class TextbookGate extends StatefulWidget {
const TextbookGate({super.key, required this.child});
final Widget child;
@override
State<TextbookGate> createState() => _TextbookGateState();
}
class _TextbookGateState extends State<TextbookGate> {
bool _ready = false;
bool _loading = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _authorize());
}
Future<void> _authorize() async {
if (!mounted) return;
setState(() => _loading = true);
try {
final ready = await AppSession.ensureTextbookSession(context);
if (mounted) setState(() => _ready = ready);
} catch (error) {
if (mounted) {
PortalToast.show(context, UnifiedSession.errorMessage(error),
type: PortalToastType.error);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
if (_ready) return widget.child;
return Scaffold(
appBar: AppBar(title: const Text('数字教材')),
body: Center(
child: _loading
? const CircularProgressIndicator()
: Column(mainAxisSize: MainAxisSize.min, children: [
const Text('登录后继续'),
const SizedBox(height: 12),
FilledButton(
onPressed: _authorize, child: const Text('继续登录')),
TextButton(
onPressed: () => context.goNamed(Routes.home),
child: const Text('返回首页')),
])),
);
}
}
差异被折叠。
...@@ -7,7 +7,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; ...@@ -7,7 +7,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../routes/index.dart'; import '../../routes/index.dart';
import '../../store/index.dart'; import '../../services/app_session.dart';
import '../../utils/index.dart'; import '../../utils/index.dart';
import '../../widgets/index.dart'; import '../../widgets/index.dart';
......
...@@ -15,7 +15,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> { ...@@ -15,7 +15,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
centerTitle: true, centerTitle: true,
title: const Text('账号安全'), title: const Text('教材账号安全'),
), ),
body: Column( body: Column(
children: [ children: [
...@@ -40,7 +40,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> { ...@@ -40,7 +40,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> {
context.pushNamed(Routes.changePhone); context.pushNamed(Routes.changePhone);
}, },
child: _buildItem( child: _buildItem(
title: '修改手机号码', value: widget.model.phone ?? '')), title: '修改教材手机号', value: widget.model.phone ?? '')),
Container( Container(
margin: EdgeInsets.symmetric(horizontal: 10.w), margin: EdgeInsets.symmetric(horizontal: 10.w),
height: 0.5.w, height: 0.5.w,
...@@ -56,7 +56,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> { ...@@ -56,7 +56,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> {
extra: widget.model, queryParameters: {'type': type}); extra: widget.model, queryParameters: {'type': type});
}, },
child: _buildItem( child: _buildItem(
title: '修改密码', title: '修改教材密码',
value: widget.model.password.toString().isEmpty value: widget.model.password.toString().isEmpty
? '未设置' ? '未设置'
: '')), : '')),
...@@ -80,7 +80,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> { ...@@ -80,7 +80,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> {
child: Column( child: Column(
children: [ children: [
GestureDetector( GestureDetector(
child: _buildItem(title: '账号注销', value: ''), child: _buildItem(title: '注销教材账号', value: ''),
onTap: () async { onTap: () async {
showDialog( showDialog(
context: context, context: context,
...@@ -99,7 +99,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> { ...@@ -99,7 +99,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> {
// 调整上下间距 // 调整上下间距
child: Center( child: Center(
child: Text( child: Text(
'确定注销账号吗', '确定注销紫荆数智学堂账号吗?其他产品的账号不受影响。',
style: TextStyle( style: TextStyle(
fontSize: 15.w, fontSize: 15.w,
fontWeight: Fonts.medium, fontWeight: Fonts.medium,
...@@ -147,7 +147,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> { ...@@ -147,7 +147,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> {
final result = await AccountAPI.delete(); final result = await AccountAPI.delete();
if (result) { if (result) {
CustomToast.loading(); CustomToast.loading();
await UserStore.to.logout(); await AppSession.logout();
CustomToast.dismiss(); CustomToast.dismiss();
if (context.mounted) { if (context.mounted) {
context.goNamed(Routes.splash); context.goNamed(Routes.splash);
......
...@@ -13,7 +13,7 @@ import 'package:package_info_plus/package_info_plus.dart'; ...@@ -13,7 +13,7 @@ import 'package:package_info_plus/package_info_plus.dart';
import '../../apis/index.dart'; import '../../apis/index.dart';
import '../../models/index.dart'; import '../../models/index.dart';
import '../../routes/index.dart'; import '../../routes/index.dart';
import '../../store/index.dart'; import '../../services/app_session.dart';
import '../../widgets/index.dart'; import '../../widgets/index.dart';
part 'view.dart'; part 'view.dart';
......
...@@ -156,12 +156,10 @@ class _UserSetPageState extends State<UserSetPage> { ...@@ -156,12 +156,10 @@ class _UserSetPageState extends State<UserSetPage> {
GestureDetector( GestureDetector(
onTap: () async { onTap: () async {
try { try {
await AccountAPI.logout(); await AppSession.logout();
} finally { } finally {
await UserStore.to.logout();
await Tools.clearData();
if (context.mounted) { if (context.mounted) {
context.goNamed(Routes.splash); context.goNamed(Routes.home);
} }
} }
}, },
...@@ -193,10 +191,10 @@ class _UserSetPageState extends State<UserSetPage> { ...@@ -193,10 +191,10 @@ class _UserSetPageState extends State<UserSetPage> {
// if (result) { // if (result) {
// // Toast.show('退出成功'); // // Toast.show('退出成功');
// CustomToast.loading(); // CustomToast.loading();
// await UserStore.to.logout(); // await AppSession.logout();
// CustomToast.dismiss(); // CustomToast.dismiss();
// if (context.mounted) { // if (context.mounted) {
// context.goNamed(Routes.splash); // context.goNamed(Routes.home);
// } // }
// } // }
// }, // },
......
library routes; library routes;
import '../utils/app_mode.dart';
import '../pages/login/index.dart';
import '../pages/unified_login/view.dart';
import '../pages/forget_pwd/index.dart';
import '../pages/reset_pwd/index.dart';
import '../pages/product_home/view.dart';
import '../pages/product_home/account.dart';
import '../pages/product_home/shell.dart';
import '../pages/product_home/products.dart';
import '../pages/product_home/achievements.dart';
import '../pages/app_link/view.dart';
import '../utils/app_link.dart';
import '../pages/textbook_gate/view.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
...@@ -11,13 +25,10 @@ import 'package:flutter_book/pages/book_shop/index.dart'; ...@@ -11,13 +25,10 @@ import 'package:flutter_book/pages/book_shop/index.dart';
import 'package:flutter_book/pages/change_phone/index.dart'; import 'package:flutter_book/pages/change_phone/index.dart';
import 'package:flutter_book/pages/change_pwd/index.dart'; import 'package:flutter_book/pages/change_pwd/index.dart';
import 'package:flutter_book/pages/credit_points/index.dart'; import 'package:flutter_book/pages/credit_points/index.dart';
import 'package:flutter_book/pages/forget_pwd/index.dart';
import 'package:flutter_book/pages/help_center/index.dart'; import 'package:flutter_book/pages/help_center/index.dart';
import 'package:flutter_book/pages/help_cneter_content/index.dart'; import 'package:flutter_book/pages/help_cneter_content/index.dart';
import 'package:flutter_book/pages/link/index.dart'; import 'package:flutter_book/pages/link/index.dart';
import 'package:flutter_book/pages/login/index.dart';
import 'package:flutter_book/pages/main/index.dart'; import 'package:flutter_book/pages/main/index.dart';
import 'package:flutter_book/pages/reset_pwd/index.dart';
import 'package:flutter_book/pages/search/index.dart'; import 'package:flutter_book/pages/search/index.dart';
import 'package:flutter_book/pages/splash/index.dart'; import 'package:flutter_book/pages/splash/index.dart';
import 'package:flutter_book/pages/study_history/index.dart'; import 'package:flutter_book/pages/study_history/index.dart';
...@@ -46,7 +57,6 @@ import 'package:flutter_book/pages/user_set/index.dart'; ...@@ -46,7 +57,6 @@ import 'package:flutter_book/pages/user_set/index.dart';
import 'package:flutter_book/pages/user_wrong/index.dart'; import 'package:flutter_book/pages/user_wrong/index.dart';
import 'package:flutter_book/pages/user_wrong_des/index.dart'; import 'package:flutter_book/pages/user_wrong_des/index.dart';
import 'package:flutter_book/pages/version_des/index.dart'; import 'package:flutter_book/pages/version_des/index.dart';
import 'package:get/get.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../models/index.dart'; import '../models/index.dart';
...@@ -61,7 +71,6 @@ import '../pages/pay_coupon/index.dart'; ...@@ -61,7 +71,6 @@ import '../pages/pay_coupon/index.dart';
import '../pages/user_about/index.dart'; import '../pages/user_about/index.dart';
import '../pages/user_terms/index.dart'; import '../pages/user_terms/index.dart';
import '../pages/version/index.dart'; import '../pages/version/index.dart';
import '../store/index.dart';
part 'observers.dart'; part 'observers.dart';
part 'routes.dart'; part 'routes.dart';
......
...@@ -8,12 +8,26 @@ T _routeExtra<T>(GoRouterState state) { ...@@ -8,12 +8,26 @@ T _routeExtra<T>(GoRouterState state) {
return extra; return extra;
} }
int _initialTextbookTab(GoRouterState state) {
final tab = int.tryParse(state.uri.queryParameters['tab'] ?? '') ?? 0;
return tab >= 0 && tab <= 3 ? tab : 0;
}
abstract class Routes { abstract class Routes {
static final RouteObserver<Route> observer = RouteObservers(); static final RouteObserver<Route> observer = RouteObservers();
static final RouteObserver<Route> portalObserver = RouteObservers();
static List<String> history = []; static List<String> history = [];
static const splash = 'splash'; static const splash = 'splash';
static const main = 'main'; static const main = 'main';
static String get home => AppMode.portalEnabled ? productHome : main;
static const login = 'login';
static const forgetPwd = 'forget_pwd';
static const resetPwd = 'reset_pwd';
static const productHome = 'product_home';
static const portalAccount = 'portal_account';
static const portalProducts = 'portal_products';
static const portalAchievements = 'portal_achievements';
static const ad = 'ad'; static const ad = 'ad';
static const adDetail = 'ad_detail'; static const adDetail = 'ad_detail';
...@@ -28,11 +42,8 @@ abstract class Routes { ...@@ -28,11 +42,8 @@ abstract class Routes {
/// 登录模块 /// 登录模块
// 登录 // 登录
static const login = 'login';
// 忘记密码 // 忘记密码
static const forgetPwd = 'forget_pwd';
// 重置密码 // 重置密码
static const resetPwd = 'reset_pwd';
/// 课程模块 /// 课程模块
// 详情 // 详情
...@@ -132,14 +143,55 @@ abstract class Routes { ...@@ -132,14 +143,55 @@ abstract class Routes {
static final GlobalKey<NavigatorState> navigatorKey = static final GlobalKey<NavigatorState> navigatorKey =
GlobalKey<NavigatorState>(); GlobalKey<NavigatorState>();
static bool _launchComplete = false;
static String? _pendingAppLink;
static String _appLinkLocation(String target) =>
Uri(path: '/app-link', queryParameters: {'target': target}).toString();
/// Called only after SplashPage has completed the user's privacy decision.
static bool completeLaunch(BuildContext context) {
_launchComplete = true;
final target = _pendingAppLink;
_pendingAppLink = null;
if (target == null) return false;
context.go(_appLinkLocation(target));
return true;
}
static String? _redirectAppLink(BuildContext context, GoRouterState state) {
final uri = state.uri;
if (uri.scheme == 'ezijing') {
final target = uri.toString();
if (AppLink.parse(target) == null) {
return _launchComplete ? '/' : '/$splash';
}
if (!_launchComplete) {
_pendingAppLink = target;
return '/$splash';
}
return _appLinkLocation(target);
}
// A bridge route can also be restored by the OS; never bypass privacy.
if (uri.path == '/app-link' && !_launchComplete) {
final target = uri.queryParameters['target'];
if (target != null && AppLink.parse(target) != null) {
_pendingAppLink = target;
}
return '/$splash';
}
return null;
}
static final GoRouter config = GoRouter( static final GoRouter config = GoRouter(
navigatorKey: navigatorKey, navigatorKey: navigatorKey,
initialLocation: '/$splash', initialLocation: '/$splash',
redirect: _redirectAppLink,
errorBuilder: (context, state) => Scaffold( errorBuilder: (context, state) => Scaffold(
appBar: AppBar(title: const Text('页面无法打开')), appBar: AppBar(title: const Text('页面无法打开')),
body: Center( body: Center(
child: TextButton( child: TextButton(
onPressed: () => context.goNamed(main), onPressed: () => context.goNamed(home),
child: const Text('返回首页'), child: const Text('返回首页'),
), ),
), ),
...@@ -149,6 +201,16 @@ abstract class Routes { ...@@ -149,6 +201,16 @@ abstract class Routes {
observer observer
], ],
routes: [ routes: [
GoRoute(
path: '/app-link',
redirect: (context, state) {
final target = state.uri.queryParameters['target'];
return target != null && AppLink.parse(target) != null ? null : '/';
},
builder: (context, state) => AppLinkPage(
key: ValueKey(state.uri.queryParameters['target']),
target: state.uri.queryParameters['target']!),
),
GoRoute( GoRoute(
path: '/$splash', path: '/$splash',
name: splash, name: splash,
...@@ -158,13 +220,62 @@ abstract class Routes { ...@@ -158,13 +220,62 @@ abstract class Routes {
child: const SplashPage(), child: const SplashPage(),
), ),
), ),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) =>
PortalShell(navigationShell: navigationShell),
branches: [
StatefulShellBranch(observers: [
portalObserver
], routes: [
GoRoute( GoRoute(
path: '/', path: '/',
name: productHome,
redirect: (context, state) =>
AppMode.portalEnabled ? null : '/textbook',
builder: (context, state) => const ProductHomePage(),
),
]),
StatefulShellBranch(routes: [
GoRoute(
path: '/products',
name: portalProducts,
redirect: (context, state) =>
AppMode.portalEnabled ? null : '/textbook',
builder: (context, state) => const ProductsPage(),
),
]),
StatefulShellBranch(routes: [
GoRoute(
path: '/achievements',
name: portalAchievements,
redirect: (context, state) =>
AppMode.portalEnabled ? null : '/textbook',
builder: (context, state) => const AchievementsPage(),
),
]),
StatefulShellBranch(routes: [
GoRoute(
path: '/account',
name: portalAccount,
redirect: (context, state) =>
AppMode.portalEnabled ? null : '/textbook?tab=3',
builder: (context, state) => const PortalAccountPage(),
),
]),
],
),
GoRoute(
path: '/textbook',
parentNavigatorKey: navigatorKey,
name: main, name: main,
pageBuilder: (context, state) => CupertinoPage( pageBuilder: (context, state) => CupertinoPage(
name: state.uri.toString(), name: state.uri.toString(),
key: state.pageKey, key: state.pageKey,
child: const MainPage())), child: _initialTextbookTab(state) == 0
? const MainPage()
: TextbookGate(
child: MainPage(
initialPage: _initialTextbookTab(state))))),
GoRoute( GoRoute(
path: '/$ad', path: '/$ad',
name: ad, name: ad,
...@@ -182,7 +293,9 @@ abstract class Routes { ...@@ -182,7 +293,9 @@ abstract class Routes {
pageBuilder: (context, state) => CupertinoPage( pageBuilder: (context, state) => CupertinoPage(
name: state.uri.toString(), name: state.uri.toString(),
key: state.pageKey, key: state.pageKey,
child: const LoginPage())), child: AppMode.portalEnabled
? const UnifiedLoginPage()
: const LoginPage())),
GoRoute( GoRoute(
path: '/$forgetPwd', path: '/$forgetPwd',
name: forgetPwd, name: forgetPwd,
...@@ -286,13 +399,14 @@ abstract class Routes { ...@@ -286,13 +399,14 @@ abstract class Routes {
GoRoute( GoRoute(
path: '/$bookDetail', path: '/$bookDetail',
name: bookDetail, name: bookDetail,
parentNavigatorKey: navigatorKey,
pageBuilder: (context, state) => CupertinoPage( pageBuilder: (context, state) => CupertinoPage(
name: state.uri.toString(), name: state.uri.toString(),
key: state.pageKey, key: state.pageKey,
child: TextbookGate(
child: BookDetailPage( child: BookDetailPage(
bookId: state.uri.queryParameters['book_id'].toString(), bookId: state.uri.queryParameters['book_id'].toString(),
)), ))),
redirect: (context, state) => _RouteRedirect.auth(),
), ),
GoRoute( GoRoute(
path: '/$bookScore', path: '/$bookScore',
...@@ -482,7 +596,6 @@ abstract class Routes { ...@@ -482,7 +596,6 @@ abstract class Routes {
child: UserDiscussDesPage( child: UserDiscussDesPage(
model: _routeExtra<CourseModel>(state), model: _routeExtra<CourseModel>(state),
))), ))),
GoRoute( GoRoute(
path: '/$orderEvaluate', path: '/$orderEvaluate',
name: orderEvaluate, name: orderEvaluate,
...@@ -644,12 +757,3 @@ abstract class Routes { ...@@ -644,12 +757,3 @@ abstract class Routes {
))), ))),
]); ]);
} }
abstract class _RouteRedirect {
static String? auth() {
final userStore =
Get.isRegistered<UserStore>() ? Get.find<UserStore>() : null;
if (userStore?.isLogin ?? false) return null;
return '/${Routes.login}';
}
}
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../routes/index.dart';
import '../utils/app_link.dart';
import 'app_session.dart';
import '../widgets/portal_toast.dart';
abstract class AppLinkNavigator {
static Future<bool> open(BuildContext context, String value,
{bool requireLogin = false,
bool replace = false,
int initialTab = 0,
Future<void> Function()? onOpen}) async {
final link = AppLink.parse(value);
if (link == null) {
if (context.mounted) PortalToast.show(context, '当前版本不支持此应用链接');
return false;
}
final tab = link.tab ?? initialTab;
if (link.destination == 'book_detail' ||
(link.destination == 'textbook' && (requireLogin || tab != 0))) {
if (!await AppSession.ensureTextbookSession(context)) return false;
} else if (requireLogin && !await AppSession.ensureLogin(context)) {
return false;
}
if (!context.mounted) return false;
await onOpen?.call();
if (!context.mounted) return false;
if (link.destination == 'products') {
context.goNamed(Routes.portalProducts);
return true;
}
if (link.destination == 'home') {
context.goNamed(Routes.home);
return true;
}
final route =
link.destination == 'textbook' ? Routes.main : Routes.bookDetail;
final parameters = link.destination == 'textbook'
? <String, String>{'tab': '$tab'}
: {'book_id': link.bookId!};
if (replace) {
context.pushReplacementNamed(route, queryParameters: parameters);
} else {
await context.pushNamed(route, queryParameters: parameters);
}
return true;
}
}
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../apis/index.dart';
import '../routes/index.dart';
import '../store/index.dart';
import '../utils/app_mode.dart';
import 'unified_session.dart';
/// Selects the login flow for the edition being built.
abstract class AppSession {
static Future<bool> ensureLogin(BuildContext context) async {
if (AppMode.portalEnabled) {
return UnifiedSession.instance.ensureLogin(context);
}
if (UserStore.to.isLogin) return true;
await context.pushNamed(Routes.login);
return context.mounted && UserStore.to.isLogin;
}
static Future<bool> ensureTextbookSession(BuildContext context) =>
AppMode.portalEnabled
? UnifiedSession.instance.ensureTextbookSession(context)
: ensureLogin(context);
static Future<void> logout() async {
if (AppMode.portalEnabled) {
await UnifiedSession.instance.logout();
return;
}
try {
await AccountAPI.logout();
} finally {
await UserStore.to.logout();
}
}
}
...@@ -58,15 +58,21 @@ class HttpService extends GetxService { ...@@ -58,15 +58,21 @@ class HttpService extends GetxService {
CancelToken? cancelToken, CancelToken? cancelToken,
bool excludeToken = false, bool excludeToken = false,
bool showLoading = false, bool showLoading = false,
bool sensitive = false,
}) async { }) async {
if (showLoading) CustomToast.loading(); if (showLoading) CustomToast.loading();
try { try {
final requestOptions = options ?? Options(); final requestOptions = options ?? Options();
if (sensitive) {
requestOptions.extra = {...?requestOptions.extra, 'sensitive': true};
}
requestOptions.headers = requestOptions.headers =
_getHeaders(excludeToken: excludeToken, params: params, url: url); _getHeaders(excludeToken: excludeToken, params: params, url: url);
if (!sensitive) {
Console.log( Console.log(
'GET $url\nheaders: ${requestOptions.headers}\nparams: $params', 'GET $url\nheaders: ${requestOptions.headers}\nparams: $params',
); );
}
final response = await _dio.get( final response = await _dio.get(
url, url,
...@@ -74,7 +80,7 @@ class HttpService extends GetxService { ...@@ -74,7 +80,7 @@ class HttpService extends GetxService {
options: requestOptions, options: requestOptions,
cancelToken: cancelToken, cancelToken: cancelToken,
); );
Console.log('GET $url response: ${response.data}'); if (!sensitive) Console.log('GET $url response: ${response.data}');
if (showLoading) CustomToast.dismiss(); if (showLoading) CustomToast.dismiss();
return ResponseModel.fromJson(response.data as Map<String, dynamic>); return ResponseModel.fromJson(response.data as Map<String, dynamic>);
} catch (error) { } catch (error) {
...@@ -91,22 +97,28 @@ class HttpService extends GetxService { ...@@ -91,22 +97,28 @@ class HttpService extends GetxService {
CancelToken? cancelToken, CancelToken? cancelToken,
bool excludeToken = false, bool excludeToken = false,
bool showLoading = false, bool showLoading = false,
bool sensitive = false,
}) async { }) async {
if (showLoading) CustomToast.loading(); if (showLoading) CustomToast.loading();
try { try {
final requestOptions = options ?? Options(); final requestOptions = options ?? Options();
if (sensitive) {
requestOptions.extra = {...?requestOptions.extra, 'sensitive': true};
}
requestOptions.headers = requestOptions.headers =
_getHeaders(excludeToken: excludeToken, params: params, url: url); _getHeaders(excludeToken: excludeToken, params: params, url: url);
if (!sensitive) {
Console.log( Console.log(
'POST $url\nheaders: ${requestOptions.headers}\nparams: $params', 'POST $url\nheaders: ${requestOptions.headers}\nparams: $params',
); );
}
final response = await _dio.post( final response = await _dio.post(
url, url,
data: params, data: params,
options: requestOptions, options: requestOptions,
cancelToken: cancelToken, cancelToken: cancelToken,
); );
Console.log('POST $url response: ${response.data}'); if (!sensitive) Console.log('POST $url response: ${response.data}');
if (showLoading) CustomToast.dismiss(); if (showLoading) CustomToast.dismiss();
return ResponseModel.fromJson(response.data as Map<String, dynamic>); return ResponseModel.fromJson(response.data as Map<String, dynamic>);
} catch (error) { } catch (error) {
...@@ -193,7 +205,11 @@ class _RequestInterceptor extends Interceptor { ...@@ -193,7 +205,11 @@ class _RequestInterceptor extends Interceptor {
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) async { void onError(DioException err, ErrorInterceptorHandler handler) async {
// Console.log(err.type); if (err.requestOptions.extra['silent'] == true &&
err.requestOptions.extra['skipAuthRefresh'] == true) {
handler.reject(err);
return;
}
switch (err.type) { switch (err.type) {
case DioExceptionType.connectionTimeout: case DioExceptionType.connectionTimeout:
CustomToast.fail('网络连接超时'); CustomToast.fail('网络连接超时');
...@@ -215,9 +231,11 @@ class _RequestInterceptor extends Interceptor { ...@@ -215,9 +231,11 @@ class _RequestInterceptor extends Interceptor {
final code = int.tryParse(body?['code']?.toString() ?? ''); final code = int.tryParse(body?['code']?.toString() ?? '');
final effectiveStatusCode = final effectiveStatusCode =
code == 401 || code == 403 ? code : statusCode; code == 401 || code == 403 ? code : statusCode;
if (err.requestOptions.extra['sensitive'] != true) {
Console.log( Console.log(
'HTTP error $statusCode ${err.requestOptions.path}\nresponse: ${response?.data}', 'HTTP error $statusCode ${err.requestOptions.path}\nresponse: ${response?.data}',
); );
}
var msg = '服务器错误'; var msg = '服务器错误';
switch (effectiveStatusCode) { switch (effectiveStatusCode) {
case 401: case 401:
...@@ -226,7 +244,19 @@ class _RequestInterceptor extends Interceptor { ...@@ -226,7 +244,19 @@ class _RequestInterceptor extends Interceptor {
handler.reject(err); handler.reject(err);
return; return;
} }
final newToken = await UserStore.to.refreshToken(); final user = UserStore.to;
final epoch = user.sessionEpoch;
final requestToken =
err.requestOptions.headers['token']?.toString() ?? '';
if (requestToken.isEmpty || requestToken != user.token) {
handler.reject(err);
return;
}
final newToken = await user.refreshToken();
if (epoch != user.sessionEpoch) {
handler.reject(err);
return;
}
if (newToken != null && newToken.isNotEmpty) { if (newToken != null && newToken.isNotEmpty) {
final RequestOptions requestOptions = err.requestOptions; final RequestOptions requestOptions = err.requestOptions;
final headers = requestOptions.headers; final headers = requestOptions.headers;
...@@ -247,7 +277,7 @@ class _RequestInterceptor extends Interceptor { ...@@ -247,7 +277,7 @@ class _RequestInterceptor extends Interceptor {
handler.resolve(newResponse); handler.resolve(newResponse);
return; return;
} on DioException catch (retryError) { } on DioException catch (retryError) {
if (_isUnauthorized(retryError)) { if (_isUnauthorized(retryError) && epoch == user.sessionEpoch) {
await _expireSession(); await _expireSession();
} }
handler.reject(retryError); handler.reject(retryError);
......
差异被折叠。
...@@ -7,6 +7,7 @@ import 'package:flutter_book/services/index.dart'; ...@@ -7,6 +7,7 @@ import 'package:flutter_book/services/index.dart';
import 'package:flutter_book/theme.dart'; import 'package:flutter_book/theme.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../utils/app_mode.dart';
import '../models/index.dart'; import '../models/index.dart';
import '../utils/index.dart'; import '../utils/index.dart';
......
...@@ -13,6 +13,15 @@ class UserStore extends GetxController { ...@@ -13,6 +13,15 @@ class UserStore extends GetxController {
final RefreshTokenRequest? _refreshTokenRequest; final RefreshTokenRequest? _refreshTokenRequest;
void Function()? onSessionExpired; void Function()? onSessionExpired;
int _sessionEpoch = 0;
int get sessionEpoch => _sessionEpoch;
Future<void> _sessionWrites = Future.value();
Future<void> _writeSession(Future<void> Function() action) {
final pending = _sessionWrites.then((_) => action());
_sessionWrites = pending.catchError((Object _) {});
return pending;
}
// 是否登录 // 是否登录
bool _isLogin = false; bool _isLogin = false;
...@@ -38,33 +47,34 @@ class UserStore extends GetxController { ...@@ -38,33 +47,34 @@ class UserStore extends GetxController {
super.onInit(); super.onInit();
_token = StorageService.to.getString(kLocalToken); _token = StorageService.to.getString(kLocalToken);
_accessToken = StorageService.to.getString(kLocalAccessToken); _accessToken = StorageService.to.getString(kLocalAccessToken);
if (!AppMode.portalEnabled && hasToken) {
_isLogin = true;
final cached = StorageService.to.getString(kLocalUserInfo);
if (cached.isNotEmpty) {
_info = UserModel.fromJson(jsonDecode(cached));
}
}
} }
/// 登录 /// 登录
Future<void> login(UserModel value) async { Future<void> login(UserModel value) async {
_token = value.token ?? ''; final epoch = ++_sessionEpoch;
_accessToken = value.accessToken ?? ''; _refreshingToken = null;
await StorageService.to.setString(kLocalToken, _token); await _writeSession(() async {
if (epoch != _sessionEpoch) return;
await StorageService.to.setString(kLocalToken, value.token ?? '');
await StorageService.to await StorageService.to
.setString(kLocalAccessToken, value.accessToken ?? ''); .setString(kLocalAccessToken, value.accessToken ?? '');
await StorageService.to await StorageService.to
.setString(kLocalUserInfo, jsonEncode(value.toJson())); .setString(kLocalUserInfo, jsonEncode(value.toJson()));
if (epoch != _sessionEpoch) return;
_token = value.token ?? '';
_accessToken = value.accessToken ?? '';
_sessionExpired = false; _sessionExpired = false;
_info = value; _info = value;
_isLogin = true; _isLogin = true;
update(); update();
} });
/// 设置token
Future<void> setToken(String value) async {
await StorageService.to.setString(kLocalToken, value);
_token = value;
}
/// 设置刷新token
Future<void> setAccessToken(String value) async {
await StorageService.to.setString(kLocalAccessToken, value);
_accessToken = value;
} }
/// 设置用户信息 /// 设置用户信息
...@@ -92,25 +102,36 @@ class UserStore extends GetxController { ...@@ -92,25 +102,36 @@ class UserStore extends GetxController {
} }
Future<void> _clearSession() async { Future<void> _clearSession() async {
_sessionEpoch++;
_refreshingToken = null;
_token = ''; _token = '';
_accessToken = ''; _accessToken = '';
_isLogin = false; _isLogin = false;
_info = UserModel();
update();
await _writeSession(() async {
await Future.wait([ await Future.wait([
StorageService.to.remove(kLocalToken), StorageService.to.remove(kLocalToken),
StorageService.to.remove(kLocalAccessToken), StorageService.to.remove(kLocalAccessToken),
StorageService.to.remove(kLocalUserInfo), StorageService.to.remove(kLocalUserInfo),
]); ]);
});
} }
Future<String?>? _refreshingToken; Future<String?>? _refreshingToken;
/// 多个请求同时过期时,共用同一次 token 刷新。 /// 多个请求同时过期时,共用同一次 token 刷新。
Future<String?> refreshToken() => Future<String?> refreshToken() {
_refreshingToken ??= _requestNewToken().whenComplete(() { if (_refreshingToken != null) return _refreshingToken!;
_refreshingToken = null; final pending = _requestNewToken();
_refreshingToken = pending;
return pending.whenComplete(() {
if (identical(_refreshingToken, pending)) _refreshingToken = null;
}); });
}
Future<String?> _requestNewToken() async { Future<String?> _requestNewToken() async {
final epoch = _sessionEpoch;
final accessToken = _accessToken.isNotEmpty final accessToken = _accessToken.isNotEmpty
? _accessToken ? _accessToken
: StorageService.to.getString(kLocalAccessToken); : StorageService.to.getString(kLocalAccessToken);
...@@ -128,20 +149,20 @@ class UserStore extends GetxController { ...@@ -128,20 +149,20 @@ class UserStore extends GetxController {
final newAccessToken = data['access_token'] as String; final newAccessToken = data['access_token'] as String;
if (newToken.isEmpty) return null; if (newToken.isEmpty) return null;
await setToken(newToken); await _writeSession(() async {
if (epoch != _sessionEpoch) return;
await StorageService.to.setString(kLocalToken, newToken);
if (newAccessToken.isNotEmpty) { if (newAccessToken.isNotEmpty) {
await setAccessToken(newAccessToken); await StorageService.to.setString(kLocalAccessToken, newAccessToken);
} }
return newToken; if (epoch != _sessionEpoch) return;
_token = newToken;
if (newAccessToken.isNotEmpty) _accessToken = newAccessToken;
});
return epoch == _sessionEpoch ? newToken : null;
} on Exception catch (error, stackTrace) { } on Exception catch (error, stackTrace) {
Console.log('刷新 token 失败: $error\n$stackTrace'); Console.log('刷新 token 失败: $error\n$stackTrace');
return null; return null;
} }
} }
Future<void> profile() async {
if (!hasToken) return;
_isLogin = true;
update();
}
} }
/// Resolves application destinations and their required parameters.
class AppLink {
const AppLink._(this.destination, {this.bookId, this.tab});
final String destination;
final String? bookId;
final int? tab;
static AppLink? parse(String value) {
final uri = Uri.tryParse(value);
if (uri == null || uri.scheme.toLowerCase() != 'ezijing') return null;
final isRoot = uri.path.isEmpty || uri.path == '/';
if (uri.host == 'home' && isRoot) {
return const AppLink._('home');
}
if (uri.host == 'products' && isRoot) {
return const AppLink._('products');
}
if (uri.host == 'textbook' && isRoot) {
final tab = uri.queryParameters['tab'];
if (tab == null) return const AppLink._('textbook');
final index = const {
'library': 0,
'course': 1,
'bookshelf': 2,
'mine': 3,
}[tab];
return index == null ? null : AppLink._('textbook', tab: index);
}
if (uri.host == 'book' && uri.path == '/detail') {
final id = uri.queryParameters['book_id'];
if (id != null && RegExp(r'^[1-9][0-9]*$').hasMatch(id)) {
return AppLink._('book_detail', bookId: id);
}
}
return null;
}
}
/// Selected when building the app; defaults to the company portal edition.
abstract class AppMode {
static const portalEnabled =
bool.fromEnvironment('PORTAL_ENABLED', defaultValue: true);
}
part of utils; part of utils;
// App 接口地址。H5 接口地址由 H5 自己配置。 // App 接口地址。H5 接口地址由 H5 自己配置。
const String kServerUrl = 'https://project-api.ezijing.com/api/book'; const String kServerUrl = kDebugMode
// const String kServerUrl = 'http://172.16.51.175:7421'; ? 'http://172.16.51.157:7421'
: 'https://project-api.ezijing.com/api/book';
// 统一登录服务地址。
const String kPassportApiBase = 'https://project-api.ezijing.com/api/passport';
const String kUserCenterApiBase =
'https://project-api.ezijing.com/api/usercenter';
const String kSsoCookieOrigin = 'https://project-api.ezijing.com';
const String kHtmlBaseServer = 'https://webapp-pub.ezijing.com/book-app'; const String kHtmlBaseServer = 'https://webapp-pub.ezijing.com/book-app';
const String kLocalToken = 'local_token'; const String kLocalToken = 'local_token';
const String kLocalAccessToken = 'local_access_token'; const String kLocalAccessToken = 'local_access_token';
......
...@@ -5,6 +5,7 @@ class CustomAppBar extends AppBar { ...@@ -5,6 +5,7 @@ class CustomAppBar extends AppBar {
Key? key, Key? key,
bool automaticallyImplyLeading = true, bool automaticallyImplyLeading = true,
Widget? title, Widget? title,
Widget? leading,
List<Widget>? actions, List<Widget>? actions,
bool? centerTitle, bool? centerTitle,
double? titleSpacing, double? titleSpacing,
...@@ -13,6 +14,7 @@ class CustomAppBar extends AppBar { ...@@ -13,6 +14,7 @@ class CustomAppBar extends AppBar {
}) : super( }) : super(
key: key, key: key,
title: title, title: title,
leading: leading,
titleSpacing: titleSpacing, titleSpacing: titleSpacing,
systemOverlayStyle: systemOverlayStyle, systemOverlayStyle: systemOverlayStyle,
automaticallyImplyLeading: automaticallyImplyLeading, automaticallyImplyLeading: automaticallyImplyLeading,
......
import 'package:flutter/material.dart';
import '../models/company_user.dart';
import '../theme.dart';
class CompanyAvatar extends StatelessWidget {
const CompanyAvatar({super.key, this.user, this.size = 40});
final CompanyUser? user;
final double size;
@override
Widget build(BuildContext context) {
final fallback = ColoredBox(
color: const Color(0xFFF3E7EC),
child: Icon(
user == null ? Icons.person_outline_rounded : Icons.person_rounded,
color: AppTheme.primary,
size: size * .6),
);
final url = user?.avatar ?? '';
return ClipOval(
child: SizedBox.square(
dimension: size,
child: url.isEmpty
? fallback
: Image.network(url,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => fallback)));
}
}
import 'dart:async';
import 'package:flutter/material.dart';
enum PortalToastType { success, error, info }
/// A compact centered toast used by the product portal and its web pages.
abstract class PortalToast {
static OverlayEntry? _entry;
static Timer? _timer;
static void show(BuildContext context, String message,
{PortalToastType type = PortalToastType.info}) {
final overlay = Overlay.of(context, rootOverlay: true);
_timer?.cancel();
_entry?.remove();
final (icon, color) = switch (type) {
PortalToastType.success => (Icons.check_rounded, Colors.white),
PortalToastType.error => (Icons.close_rounded, Colors.white),
PortalToastType.info => (Icons.info_outline_rounded, Colors.white),
};
final entry = OverlayEntry(
builder: (_) => Positioned.fill(
child: IgnorePointer(
child: Center(
child: Material(
color: Colors.transparent,
child: Container(
constraints: const BoxConstraints(
minWidth: 128, minHeight: 112, maxWidth: 220),
padding: const EdgeInsets.symmetric(
horizontal: 18, vertical: 16),
decoration: BoxDecoration(
color: const Color(0xE6454545),
borderRadius: BorderRadius.circular(12)),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 38, color: color),
const SizedBox(height: 10),
Text(message,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
height: 1.3))
])))))));
_entry = entry;
overlay.insert(entry);
_timer = Timer(const Duration(milliseconds: 1300), () {
if (identical(_entry, entry)) {
entry.remove();
_entry = null;
_timer = null;
}
});
}
}
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../routes/index.dart';
import '../utils/app_mode.dart';
/// Returns from textbook pages to the previous page or the product homepage.
class ProductHomeButton extends StatelessWidget {
const ProductHomeButton({super.key});
@override
Widget build(BuildContext context) => !AppMode.portalEnabled
? const SizedBox.shrink()
: IconButton(
tooltip: '返回',
icon: const Icon(CupertinoIcons.back, size: 21),
onPressed: () async {
final navigator = Navigator.of(context);
if (await navigator.maybePop()) return;
if (context.mounted) Routes.config.goNamed(Routes.home);
},
);
}
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论