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

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

上级 928f5fa3
......@@ -53,4 +53,5 @@ app.*.map.json
.asc/
signing/
.agents/
\ No newline at end of file
.agents/
/output/
\ No newline at end of file
......@@ -23,6 +23,13 @@
android:name="io.flutter.embedding.android.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>
<action android:name="android.intent.action.MAIN"/>
<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 @@
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>FlutterDeepLinkingEnabled</key>
<true/>
<key>CFBundleURLTypes</key>
<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>
<key>CFBundleTypeRole</key>
<string>Editor</string>
......
......@@ -28,7 +28,11 @@ abstract class AccountAPI {
params['uuid'] = _getUUID();
}
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));
}
......@@ -56,6 +60,8 @@ abstract class AccountAPI {
}) async {
final result = await HttpService.to.post(
'/v1/members/login/sendCode',
sensitive: true,
options: Options(extra: {'skipAuthRefresh': true}),
params: {
'phone': phone,
'types': type,
......@@ -76,6 +82,8 @@ abstract class AccountAPI {
}) async {
final result = await HttpService.to.post(
'/v1/members/login/editPassword',
sensitive: true,
options: Options(extra: {'skipAuthRefresh': true}),
params: {
'phone': phone,
'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,17 +12,13 @@ import 'package:oktoast/oktoast.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'global.dart';
import 'utils/app_mode.dart';
void main() {
Global.init().then((_) {
Future.wait([
UserStore.to.profile(),
]).whenComplete(() {
// 如果需要 ensureInitialized,请在这里运行。
WidgetsFlutterBinding.ensureInitialized();
UserStore.to.onSessionExpired = () => Routes.config.goNamed(Routes.login);
runApp(const MyApp());
});
UserStore.to.onSessionExpired = () => Routes.config
.goNamed(AppMode.portalEnabled ? Routes.productHome : Routes.login);
runApp(const MyApp());
});
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 {
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() {
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
......@@ -23,7 +37,7 @@ class AdController extends GetxController {
} else {
_timer.cancel();
// context.pushReplacementNamed(Routes.splash);
context.goNamed(Routes.main);
context.goNamed(Routes.home);
}
});
}
......
......@@ -18,9 +18,7 @@ class _AdPageState extends State<AdPage> {
fit: StackFit.expand,
children: [
GestureDetector(
onTap: () {
context.pushNamed(Routes.adDetail, extra: widget.ads.first);
},
onTap: () => controller.openBanner(widget.ads.first),
child: Container(
color: Colors.green,
child: CustomImage.network(url: widget.ads.first.pic ?? ''),
......@@ -31,7 +29,7 @@ class _AdPageState extends State<AdPage> {
right: 20.w,
child: GestureDetector(
onTap: () {
context.goNamed(Routes.main);
context.goNamed(Routes.home);
},
child: Container(
padding: EdgeInsets.symmetric(
......
library ad_detail;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import '../../models/index.dart';
import '../../utils/app_link.dart';
import '../../services/app_link_navigator.dart';
part 'view.dart';
......@@ -9,7 +9,56 @@ class AdDetailPage extends StatefulWidget {
}
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) {
final value = title?.trim() ?? '';
......@@ -22,6 +71,18 @@ class _AdDetailPageState extends State<AdDetailPage> {
@override
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(
appBar: AppBar(title: Text(_pageTitle)),
body: InAppWebView(
......@@ -31,7 +92,28 @@ class _AdDetailPageState extends State<AdDetailPage> {
),
initialSettings: InAppWebViewSettings(
// 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 {
_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;
import 'package:decimal/decimal.dart';
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import '../../widgets/product_home_button.dart';
import 'package:flutter_book/utils/index.dart';
import 'package:flutter_book/widgets/index.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
......
......@@ -14,6 +14,7 @@ class _BookShopPageState extends State<BookShopPage> {
init: BookshopController(),
builder: (controller) => Scaffold(
appBar: AppBar(
leading: const ProductHomeButton(),
title: const Text('书架'),
),
body: Column(
......
......@@ -3,6 +3,7 @@ library course;
import 'package:carousel_slider/carousel_slider.dart';
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import '../../widgets/product_home_button.dart';
import 'package:flutter_book/theme.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
......
......@@ -14,6 +14,7 @@ class _CoursePageState extends State<CoursePage> {
init: CourseController(),
builder: (controller) => Scaffold(
appBar: CustomAppBar(
leading: const ProductHomeButton(),
titleSpacing: 0,
title: Padding(
padding: EdgeInsets.symmetric(horizontal: AppTheme.margin),
......
......@@ -4,6 +4,7 @@ import 'dart:convert';
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import '../../widgets/product_home_button.dart';
import 'package:flutter_book/apis/index.dart';
import 'package:flutter_book/theme.dart';
import 'package:flutter_book/utils/index.dart';
......@@ -14,6 +15,7 @@ import 'package:go_router/go_router.dart';
import '../../models/index.dart';
import '../../routes/index.dart';
import '../../store/index.dart';
import '../../services/unified_session.dart';
import '../../widgets/index.dart';
import '../course/index.dart';
......
......@@ -14,6 +14,7 @@ class _LibraryPageState extends State<LibraryPage> {
init: LibraryController(context),
builder: (controller) => Scaffold(
appBar: AppBar(
leading: const ProductHomeButton(),
title: const Text('图书馆'),
actions: [
IconButton(
......
......@@ -33,7 +33,12 @@ class LibraryContentPage extends StatelessWidget {
itemBuilder: (BuildContext context, int index) {
final model = controller.books[index];
return GestureDetector(
onTap: () {
onTap: () async {
if (!UserStore.to.isLogin &&
!await UnifiedSession.instance.ensureTextbookSession(context)) {
return;
}
if (!context.mounted) return;
context.pushNamed(
Routes.bookDetail,
queryParameters: {'book_id': model.bookId.toString()},
......@@ -43,8 +48,9 @@ class LibraryContentPage extends StatelessWidget {
model: model,
onTap: () async {
if (!UserStore.to.isLogin) {
context.pushNamed(Routes.login);
return;
final ready = await UnifiedSession.instance
.ensureTextbookSession(context);
if (!ready || !context.mounted) return;
}
if (!controller.canTap) return;
......
library link;
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
......@@ -8,6 +10,7 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:go_router/go_router.dart';
import '../../routes/index.dart';
import '../../services/app_link_navigator.dart';
import '../../store/index.dart';
import '../../utils/index.dart';
import '../../widgets/index.dart';
......
......@@ -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) {
controller.addJavaScriptHandler(
handlerName: 'openWebview',
callback: (args) {
callback: (args) async {
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(
urlRequest: URLRequest(url: WebUri.uri(Uri.parse(url))));
} else if (_isHttpUrl(url)) {
......@@ -69,10 +83,13 @@ class _LinkPageState extends State<LinkPage> {
callback: (args) => CustomToast.dismiss());
controller.addJavaScriptHandler(
handlerName: 'openLinkCallback',
callback: (args) {
if (args.isNotEmpty) {
context.pushNamed(Routes.link,
queryParameters: {'url': args.first.toString()});
callback: (args) async {
if (args.isEmpty) return;
final 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(
......
......@@ -234,21 +234,14 @@ class LoginController extends GetxController {
if ((result.token ?? '').isEmpty) return;
// 存储用户信息
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(),
// ]);
await UserStore.to.login(result);
if (context.mounted) {
context.goNamed(Routes.main);
if (context.canPop()) {
context.pop();
} else {
context.goNamed(Routes.main);
}
}
}
......
part of main;
class MainController extends GetxController {
MainController({this.initialPage = 0});
final int initialPage;
late final PageController pageController;
// 默认显示
int currentPage = 1;
late int currentPage = initialPage;
@override
void onInit() {
......
......@@ -5,10 +5,8 @@ import 'package:flutter_book/pages/library/index.dart';
import 'package:flutter_book/pages/mine/index.dart';
import 'package:flutter_book/theme.dart';
import 'package:get/get.dart';
import 'package:go_router/go_router.dart';
import '../../routes/index.dart';
import '../../store/index.dart';
import '../../services/app_session.dart';
import '../book_shop/index.dart';
import '../course/index.dart';
......
part of main;
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
State<MainPage> createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
final String _controllerTag = UniqueKey().toString();
@override
void initState() {
super.initState();
......@@ -29,21 +33,23 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
return GetBuilder<MainController>(
init: MainController(),
tag: _controllerTag,
init: MainController(initialPage: widget.initialPage),
builder: (controller) => Scaffold(
body: PageView(
physics: const NeverScrollableScrollPhysics(),
controller: controller.pageController,
onPageChanged: controller.onPageChanged,
children: const [
CoursePage(),
LibraryPage(),
CoursePage(),
BookShopPage(),
MinePage(),
],
),
bottomNavigationBar: GetBuilder<MainController>(
id: 'navigation',
tag: _controllerTag,
builder: (controller) => Container(
decoration: BoxDecoration(
color: Colors.white,
......@@ -58,44 +64,44 @@ class _MainPageState extends State<MainPage> with WidgetsBindingObserver {
),
child: BottomNavigationBar(
currentIndex: controller.currentPage,
onTap: (page) {
if (page != 1 && !UserStore.to.isLogin) {
context.pushNamed(Routes.login);
} else {
controller.pageController.jumpToPage(page);
onTap: (page) async {
if (page != 0) {
final ready =
await AppSession.ensureTextbookSession(context);
if (!ready || !context.mounted) return;
}
// controller.pageController.jumpToPage(page);
controller.pageController.jumpToPage(page);
},
items: [
BottomNavigationBarItem(
icon: Image.asset(
'assets/images/course.png',
'assets/images/library.png',
width: 25,
height: 27.5,
fit: BoxFit.cover,
),
activeIcon: Image.asset(
'assets/images/course_select.png',
'assets/images/library_select.png',
width: 25,
height: 27.5,
fit: BoxFit.cover,
),
label: '课程',
label: '图书馆',
),
BottomNavigationBarItem(
icon: Image.asset(
'assets/images/library.png',
'assets/images/course.png',
width: 25,
height: 27.5,
fit: BoxFit.cover,
),
activeIcon: Image.asset(
'assets/images/library_select.png',
'assets/images/course_select.png',
width: 25,
height: 27.5,
fit: BoxFit.cover,
),
label: '图书馆',
label: '课程',
),
BottomNavigationBarItem(
icon: Image.asset(
......
library mine;
import 'package:flutter/material.dart';
import '../../widgets/product_home_button.dart';
import 'package:flutter_book/theme.dart';
import 'package:flutter_book/utils/index.dart';
import 'package:flutter_book/widgets/index.dart';
......
......@@ -22,6 +22,7 @@ class _MinePageState extends State<MinePage> {
Scaffold(
backgroundColor: Colors.transparent,
appBar: CustomAppBar(
leading: const ProductHomeButton(),
backgroundColor: Colors.transparent,
actions: [
CustomButton.icon(
......
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../routes/index.dart';
import '../../services/unified_session.dart';
import '../../theme.dart';
import '../../models/company_user.dart';
import '../../widgets/company_avatar.dart';
import '../../widgets/portal_toast.dart';
import '../../services/app_link_navigator.dart';
class PortalAccountPage extends StatefulWidget {
const PortalAccountPage({super.key});
@override
State<PortalAccountPage> createState() => _PortalAccountPageState();
}
class _PortalAccountPageState extends State<PortalAccountPage> {
bool _loadingProfile = true;
bool _loggingOut = false;
String _version = '';
@override
void initState() {
super.initState();
_refresh();
_loadVersion();
}
Future<void> _loadVersion() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
if (mounted) setState(() => _version = packageInfo.version);
} catch (_) {
// Keep the footer clean if version metadata is unavailable.
}
}
Future<void> _refresh({bool force = false}) async {
try {
if (await UnifiedSession.instance.hasSession()) {
await UnifiedSession.instance.refreshProfile(force: force);
} else {
UnifiedSession.instance.profile.value = null;
}
} catch (error) {
if (mounted) {
PortalToast.show(context, UnifiedSession.errorMessage(error),
type: PortalToastType.error);
}
} finally {
if (mounted) setState(() => _loadingProfile = false);
}
}
Future<void> _run(Future<void> Function() action) async {
try {
await action();
} catch (error) {
if (mounted) {
PortalToast.show(context, UnifiedSession.errorMessage(error),
type: PortalToastType.error);
}
}
}
Future<void> _learning(String route) async {
if (!await UnifiedSession.instance.ensureTextbookSession(context)) return;
if (mounted) await context.pushNamed(route);
}
Future<void> _openTextbook() async {
await AppLinkNavigator.open(context, 'ezijing://textbook?tab=mine',
requireLogin: true);
}
Future<void> _confirmLogout() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => const _LogoutConfirmationDialog());
if (confirmed != true || !mounted) return;
await _run(_performLogout);
}
Future<void> _performLogout() async {
if (mounted) setState(() => _loggingOut = true);
try {
await UnifiedSession.instance.logout();
} finally {
if (mounted) {
setState(() {
_loggingOut = false;
});
}
}
}
Widget _section(String title, Widget child) => Padding(
padding: const EdgeInsets.only(top: 22),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 12),
child: Text(title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF202733)))),
Container(
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(18)),
child: child)
]));
Widget _item(IconData icon, String title, Future<void> Function() action,
{String? subtitle}) =>
ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 18, vertical: 3),
leading: Icon(icon, size: 22, color: const Color(0xFF6C7685)),
title: Text(title,
style: const TextStyle(fontSize: 14, color: Color(0xFF202733))),
subtitle: subtitle == null
? null
: Text(subtitle,
style:
const TextStyle(fontSize: 11, color: Color(0xFF85858E))),
trailing: const Icon(Icons.chevron_right_rounded,
size: 20, color: Color(0xFFABB1BB)),
onTap: () => _run(action));
@override
Widget build(BuildContext context) => ValueListenableBuilder<CompanyUser?>(
valueListenable: UnifiedSession.instance.profile,
builder: (context, user, _) => Scaffold(
backgroundColor: const Color(0xFFF8FAFC),
appBar: AppBar(
automaticallyImplyLeading: false,
title: const Text('我的'),
centerTitle: true,
backgroundColor: const Color(0xFFF8FAFC),
surfaceTintColor: Colors.transparent),
body: RefreshIndicator(
onRefresh: () => _refresh(force: true),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
children: [
Container(
padding: const EdgeInsets.all(22),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: const LinearGradient(colors: [
Color(0xFFFBEFF3),
Color(0xFFF6F4FC),
Color(0xFFF0F7FC)
])),
child: Row(children: [
CompanyAvatar(user: user, size: 60),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_loadingProfile
? '正在加载'
: user != null
? (user.name.isEmpty
? '你好,欢迎回来'
: user.name)
: '欢迎来到清控紫荆教育',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF202733))),
const SizedBox(height: 7),
Text(
user != null
? '让每个人都有改变的能力'
: '登录后开启你的学习之旅',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF85858E))),
if (user == null && !_loadingProfile) ...[
const SizedBox(height: 14),
FilledButton(
onPressed: () => _run(() async {
await UnifiedSession.instance
.ensureLogin(context);
}),
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primary,
padding: const EdgeInsets.symmetric(
horizontal: 28)),
child: const Text('登录'))
]
]))
])),
_section(
'学习服务',
_item(Icons.menu_book_outlined, '紫荆数智学堂', _openTextbook,
subtitle: '学习资料、账户与更多服务')),
_section(
'帮助与设置',
Column(children: [
_item(Icons.help_outline_rounded, '帮助中心', () async {
await context.pushNamed(Routes.helpCenter);
}),
const Divider(
height: 1,
indent: 56,
endIndent: 18,
color: Color(0xFFF1F3F6)),
_item(Icons.chat_bubble_outline_rounded, '意见反馈',
() => _learning(Routes.feedback)),
const Divider(
height: 1,
indent: 56,
endIndent: 18,
color: Color(0xFFF1F3F6)),
_item(Icons.info_outline_rounded, '关于我们', () async {
await context.pushNamed(Routes.about);
}),
const Divider(
height: 1,
indent: 56,
endIndent: 18,
color: Color(0xFFF1F3F6)),
_item(Icons.settings_outlined, '设置', () async {
await context.pushNamed(Routes.set);
}),
])),
if (user != null) ...[
const SizedBox(height: 22),
SizedBox(
height: 46,
child: OutlinedButton(
onPressed: _loggingOut ? null : _confirmLogout,
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF737B88),
side: const BorderSide(
color: Color(0xFFE4E8EF)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14))),
child: _loggingOut
? const Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2)),
SizedBox(width: 8),
Text('正在退出…')
],
)
: const Text('退出登录')))
],
const SizedBox(height: 28),
const Center(
child: Text('清控紫荆教育',
style: TextStyle(
fontSize: 12, color: Color(0xFFADB3BC)))),
if (_version.isNotEmpty) ...[
const SizedBox(height: 5),
Center(
child: Text('V$_version',
style: const TextStyle(
fontSize: 11, color: Color(0xFFADB3BC))))
]
]))));
}
class _LogoutConfirmationDialog extends StatelessWidget {
const _LogoutConfirmationDialog();
@override
Widget build(BuildContext context) {
const primary = AppTheme.primary;
return Dialog(
backgroundColor: Colors.white,
insetPadding: const EdgeInsets.symmetric(horizontal: 32),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 26, 24, 22),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: primary.withValues(alpha: .08),
shape: BoxShape.circle,
),
child: const Icon(Icons.logout_rounded, color: primary, size: 25),
),
const SizedBox(height: 16),
const Text(
'退出登录',
style: TextStyle(
fontSize: 18,
color: Color(0xFF202733),
fontWeight: FontWeight.w600),
),
const SizedBox(height: 9),
const Text(
'确定退出当前账号吗?退出后,使用学习服务时需要重新登录。',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14, height: 1.55, color: Color(0xFF737B88)),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context, false),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(46),
foregroundColor: const Color(0xFF525B68),
side: const BorderSide(color: Color(0xFFE4E8EF)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(13)),
),
child: const Text('取消'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () => Navigator.pop(context, true),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(46),
backgroundColor: primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(13)),
),
child: const Text('退出登录'),
),
),
],
),
],
),
),
);
}
}
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: '我的'),
],
),
);
}
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../models/index.dart';
import '../../models/portal.dart';
import '../../models/product.dart';
import '../../services/unified_session.dart';
import 'package:go_router/go_router.dart';
import '../../routes/index.dart';
import '../../models/company_user.dart';
import '../../widgets/company_avatar.dart';
import '../../theme.dart';
import 'controller.dart';
import 'product_widgets.dart';
import 'news_detail.dart';
import 'news_list.dart';
class ProductHomePage extends StatefulWidget {
const ProductHomePage({super.key});
@override
State<ProductHomePage> createState() => _ProductHomePageState();
}
class _ProductHomePageState extends State<ProductHomePage>
with WidgetsBindingObserver, RouteAware {
bool _opening = false;
Future<void> _refreshCompanySession() async {
try {
if (await UnifiedSession.instance.hasSession()) {
await UnifiedSession.instance.refreshProfile();
} else {
UnifiedSession.instance.profile.value = null;
}
} catch (_) {
// Keep the homepage usable when account information cannot be loaded.
}
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_refreshCompanySession();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed &&
Get.isRegistered<ProductHomeController>()) {
Get.find<ProductHomeController>().refreshProducts();
_refreshCompanySession();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (route != null) Routes.portalObserver.subscribe(this, route);
}
@override
void didPopNext() {
if (Get.isRegistered<ProductHomeController>()) {
Get.find<ProductHomeController>().refreshProducts();
}
_refreshCompanySession();
}
@override
void dispose() {
Routes.portalObserver.unsubscribe(this);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
Future<void> _open(ProductModel product) async {
if (_opening) return;
_opening = true;
try {
await openPortalProduct(context, product);
} finally {
_opening = false;
}
}
Future<void> _openMore(String target) async {
if (_opening) return;
_opening = true;
try {
await openPortalLink(context, target);
} finally {
_opening = false;
}
}
static const _tints = [
Color(0xFFFCF3F6),
Color(0xFFF7F3FC),
Color(0xFFF1F7FD),
Color(0xFFF1F9F5),
Color(0xFFFCF6F0),
Color(0xFFFCF3F6),
Color(0xFFF1F7FD),
Color(0xFFF7F3FC),
];
Widget _heading(String title,
{String moreName = '',
VoidCallback? onMore,
double bottomSpacing = 18}) =>
Padding(
padding: EdgeInsets.only(bottom: bottomSpacing),
child: SizedBox(
height: 40,
child: Row(children: [
Container(
width: 3,
height: 16,
decoration: BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(3))),
const SizedBox(width: 8),
Expanded(
child: Text(title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF202733)))),
if (moreName.isNotEmpty)
TextButton(
onPressed: onMore,
style: TextButton.styleFrom(
minimumSize: const Size(0, 40),
tapTargetSize: MaterialTapTargetSize.shrinkWrap),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Text(moreName,
style: const TextStyle(
fontSize: 12, color: Color(0xFF85858E))),
const Icon(Icons.chevron_right,
size: 16, color: Color(0xFF85858E))
]))
])));
Widget _grid(ProductHomeController controller, PortalModule module) =>
LayoutBuilder(builder: (_, constraints) {
final columns = constraints.maxWidth >= 560 ? 6 : 4;
final width = constraints.maxWidth / columns;
return Wrap(runSpacing: 20, children: [
for (final entry in module.products.asMap().entries)
SizedBox(
width: width,
child: InkWell(
onTap: () => _open(entry.value),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Column(children: [
Container(
width: width.clamp(0, 68).toDouble(),
height: 62,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _tints[entry.key % _tints.length],
borderRadius: BorderRadius.circular(14)),
child: PortalImage(
url: entry.value.icon, fit: BoxFit.contain)),
const SizedBox(height: 8),
Text(entry.value.name,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 11,
height: 1.45,
color: Color(0xFF343B47)))
]))))
]);
});
Widget _cards(ProductHomeController controller, PortalModule module) =>
LayoutBuilder(builder: (_, constraints) {
final columns = constraints.maxWidth >= 560 ? 3 : 2;
final width = (constraints.maxWidth - (columns - 1) * 10) / columns;
return Wrap(spacing: 10, runSpacing: 10, children: [
for (final entry in module.products.asMap().entries)
SizedBox(
width: width,
child: PortalProductTile(
product: entry.value,
background: _tints[[0, 2, 3, 4, 1, 2][entry.key % 6]],
onTap: () => _open(entry.value)))
]);
});
Widget _module(ProductHomeController controller, PortalModule module) =>
Container(
margin: const EdgeInsets.only(top: 14),
padding: const EdgeInsets.fromLTRB(12, 16, 12, 18),
decoration: BoxDecoration(
color: Colors.white, borderRadius: BorderRadius.circular(18)),
child:
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_heading(module.name,
moreName: module.moreName,
onMore: module.moreTarget.isEmpty
? null
: () => _openMore(module.moreTarget)),
if (module.style == 'cards')
_cards(controller, module)
else
_grid(controller, module)
]));
Widget _account() => ValueListenableBuilder<CompanyUser?>(
valueListenable: UnifiedSession.instance.profile,
builder: (context, user, _) => IconButton(
iconSize: 40,
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 48, height: 48),
tooltip: '个人中心',
onPressed: () => context.goNamed(Routes.portalAccount),
icon: CompanyAvatar(user: user)));
@override
Widget build(BuildContext context) => GetBuilder<ProductHomeController>(
init: ProductHomeController(),
builder: (controller) => Scaffold(
backgroundColor: const Color(0xFFF8FAFC),
appBar: AppBar(
automaticallyImplyLeading: false,
toolbarHeight: 74,
titleSpacing: 20,
backgroundColor: const Color(0xFFF8FAFC),
surfaceTintColor: Colors.transparent,
title: Row(children: [
Image.asset('assets/images/portal_logo.png',
width: 34, height: 38, fit: BoxFit.contain),
const SizedBox(width: 9),
const Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('清控紫荆教育',
style: TextStyle(
color: Color(0xFF202733),
fontSize: 20,
fontWeight: FontWeight.w600)),
SizedBox(height: 3),
Text('让每个人都有改变的能力',
style: TextStyle(
color: Color(0xFF85858E), fontSize: 10)),
]))
]),
actions: [_account(), const SizedBox(width: 12)]),
body: RefreshIndicator(
onRefresh: controller.refreshProducts,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(12, 8, 12, 24),
children: [
if (controller.portal.banners.isNotEmpty)
PortalBanner(banners: controller.portal.banners),
if (controller.failed)
Row(children: [
Expanded(
child: Text(
controller.products.isEmpty
? '首页加载失败,请重试'
: '暂时无法更新,正在展示已缓存内容',
style: const TextStyle(
color: Color(0xFF8D8188), fontSize: 12))),
TextButton(
onPressed: controller.refreshProducts,
child: const Text('重试'))
]),
if (controller.loading && controller.products.isEmpty)
const Padding(
padding: EdgeInsets.all(40),
child: Center(child: CircularProgressIndicator()))
else if (!controller.failed &&
controller.portal.modules.isEmpty &&
controller.portal.banners.isEmpty)
const Padding(
padding: EdgeInsets.all(40),
child: Center(child: Text('暂无内容'))),
...controller.portal.modules
.map((module) => _module(controller, module)),
if (controller.portal.news.isNotEmpty)
Container(
margin: const EdgeInsets.only(top: 14),
padding: const EdgeInsets.fromLTRB(12, 16, 12, 18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_heading('最新动态',
moreName: '查看更多',
bottomSpacing: 4,
onMore: () => Navigator.of(context,
rootNavigator: true)
.push(MaterialPageRoute<void>(
builder: (_) =>
const NewsListPage()))),
for (final news in controller.portal.news)
ListTile(
contentPadding:
const EdgeInsets.symmetric(vertical: 8),
title: Row(children: [
if (news.image.isNotEmpty) ...[
ClipRRect(
borderRadius:
BorderRadius.circular(10),
child: SizedBox(
width: 112,
height: 84,
child: PortalImage(
url: news.image,
fit: BoxFit.cover)),
),
const SizedBox(width: 12),
],
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(news.title,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14)),
const SizedBox(height: 8),
Text(
DateTime.fromMillisecondsSinceEpoch(
news.startTime * 1000)
.toString()
.substring(0, 10),
style: const TextStyle(
fontSize: 12,
color: Color(0xFF85858E))),
],
)),
]),
onTap: () => Navigator.of(context,
rootNavigator: true)
.push(MaterialPageRoute<void>(
builder: (_) =>
NewsDetailPage(news: news))),
),
]),
),
for (final banner in controller.portal.productBanners)
Padding(
padding: const EdgeInsets.only(top: 14),
child: InkWell(
onTap: () => _openMore(banner.url ?? ''),
borderRadius: BorderRadius.circular(16),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AspectRatio(
aspectRatio: 3,
child: PortalImage(
url: banner.pic ?? '', fit: BoxFit.cover),
),
),
),
),
])),
));
}
class PortalBanner extends StatefulWidget {
const PortalBanner({super.key, required this.banners});
final List<AdModel> banners;
@override
State<PortalBanner> createState() => _PortalBannerState();
}
class _PortalBannerState extends State<PortalBanner> {
int _index = 0;
bool _opening = false;
Future<void> _openBanner(AdModel banner) async {
final target = banner.url?.trim() ?? '';
if (_opening || target.isEmpty) return;
_opening = true;
try {
await openPortalLink(context, target);
} finally {
_opening = false;
}
}
@override
Widget build(BuildContext context) => Column(children: [
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AspectRatio(
aspectRatio: 2.45,
child: PageView.builder(
itemCount: widget.banners.length,
onPageChanged: (index) => setState(() => _index = index),
itemBuilder: (_, index) => InkWell(
onTap: () => _openBanner(widget.banners[index]),
child: ColoredBox(
color: const Color(0xFFF3E7EC),
child: PortalImage(
url: widget.banners[index].pic ?? '',
icon: Icons.image_outlined)))))),
if (widget.banners.length > 1)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
widget.banners.length,
(index) => AnimatedContainer(
duration: const Duration(milliseconds: 180),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: index == _index ? 16 : 5,
height: 5,
decoration: BoxDecoration(
color: index == _index
? AppTheme.primary
: const Color(0xFFDCCBD3),
borderRadius: BorderRadius.circular(4))))))
]);
}
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../../services/app_link_navigator.dart';
import '../../theme.dart';
import '../../widgets/portal_toast.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
/// Shares the native cookie store with UnifiedLoginPage (ezijing.com TGC).
class ProductWebPage extends StatefulWidget {
final String url;
final String title;
const ProductWebPage({super.key, required this.url, required this.title});
@override
State<ProductWebPage> createState() => _ProductWebPageState();
}
class _ProductWebPageState extends State<ProductWebPage> {
InAppWebViewController? _controller;
bool _loading = true;
bool _failed = false;
bool _openingLink = false;
bool _showNavigation = true;
bool _canGoBack = false;
bool _canGoForward = false;
int _lastScrollY = 0;
int _directionStartY = 0;
bool? _scrollingTowardBottom;
DateTime _lastNavigationToggle = DateTime.fromMillisecondsSinceEpoch(0);
Timer? _progressCompletionTimer;
double _progress = 0;
String _pageTitle = '';
String _host = '';
@override
void initState() {
super.initState();
_pageTitle = widget.title;
_host = Uri.tryParse(widget.url)?.host ?? '';
}
Future<void> _syncNavigation() async {
final controller = _controller;
if (controller == null || !mounted) return;
final canBack = await controller.canGoBack();
final canForward = await controller.canGoForward();
if (!mounted) return;
setState(() {
_canGoBack = canBack;
_canGoForward = canForward;
});
}
void _onScrollChanged(int y) {
if (y <= 0) {
_lastScrollY = 0;
_directionStartY = 0;
_scrollingTowardBottom = null;
if (!_showNavigation && mounted) {
setState(() => _showNavigation = true);
}
return;
}
final delta = y - _lastScrollY;
if (delta.abs() < 2) return;
_lastScrollY = y;
final towardBottom = delta > 0;
if (_scrollingTowardBottom != towardBottom) {
_scrollingTowardBottom = towardBottom;
_directionStartY = y;
return;
}
if ((y - _directionStartY).abs() < 6) return;
_directionStartY = y;
final shouldShow = !towardBottom;
if (shouldShow == _showNavigation) return;
final now = DateTime.now();
if (now.difference(_lastNavigationToggle).inMilliseconds < 240) return;
_lastNavigationToggle = now;
if (mounted) setState(() => _showNavigation = shouldShow);
}
@override
void dispose() {
_progressCompletionTimer?.cancel();
super.dispose();
}
Future<void> _copyLink() async {
final url = await _controller?.getUrl();
if (url == null) return;
await Clipboard.setData(ClipboardData(text: url.toString()));
if (mounted) {
PortalToast.show(context, '已复制', type: PortalToastType.success);
}
}
Future<void> _openInBrowser() async {
final url = await _controller?.getUrl();
final uri = Uri.tryParse(url?.toString() ?? widget.url);
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
if (mounted) {
PortalToast.show(context, '当前网页无法在浏览器中打开', type: PortalToastType.error);
}
return;
}
try {
await InAppBrowser.openWithSystemBrowser(url: WebUri.uri(uri));
} catch (_) {
if (mounted) {
PortalToast.show(context, '无法打开默认浏览器', type: PortalToastType.error);
}
}
}
Future<void> _showMoreMenu() async {
final action = await showModalBottomSheet<_WebAction>(
context: context,
backgroundColor: Colors.transparent,
barrierColor: Colors.black54,
isScrollControlled: true,
builder: (sheetContext) => Container(
padding: EdgeInsets.only(
bottom: MediaQuery.viewPaddingOf(sheetContext).bottom),
decoration: const BoxDecoration(
color: Color(0xFFF4F4F4),
borderRadius: BorderRadius.vertical(top: Radius.circular(18))),
child: Column(mainAxisSize: MainAxisSize.min, children: [
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(_host.isEmpty ? '网页操作' : '此网页由 $_host 提供',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14, color: Color(0xFF777777)))),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(children: [
_actionItem(sheetContext, _WebAction.copyLink,
CupertinoIcons.link, '复制链接'),
_actionItem(sheetContext, _WebAction.refresh,
CupertinoIcons.refresh_thin, '刷新'),
_actionItem(sheetContext, _WebAction.openBrowser,
CupertinoIcons.globe, '用默认浏览器打开'),
])),
const SizedBox(height: 18),
const Divider(height: 1, color: Color(0xFFE3E3E3)),
SizedBox(
width: double.infinity,
height: 62,
child: TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF576B95),
textStyle: const TextStyle(fontSize: 17)),
child: const Text('取消'))),
])));
if (action != null && mounted) await _selectAction(action);
}
Widget _actionItem(BuildContext sheetContext, _WebAction action,
IconData icon, String title) =>
Expanded(
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => Navigator.of(sheetContext).pop(action),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Column(children: [
Container(
width: 58,
height: 58,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16)),
child: Icon(icon,
size: 26, color: const Color(0xFF34383F))),
const SizedBox(height: 9),
Text(title,
textAlign: TextAlign.center,
maxLines: 2,
style: const TextStyle(
fontSize: 12,
height: 1.35,
color: Color(0xFF777777)))
]))));
Future<void> _selectAction(_WebAction action) async {
switch (action) {
case _WebAction.copyLink:
await _copyLink();
case _WebAction.refresh:
await _controller?.reload();
case _WebAction.openBrowser:
await _openInBrowser();
}
}
Future<void> _openAppLink(String target) async {
if (_openingLink || !mounted) return;
_openingLink = true;
try {
await AppLinkNavigator.open(context, target);
} catch (_) {
if (mounted) {
PortalToast.show(context, '暂时无法打开,请重试', type: PortalToastType.error);
}
} finally {
_openingLink = false;
}
}
Future<void> _back() async {
if (await _controller?.canGoBack() == true) {
await _controller?.goBack();
} else if (mounted) {
Navigator.of(context).pop();
}
await _syncNavigation();
}
Future<void> _close() async {
if (mounted) Navigator.of(context).pop();
}
Widget _navigationBar() {
final bottomInset = MediaQuery.paddingOf(context).bottom;
return Container(
height: 54 + bottomInset,
clipBehavior: Clip.hardEdge,
decoration: const BoxDecoration(
color: Color(0xFFF7F7F7),
border: Border(top: BorderSide(color: Color(0xFFECECEC)))),
child: SafeArea(
top: false,
child: SizedBox(
height: 54,
child:
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
IconButton(
tooltip: '后退',
onPressed: _canGoBack ? _back : null,
icon: Icon(CupertinoIcons.back,
size: 23,
color: _canGoBack
? const Color(0xFF222222)
: const Color(0xFFB7B7B7))),
const SizedBox(width: 58),
IconButton(
tooltip: '前进',
onPressed: _canGoForward
? () async {
await _controller?.goForward();
await _syncNavigation();
}
: null,
icon: Icon(CupertinoIcons.forward,
size: 23,
color: _canGoForward
? const Color(0xFF222222)
: const Color(0xFFB7B7B7)))
]))));
}
@override
Widget build(BuildContext context) => PopScope(
canPop: !_canGoBack,
onPopInvokedWithResult: (didPop, result) async {
if (!didPop) await _back();
},
child: Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
leading: IconButton(
tooltip: '关闭',
icon: const Icon(CupertinoIcons.xmark, size: 21),
onPressed: _close),
titleSpacing: 0,
title: Text(_pageTitle,
maxLines: 1, overflow: TextOverflow.ellipsis),
actions: [
IconButton(
tooltip: '更多',
icon: const Icon(CupertinoIcons.ellipsis, size: 22),
onPressed: _showMoreMenu,
),
const SizedBox(width: 4)
]),
body: Stack(children: [
InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(widget.url)),
initialSettings: InAppWebViewSettings(
sharedCookiesEnabled: true,
allowsBackForwardNavigationGestures: true,
useShouldOverrideUrlLoading: true,
supportMultipleWindows: true,
// App H5 pages can detect this marker without losing the
// platform's default iOS/Android user-agent tokens.
applicationNameForUserAgent: 'ZijingApp',
mixedContentMode:
MixedContentMode.MIXED_CONTENT_ALWAYS_ALLOW),
onWebViewCreated: (controller) {
_controller = controller;
_syncNavigation();
},
onScrollChanged: (controller, x, y) => _onScrollChanged(y),
onUpdateVisitedHistory: (controller, url, isReload) {
_syncNavigation();
},
shouldOverrideUrlLoading: (controller, action) async {
final target = action.request.url;
if (target?.scheme == 'ezijing') {
unawaited(_openAppLink(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(_openAppLink(target.toString()));
} else if (target?.scheme == 'https' ||
target?.scheme == 'http') {
await controller.loadUrl(urlRequest: action.request);
}
// Open supported destinations in the current app, without a popup.
return false;
},
onProgressChanged: (controller, progress) {
if (mounted && _loading) {
final next = (progress.clamp(0, 100)) / 100;
if (next >= _progress) {
setState(() => _progress = next);
}
}
},
onLoadStart: (controller, url) {
if (mounted) {
setState(() {
_progressCompletionTimer?.cancel();
if (!_loading) _progress = 0;
_loading = true;
_failed = false;
_lastScrollY = 0;
_directionStartY = 0;
_scrollingTowardBottom = null;
_showNavigation = true;
});
}
},
onLoadStop: (controller, url) async {
if (mounted) {
final title = (await controller.getTitle())?.trim();
if (!mounted) return;
setState(() {
_progress = 1;
if (title != null && title.isNotEmpty) _pageTitle = title;
_host = Uri.tryParse(url?.toString() ?? '')?.host ?? _host;
});
}
_progressCompletionTimer?.cancel();
_progressCompletionTimer =
Timer(const Duration(milliseconds: 180), () {
if (mounted) setState(() => _loading = false);
});
await _syncNavigation();
},
onReceivedError: (controller, request, error) {
if (request.isForMainFrame == true && mounted) {
setState(() {
_loading = false;
_progressCompletionTimer?.cancel();
_failed = true;
});
}
},
),
if (_canGoBack || _canGoForward)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: IgnorePointer(
ignoring: !_showNavigation,
child: AnimatedSlide(
offset: _showNavigation ? Offset.zero : const Offset(0, 1),
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
child: _navigationBar(),
),
),
),
if (_loading)
LinearProgressIndicator(
value: _progress,
minHeight: 1.5,
backgroundColor: const Color(0xFFF1E7EB),
valueColor:
const AlwaysStoppedAnimation<Color>(AppTheme.primary)),
if (_failed)
Positioned.fill(
child: ColoredBox(
color: Colors.white,
child: Center(
child:
Column(mainAxisSize: MainAxisSize.min, children: [
const Text('页面加载失败'),
TextButton(
onPressed: () => _controller?.reload(),
child: const Text('重试')),
])))),
]),
),
);
}
enum _WebAction { copyLink, refresh, openBrowser }
......@@ -33,7 +33,6 @@ class ResetPwdController extends GetxController {
if (ValidatorTool.isValidPassword(pwdInput.text) &&
ValidatorTool.isValidPassword(pwd2Input.text) &&
(pwdInput.text == pwd2Input.text)) {
Console.log('-------${pwdInput.text}-------------${pwd2Input.text}');
_enable = true;
} else {
_enable = false;
......
......@@ -29,6 +29,11 @@ class _SplashPageState extends State<SplashPage> {
await Future.delayed(const Duration(seconds: 2));
if (!mounted || _navigated) return;
if (Routes.completeLaunch(context)) {
_navigated = true;
return;
}
final netStatus = await Tools.checkCurrentNetStatus();
if (!mounted || _navigated) return;
......@@ -43,7 +48,7 @@ class _SplashPageState extends State<SplashPage> {
}
_navigated = true;
context.pushReplacementNamed(Routes.main);
context.goNamed(Routes.home);
}
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('返回首页')),
])),
);
}
}
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../services/unified_session.dart';
import '../../theme.dart';
enum _LoginMode { account, sms }
class UnifiedLoginPage extends StatefulWidget {
const UnifiedLoginPage({super.key});
@override
State<UnifiedLoginPage> createState() => _UnifiedLoginPageState();
}
class _UnifiedLoginPageState extends State<UnifiedLoginPage> {
final _accountController = TextEditingController();
final _passwordController = TextEditingController();
final _phoneController = TextEditingController();
final _codeController = TextEditingController();
_LoginMode _mode = _LoginMode.account;
bool _busy = false;
bool _sendingCode = false;
bool _obscurePassword = true;
int _countdown = 0;
Timer? _timer;
@override
void dispose() {
_timer?.cancel();
_accountController.dispose();
_passwordController.dispose();
_phoneController.dispose();
_codeController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_busy) return;
if (_mode == _LoginMode.account) {
if (_accountController.text.trim().isEmpty ||
_passwordController.text.isEmpty) {
_showMessage('请输入账号和密码');
return;
}
} else if (!_validPhone(_phoneController.text.trim()) ||
_codeController.text.trim().isEmpty) {
_showMessage('请输入正确的手机号和验证码');
return;
}
setState(() => _busy = true);
try {
if (_mode == _LoginMode.account) {
await UnifiedSession.instance.loginWithPassword(
_accountController.text.trim(), _passwordController.text);
} else {
await UnifiedSession.instance.loginWithCode(
_phoneController.text.trim(), _codeController.text.trim());
}
if (mounted) Navigator.of(context).pop(true);
} catch (error) {
_showMessage(UnifiedSession.errorMessage(error));
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _sendCode() async {
final phone = _phoneController.text.trim();
if (!_validPhone(phone)) {
_showMessage('请输入正确的手机号');
return;
}
if (_sendingCode || _countdown > 0) return;
setState(() => _sendingCode = true);
try {
await UnifiedSession.instance.sendLoginCode(phone);
if (!mounted) return;
_showMessage('验证码已发送');
setState(() => _countdown = 60);
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
if (_countdown <= 1) {
timer.cancel();
setState(() => _countdown = 0);
} else {
setState(() => _countdown--);
}
});
} catch (error) {
_showMessage(UnifiedSession.errorMessage(error));
} finally {
if (mounted) setState(() => _sendingCode = false);
}
}
bool _validPhone(String value) => RegExp(r'^1[3-9]\d{9}$').hasMatch(value);
void _showMessage(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
}
InputDecoration _decoration(String hint, {Widget? prefix}) => InputDecoration(
hintText: hint,
prefixIcon: prefix,
filled: true,
fillColor: const Color(0xFFF7F7F8),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFF0F0F2))),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppTheme.primary, width: 1.2)),
);
Widget _modeTab(_LoginMode mode, String label) {
final selected = _mode == mode;
return Expanded(
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: _busy ? null : () => setState(() => _mode = mode),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(10),
boxShadow: selected
? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 8,
offset: const Offset(0, 2))
]
: null,
),
child: Center(
child: Text(label,
style: TextStyle(
fontSize: 15,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: selected
? const Color(0xFF22252B)
: const Color(0xFF888B92))),
),
),
),
);
}
Widget _accountFields() => Column(children: [
TextField(
controller: _accountController,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.username],
decoration: _decoration('手机号 / 邮箱 / 用户名',
prefix: const Icon(Icons.person_outline))),
const SizedBox(height: 14),
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
onSubmitted: (_) => _submit(),
decoration: _decoration(
'请输入密码',
prefix: const Icon(Icons.lock_outline),
).copyWith(
suffixIcon: IconButton(
tooltip: _obscurePassword ? '显示密码' : '隐藏密码',
onPressed: () =>
setState(() => _obscurePassword = !_obscurePassword),
icon: Icon(_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined),
),
),
),
]);
Widget _smsFields() => Column(children: [
TextField(
controller: _phoneController,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.telephoneNumber],
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
maxLength: 11,
decoration: _decoration('请输入手机号',
prefix: const Icon(Icons.phone_iphone_outlined))
.copyWith(counterText: ''),
),
const SizedBox(height: 14),
TextField(
controller: _codeController,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
maxLength: 6,
onSubmitted: (_) => _submit(),
decoration:
_decoration('请输入短信验证码', prefix: const Icon(Icons.sms_outlined))
.copyWith(
counterText: '',
suffixIcon: Padding(
padding: const EdgeInsets.only(right: 8),
child: TextButton(
onPressed: _sendingCode || _countdown > 0 ? null : _sendCode,
child: Text(_sendingCode
? '发送中'
: _countdown > 0
? '${_countdown}s后重发'
: '获取验证码'),
),
),
),
),
]);
@override
Widget build(BuildContext context) => Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('登录'),
centerTitle: true,
backgroundColor: Colors.white,
surfaceTintColor: Colors.white,
),
body: SafeArea(
top: false,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 32, 24, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('欢迎登录',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.w700,
color: Color(0xFF20232A))),
const SizedBox(height: 8),
const Text('使用清控紫荆教育账号继续',
style: TextStyle(fontSize: 14, color: Color(0xFF8A8D94))),
const SizedBox(height: 32),
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: const Color(0xFFF2F3F5),
borderRadius: BorderRadius.circular(12)),
child: Row(children: [
_modeTab(_LoginMode.account, '账号登录'),
_modeTab(_LoginMode.sms, '验证码登录'),
]),
),
const SizedBox(height: 24),
if (_mode == _LoginMode.account)
_accountFields()
else ...[
_smsFields(),
const SizedBox(height: 12),
const Text('新手机号验证通过后将自动注册',
style:
TextStyle(fontSize: 12, color: Color(0xFF999CA3))),
],
const SizedBox(height: 28),
SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
onPressed: _busy ? null : _submit,
style: FilledButton.styleFrom(
backgroundColor: AppTheme.primary,
disabledBackgroundColor:
AppTheme.primary.withValues(alpha: 0.55),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
child: _busy
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Text('登录',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600)),
),
),
],
),
),
),
),
);
}
......@@ -7,7 +7,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import '../../routes/index.dart';
import '../../store/index.dart';
import '../../services/app_session.dart';
import '../../utils/index.dart';
import '../../widgets/index.dart';
......
......@@ -15,7 +15,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> {
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: const Text('账号安全'),
title: const Text('教材账号安全'),
),
body: Column(
children: [
......@@ -40,7 +40,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> {
context.pushNamed(Routes.changePhone);
},
child: _buildItem(
title: '修改手机号码', value: widget.model.phone ?? '')),
title: '修改教材手机号', value: widget.model.phone ?? '')),
Container(
margin: EdgeInsets.symmetric(horizontal: 10.w),
height: 0.5.w,
......@@ -56,7 +56,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> {
extra: widget.model, queryParameters: {'type': type});
},
child: _buildItem(
title: '修改密码',
title: '修改教材密码',
value: widget.model.password.toString().isEmpty
? '未设置'
: '')),
......@@ -80,7 +80,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> {
child: Column(
children: [
GestureDetector(
child: _buildItem(title: '账号注销', value: ''),
child: _buildItem(title: '注销教材账号', value: ''),
onTap: () async {
showDialog(
context: context,
......@@ -99,7 +99,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> {
// 调整上下间距
child: Center(
child: Text(
'确定注销账号吗',
'确定注销紫荆数智学堂账号吗?其他产品的账号不受影响。',
style: TextStyle(
fontSize: 15.w,
fontWeight: Fonts.medium,
......@@ -147,7 +147,7 @@ class _UserSecurityPageState extends State<UserSecurityPage> {
final result = await AccountAPI.delete();
if (result) {
CustomToast.loading();
await UserStore.to.logout();
await AppSession.logout();
CustomToast.dismiss();
if (context.mounted) {
context.goNamed(Routes.splash);
......
......@@ -13,7 +13,7 @@ import 'package:package_info_plus/package_info_plus.dart';
import '../../apis/index.dart';
import '../../models/index.dart';
import '../../routes/index.dart';
import '../../store/index.dart';
import '../../services/app_session.dart';
import '../../widgets/index.dart';
part 'view.dart';
......
......@@ -156,12 +156,10 @@ class _UserSetPageState extends State<UserSetPage> {
GestureDetector(
onTap: () async {
try {
await AccountAPI.logout();
await AppSession.logout();
} finally {
await UserStore.to.logout();
await Tools.clearData();
if (context.mounted) {
context.goNamed(Routes.splash);
context.goNamed(Routes.home);
}
}
},
......@@ -193,10 +191,10 @@ class _UserSetPageState extends State<UserSetPage> {
// if (result) {
// // Toast.show('退出成功');
// CustomToast.loading();
// await UserStore.to.logout();
// await AppSession.logout();
// CustomToast.dismiss();
// if (context.mounted) {
// context.goNamed(Routes.splash);
// context.goNamed(Routes.home);
// }
// }
// },
......
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/foundation.dart';
import 'package:flutter/material.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_pwd/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_cneter_content/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/reset_pwd/index.dart';
import 'package:flutter_book/pages/search/index.dart';
import 'package:flutter_book/pages/splash/index.dart';
import 'package:flutter_book/pages/study_history/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_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 '../models/index.dart';
......@@ -61,7 +71,6 @@ import '../pages/pay_coupon/index.dart';
import '../pages/user_about/index.dart';
import '../pages/user_terms/index.dart';
import '../pages/version/index.dart';
import '../store/index.dart';
part 'observers.dart';
part 'routes.dart';
......
......@@ -8,12 +8,26 @@ T _routeExtra<T>(GoRouterState state) {
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 {
static final RouteObserver<Route> observer = RouteObservers();
static final RouteObserver<Route> portalObserver = RouteObservers();
static List<String> history = [];
static const splash = 'splash';
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 adDetail = 'ad_detail';
......@@ -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 {
static final GlobalKey<NavigatorState> navigatorKey =
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(
navigatorKey: navigatorKey,
initialLocation: '/$splash',
redirect: _redirectAppLink,
errorBuilder: (context, state) => Scaffold(
appBar: AppBar(title: const Text('页面无法打开')),
body: Center(
child: TextButton(
onPressed: () => context.goNamed(main),
onPressed: () => context.goNamed(home),
child: const Text('返回首页'),
),
),
......@@ -149,6 +201,16 @@ abstract class Routes {
observer
],
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(
path: '/$splash',
name: splash,
......@@ -158,13 +220,62 @@ abstract class Routes {
child: const SplashPage(),
),
),
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) =>
PortalShell(navigationShell: navigationShell),
branches: [
StatefulShellBranch(observers: [
portalObserver
], routes: [
GoRoute(
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: '/',
path: '/textbook',
parentNavigatorKey: navigatorKey,
name: main,
pageBuilder: (context, state) => CupertinoPage(
name: state.uri.toString(),
key: state.pageKey,
child: const MainPage())),
child: _initialTextbookTab(state) == 0
? const MainPage()
: TextbookGate(
child: MainPage(
initialPage: _initialTextbookTab(state))))),
GoRoute(
path: '/$ad',
name: ad,
......@@ -182,7 +293,9 @@ abstract class Routes {
pageBuilder: (context, state) => CupertinoPage(
name: state.uri.toString(),
key: state.pageKey,
child: const LoginPage())),
child: AppMode.portalEnabled
? const UnifiedLoginPage()
: const LoginPage())),
GoRoute(
path: '/$forgetPwd',
name: forgetPwd,
......@@ -286,13 +399,14 @@ abstract class Routes {
GoRoute(
path: '/$bookDetail',
name: bookDetail,
parentNavigatorKey: navigatorKey,
pageBuilder: (context, state) => CupertinoPage(
name: state.uri.toString(),
key: state.pageKey,
child: BookDetailPage(
child: TextbookGate(
child: BookDetailPage(
bookId: state.uri.queryParameters['book_id'].toString(),
)),
redirect: (context, state) => _RouteRedirect.auth(),
))),
),
GoRoute(
path: '/$bookScore',
......@@ -482,7 +596,6 @@ abstract class Routes {
child: UserDiscussDesPage(
model: _routeExtra<CourseModel>(state),
))),
GoRoute(
path: '/$orderEvaluate',
name: orderEvaluate,
......@@ -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 {
CancelToken? cancelToken,
bool excludeToken = false,
bool showLoading = false,
bool sensitive = false,
}) async {
if (showLoading) CustomToast.loading();
try {
final requestOptions = options ?? Options();
if (sensitive) {
requestOptions.extra = {...?requestOptions.extra, 'sensitive': true};
}
requestOptions.headers =
_getHeaders(excludeToken: excludeToken, params: params, url: url);
Console.log(
'GET $url\nheaders: ${requestOptions.headers}\nparams: $params',
);
if (!sensitive) {
Console.log(
'GET $url\nheaders: ${requestOptions.headers}\nparams: $params',
);
}
final response = await _dio.get(
url,
......@@ -74,7 +80,7 @@ class HttpService extends GetxService {
options: requestOptions,
cancelToken: cancelToken,
);
Console.log('GET $url response: ${response.data}');
if (!sensitive) Console.log('GET $url response: ${response.data}');
if (showLoading) CustomToast.dismiss();
return ResponseModel.fromJson(response.data as Map<String, dynamic>);
} catch (error) {
......@@ -91,22 +97,28 @@ class HttpService extends GetxService {
CancelToken? cancelToken,
bool excludeToken = false,
bool showLoading = false,
bool sensitive = false,
}) async {
if (showLoading) CustomToast.loading();
try {
final requestOptions = options ?? Options();
if (sensitive) {
requestOptions.extra = {...?requestOptions.extra, 'sensitive': true};
}
requestOptions.headers =
_getHeaders(excludeToken: excludeToken, params: params, url: url);
Console.log(
'POST $url\nheaders: ${requestOptions.headers}\nparams: $params',
);
if (!sensitive) {
Console.log(
'POST $url\nheaders: ${requestOptions.headers}\nparams: $params',
);
}
final response = await _dio.post(
url,
data: params,
options: requestOptions,
cancelToken: cancelToken,
);
Console.log('POST $url response: ${response.data}');
if (!sensitive) Console.log('POST $url response: ${response.data}');
if (showLoading) CustomToast.dismiss();
return ResponseModel.fromJson(response.data as Map<String, dynamic>);
} catch (error) {
......@@ -193,7 +205,11 @@ class _RequestInterceptor extends Interceptor {
@override
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) {
case DioExceptionType.connectionTimeout:
CustomToast.fail('网络连接超时');
......@@ -215,9 +231,11 @@ class _RequestInterceptor extends Interceptor {
final code = int.tryParse(body?['code']?.toString() ?? '');
final effectiveStatusCode =
code == 401 || code == 403 ? code : statusCode;
Console.log(
'HTTP error $statusCode ${err.requestOptions.path}\nresponse: ${response?.data}',
);
if (err.requestOptions.extra['sensitive'] != true) {
Console.log(
'HTTP error $statusCode ${err.requestOptions.path}\nresponse: ${response?.data}',
);
}
var msg = '服务器错误';
switch (effectiveStatusCode) {
case 401:
......@@ -226,7 +244,19 @@ class _RequestInterceptor extends Interceptor {
handler.reject(err);
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) {
final RequestOptions requestOptions = err.requestOptions;
final headers = requestOptions.headers;
......@@ -247,7 +277,7 @@ class _RequestInterceptor extends Interceptor {
handler.resolve(newResponse);
return;
} on DioException catch (retryError) {
if (_isUnauthorized(retryError)) {
if (_isUnauthorized(retryError) && epoch == user.sessionEpoch) {
await _expireSession();
}
handler.reject(retryError);
......
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_book/models/index.dart';
import 'package:flutter_book/services/index.dart';
import 'package:flutter_book/store/index.dart';
import '../pages/unified_login/view.dart';
import '../models/company_user.dart';
import '../utils/index.dart';
import '../widgets/portal_toast.dart';
/// TGC stays in the platform cookie store; never copy it to SharedPreferences.
class UnifiedSession {
UnifiedSession._();
static final instance = UnifiedSession._();
final _cookies = CookieManager.instance();
final profile = ValueNotifier<CompanyUser?>(null);
Future<CompanyUser>? _profileRequest;
DateTime? _profileLoadedAt;
Future<bool>? _loginRequest;
Future<bool>? _exchangeRequest;
Future<void>? _logoutRequest;
Future<void>? _cleanupRequest;
String? _textbookOwner;
int _sessionEpoch = 0;
late final Dio _authDio = Dio(BaseOptions(
baseUrl: kPassportApiBase,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
contentType: Headers.formUrlEncodedContentType,
responseType: ResponseType.json,
headers: {'Referer': 'https://h5.ezijing.com/'}));
late final Dio _userCenterDio = Dio(BaseOptions(
baseUrl: kUserCenterApiBase,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
contentType: Headers.formUrlEncodedContentType,
responseType: ResponseType.json,
headers: {'Referer': 'https://h5.ezijing.com/'}));
Future<void> sendLoginCode(String phone) async {
final response = await _userCenterDio.post('/user/send-code',
data: {'account': phone, 'country_code': '86'});
_requireSuccess(response);
}
Future<void> loginWithPassword(String account, String password) async {
final response = await _authDio.post('/rest/login', data: {
'type': 1,
'account': account,
'password': password,
'service': 'https://h5.ezijing.com',
'remember_me': 'true',
'info': 'true',
});
final data = _requireSuccess(response);
await _finishLogin(response, data!['TGC'] as String);
}
Future<void> loginWithCode(String phone, String code) async {
final response = await _userCenterDio.post(
'/user/register-and-login-by-mobile',
data: {'mobile': phone, 'code': code, 'country_code': '86'});
_requireSuccess(response);
await _finishLogin(response);
}
/// One shared company profile for the homepage and account page.
Future<CompanyUser> refreshProfile({bool force = false}) async {
await _awaitCleanup();
if (_profileRequest != null) return _profileRequest!;
if (!force &&
profile.value != null &&
_profileLoadedAt != null &&
DateTime.now().difference(_profileLoadedAt!) <
const Duration(minutes: 5)) {
return profile.value!;
}
final pending = _fetchProfile();
_profileRequest = pending;
try {
return await pending;
} finally {
if (identical(_profileRequest, pending)) _profileRequest = null;
}
}
Future<CompanyUser> _fetchProfile([String? loginTgc]) async {
final epoch = _sessionEpoch;
final tgc = loginTgc ?? await readTgc();
if (tgc == null) {
profile.value = null;
throw StateError('请先登录');
}
final response = await _authDio.get('/account/get-user-info',
options: Options(headers: {'Cookie': 'TGC=$tgc'}));
final data = _requireSuccess(response);
final user = CompanyUser.fromJson(data!);
if (user.id.isEmpty) throw const FormatException('用户信息缺少用户编号');
if (epoch != _sessionEpoch) throw StateError('账号状态已变化,请重试');
profile.value = user;
_profileLoadedAt = DateTime.now();
return user;
}
Map<String, dynamic>? _requireSuccess(Response response) {
final body = response.data;
if (body is! Map) throw const FormatException('登录服务返回数据格式错误');
if (body['code'] != 0) {
throw StateError(
body['msg'] as String? ?? body['message'] as String? ?? '登录失败');
}
return body['data'] as Map<String, dynamic>?;
}
Future<void> _finishLogin(Response response, [String? responseTgc]) async {
final tgc = _tgcFromHeaders(response.headers) ?? responseTgc?.trim();
if (tgc == null || tgc.isEmpty) {
throw StateError('登录成功,但未收到登录凭证,请重试');
}
await _awaitCleanup();
_sessionEpoch++;
_profileRequest = null;
_profileLoadedAt = null;
_textbookOwner = null;
await _cookies.setCookie(
url: WebUri('$kSsoCookieOrigin/'),
name: 'TGC',
value: tgc,
domain: '.ezijing.com',
path: '/',
isSecure: true,
isHttpOnly: true,
expiresDate: _cookieExpiresAt(response.headers));
await _fetchProfile(tgc);
}
String? _tgcFromHeaders(Headers headers) {
for (final value in headers.map['set-cookie'] ?? const <String>[]) {
final cookie = value.split(';').first.trim();
if (cookie.startsWith('TGC=')) return cookie.substring(4);
}
return null;
}
int? _cookieExpiresAt(Headers headers) {
for (final value in headers.map['set-cookie'] ?? const <String>[]) {
if (!value.trimLeft().startsWith('TGC=')) continue;
for (final attribute in value.split(';').skip(1)) {
final separator = attribute.indexOf('=');
if (separator < 0 ||
attribute.substring(0, separator).trim().toLowerCase() !=
'expires') {
continue;
}
try {
final rawDate = attribute.substring(separator + 1).trim();
final httpDate = rawDate.replaceFirstMapped(
RegExp(r'(\b\d{1,2})-([A-Za-z]{3})-(\d{4}\b)'),
(match) =>
'${match.group(1)} ${match.group(2)} ${match.group(3)}');
final expiresAt = HttpDate.parse(httpDate).millisecondsSinceEpoch;
return expiresAt > DateTime.now().millisecondsSinceEpoch
? expiresAt
: null;
} catch (_) {
// A malformed Expires value must not prevent a valid TGC login.
return null;
}
}
}
return null;
}
Future<String?> readTgc() async {
final cookie = await _cookies.getCookie(
url: WebUri('$kSsoCookieOrigin/'), name: 'TGC');
final value = cookie?.value.toString() ?? '';
return value.isEmpty ? null : value;
}
Future<bool> hasSession() async => await readTgc() != null;
Future<Map<String, dynamic>> _textbookRequest(
String action, Map<String, dynamic> values,
{CancelToken? cancelToken, bool silent = false}) async {
final result = await HttpService.to.post('/v1/members/sso/$action',
params: values,
excludeToken: true,
sensitive: true,
cancelToken: cancelToken,
options: Options(extra: {'skipAuthRefresh': true, 'silent': silent}));
return result.data as Map<String, dynamic>;
}
Future<bool> ensureLogin(BuildContext context) async {
if (_loginRequest != null) return _loginRequest!;
final pending = _ensureLogin(context);
_loginRequest = pending;
try {
return await pending;
} finally {
_loginRequest = null;
}
}
Future<bool> _ensureLogin(BuildContext context) async {
await _awaitCleanup();
if (await hasSession()) {
try {
await refreshProfile();
return true;
} on StateError {
profile.value = null;
}
} else {
profile.value = null;
}
if (!context.mounted) return false;
return await Navigator.of(context, rootNavigator: true).push<bool>(
MaterialPageRoute(builder: (_) => const UnifiedLoginPage())) ==
true;
}
Future<bool> ensureTextbookSession(BuildContext context) async {
if (_exchangeRequest != null) return _exchangeRequest!;
final pending = _ensureTextbookSession(context);
_exchangeRequest = pending;
try {
return await pending;
} finally {
_exchangeRequest = null;
}
}
Future<bool> _ensureTextbookSession(BuildContext context) async {
try {
if (!await ensureLogin(context) || !context.mounted) return false;
if (_textbookOwner == profile.value?.id &&
UserStore.to.hasToken &&
UserStore.to.isLogin) {
return true;
}
final epoch = _sessionEpoch;
final owner = profile.value?.id;
final tgc = await readTgc();
if (tgc == null) {
throw StateError('请先登录');
}
final data = await _textbookRequest('exchange', {'tgc': tgc});
if (data['status'] != 'bound') {
throw StateError('学堂登录未完成,请稍后重试');
}
if (epoch != _sessionEpoch) return false;
final member = UserModel.fromJson(data['member']);
if ((member.token ?? '').isEmpty) throw StateError('教材登录失败');
await UserStore.to.login(member);
if (epoch != _sessionEpoch) {
await UserStore.to.logout();
return false;
}
_textbookOwner = owner;
return true;
} catch (error) {
if (context.mounted) {
PortalToast.show(context, errorMessage(error),
type: PortalToastType.error);
}
return false;
}
}
static String errorMessage(Object error) {
if (error is StateError) return error.message.toString();
if (error is FormatException) return error.message;
if (error is HttpException) {
final message = error.message.trim();
return message.isEmpty ? 'HTTP 请求异常,请检查服务地址和网络' : message;
}
if (error is DioException) {
final response = error.response;
final body = response?.data;
if (body is Map) {
final message = body['message'] ?? body['msg'] ?? body['error'];
if (message is String && message.trim().isNotEmpty) {
return message.trim();
}
}
if (response?.statusCode case final status?) {
if (status == 405 && response != null) {
final allow = response.headers.value('allow');
final path = response.requestOptions.uri.path;
return allow == null || allow.isEmpty
? '登录服务拒绝请求方法(HTTP 405:${response.requestOptions.method} $path)'
: '登录服务拒绝请求方法(HTTP 405:${response.requestOptions.method} $path;允许:$allow)';
}
return '登录服务请求失败(HTTP $status)';
}
return switch (error.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout =>
'连接登录服务超时,请检查网络后重试',
DioExceptionType.connectionError => '无法连接登录服务,请检查网络后重试',
DioExceptionType.badCertificate => '登录服务证书校验失败',
DioExceptionType.cancel => '登录请求已取消',
_ => '登录服务连接异常(${error.type.name})',
};
}
return '操作失败(${error.runtimeType}),请稍后重试';
}
Future<void> logout() async {
if (_logoutRequest != null) return _logoutRequest!;
final pending = _logout();
_logoutRequest = pending;
try {
await pending;
} finally {
_logoutRequest = null;
}
}
Future<void> _logout() async {
_sessionEpoch++;
_profileRequest = null;
final tgc = await readTgc();
final values = {
'tgc': tgc ?? '',
'textbook_token': UserStore.to.token,
'textbook_access_token': UserStore.to.accessToken
};
final cancel = CancelToken();
final timer = Timer(const Duration(seconds: 2), () => cancel.cancel());
final remote = () async {
try {
await _textbookRequest('logout', values,
cancelToken: cancel, silent: true);
} catch (_) {
// A network outage must not prevent local sign-out.
} finally {
timer.cancel();
}
}();
profile.value = null;
_profileLoadedAt = null;
_textbookOwner = null;
await UserStore.to.logout();
final cleanup = _clearWebSessions();
_cleanupRequest = cleanup;
try {
await cleanup.timeout(const Duration(seconds: 3));
} on TimeoutException {
throw StateError('网页会话正在清理,请稍后重试');
} finally {
await remote;
}
}
Future<void> _clearWebSessions() async {
try {
await _cookies.deleteAllCookies();
final storage = WebStorageManager.instance();
if (Platform.isIOS) {
await storage.removeDataModifiedSince(
dataTypes: WebsiteDataType.ALL,
date: DateTime.fromMillisecondsSinceEpoch(0));
} else {
await storage.deleteAllData();
}
} finally {
_cleanupRequest = null;
}
}
Future<void> _awaitCleanup() async {
final logout = _logoutRequest;
if (logout != null) await logout;
final pending = _cleanupRequest;
if (pending == null) return;
try {
await pending.timeout(const Duration(seconds: 3));
} on TimeoutException {
throw StateError('网页会话正在清理,请稍后重试');
}
}
}
......@@ -7,6 +7,7 @@ import 'package:flutter_book/services/index.dart';
import 'package:flutter_book/theme.dart';
import 'package:get/get.dart';
import '../utils/app_mode.dart';
import '../models/index.dart';
import '../utils/index.dart';
......
......@@ -13,6 +13,15 @@ class UserStore extends GetxController {
final RefreshTokenRequest? _refreshTokenRequest;
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;
......@@ -38,33 +47,34 @@ class UserStore extends GetxController {
super.onInit();
_token = StorageService.to.getString(kLocalToken);
_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 {
_token = value.token ?? '';
_accessToken = value.accessToken ?? '';
await StorageService.to.setString(kLocalToken, _token);
await StorageService.to
.setString(kLocalAccessToken, value.accessToken ?? '');
await StorageService.to
.setString(kLocalUserInfo, jsonEncode(value.toJson()));
_sessionExpired = false;
_info = value;
_isLogin = true;
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;
final epoch = ++_sessionEpoch;
_refreshingToken = null;
await _writeSession(() async {
if (epoch != _sessionEpoch) return;
await StorageService.to.setString(kLocalToken, value.token ?? '');
await StorageService.to
.setString(kLocalAccessToken, value.accessToken ?? '');
await StorageService.to
.setString(kLocalUserInfo, jsonEncode(value.toJson()));
if (epoch != _sessionEpoch) return;
_token = value.token ?? '';
_accessToken = value.accessToken ?? '';
_sessionExpired = false;
_info = value;
_isLogin = true;
update();
});
}
/// 设置用户信息
......@@ -92,25 +102,36 @@ class UserStore extends GetxController {
}
Future<void> _clearSession() async {
_sessionEpoch++;
_refreshingToken = null;
_token = '';
_accessToken = '';
_isLogin = false;
await Future.wait([
StorageService.to.remove(kLocalToken),
StorageService.to.remove(kLocalAccessToken),
StorageService.to.remove(kLocalUserInfo),
]);
_info = UserModel();
update();
await _writeSession(() async {
await Future.wait([
StorageService.to.remove(kLocalToken),
StorageService.to.remove(kLocalAccessToken),
StorageService.to.remove(kLocalUserInfo),
]);
});
}
Future<String?>? _refreshingToken;
/// 多个请求同时过期时,共用同一次 token 刷新。
Future<String?> refreshToken() =>
_refreshingToken ??= _requestNewToken().whenComplete(() {
_refreshingToken = null;
});
Future<String?> refreshToken() {
if (_refreshingToken != null) return _refreshingToken!;
final pending = _requestNewToken();
_refreshingToken = pending;
return pending.whenComplete(() {
if (identical(_refreshingToken, pending)) _refreshingToken = null;
});
}
Future<String?> _requestNewToken() async {
final epoch = _sessionEpoch;
final accessToken = _accessToken.isNotEmpty
? _accessToken
: StorageService.to.getString(kLocalAccessToken);
......@@ -128,20 +149,20 @@ class UserStore extends GetxController {
final newAccessToken = data['access_token'] as String;
if (newToken.isEmpty) return null;
await setToken(newToken);
if (newAccessToken.isNotEmpty) {
await setAccessToken(newAccessToken);
}
return newToken;
await _writeSession(() async {
if (epoch != _sessionEpoch) return;
await StorageService.to.setString(kLocalToken, newToken);
if (newAccessToken.isNotEmpty) {
await StorageService.to.setString(kLocalAccessToken, newAccessToken);
}
if (epoch != _sessionEpoch) return;
_token = newToken;
if (newAccessToken.isNotEmpty) _accessToken = newAccessToken;
});
return epoch == _sessionEpoch ? newToken : null;
} on Exception catch (error, stackTrace) {
Console.log('刷新 token 失败: $error\n$stackTrace');
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;
// App 接口地址。H5 接口地址由 H5 自己配置。
const String kServerUrl = 'https://project-api.ezijing.com/api/book';
// const String kServerUrl = 'http://172.16.51.175:7421';
const String kServerUrl = kDebugMode
? '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 kLocalToken = 'local_token';
const String kLocalAccessToken = 'local_access_token';
......
......@@ -5,6 +5,7 @@ class CustomAppBar extends AppBar {
Key? key,
bool automaticallyImplyLeading = true,
Widget? title,
Widget? leading,
List<Widget>? actions,
bool? centerTitle,
double? titleSpacing,
......@@ -13,6 +14,7 @@ class CustomAppBar extends AppBar {
}) : super(
key: key,
title: title,
leading: leading,
titleSpacing: titleSpacing,
systemOverlayStyle: systemOverlayStyle,
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 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论