feat: 完成智能电网电工端 Flutter 应用初始化
创建完整的 Flutter 项目结构,包含 Android 和 iOS 平台配置、核心网络层、路由、状态管理及业务模块(订单、收益、认证),适配原 uni-app 项目功能逻辑
This commit is contained in:
commit
56830e94ec
108 files changed
+9272
No files matched your search
@@ -0,0 +1,149 @@
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/utils/id_util.dart';
|
||||
|
||||
/// 站内消息。对应后端 `sys_notice` 的电工端视图。
|
||||
///
|
||||
/// 后端 `toJSON()` 返回原始列名,时间字段为下划线命名 `created_at`,
|
||||
/// `is_read` 为 0/1。
|
||||
class Notice {
|
||||
const Notice({
|
||||
required this.id,
|
||||
required this.noticeType,
|
||||
required this.title,
|
||||
required this.content,
|
||||
required this.bizType,
|
||||
required this.bizId,
|
||||
required this.receiverType,
|
||||
required this.ruleId,
|
||||
required this.batchId,
|
||||
required this.isRead,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final int id;
|
||||
|
||||
/// 通知类型,如 `cert_expire`。
|
||||
final String noticeType;
|
||||
|
||||
final String title;
|
||||
final String content;
|
||||
|
||||
/// 业务类型,如 `worker_cert`。
|
||||
final String bizType;
|
||||
|
||||
/// 业务主键。
|
||||
final int? bizId;
|
||||
|
||||
/// `user` 管理端 / `worker` 电工端。
|
||||
final String receiverType;
|
||||
|
||||
/// 来源通知规则 / 群发批次,可能为空。
|
||||
final int? ruleId;
|
||||
final int? batchId;
|
||||
|
||||
final bool isRead;
|
||||
|
||||
/// 原始 `created_at`,交给 `format_util` 渲染。
|
||||
final Object? createdAt;
|
||||
|
||||
factory Notice.fromJson(Map<String, dynamic> json) => Notice(
|
||||
id: toIntOr(json['id'], 0),
|
||||
noticeType: json['noticeType'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
content: json['content'] as String? ?? '',
|
||||
bizType: json['bizType'] as String? ?? '',
|
||||
bizId: toIntOrNull(json['bizId']),
|
||||
receiverType: json['receiverType'] as String? ?? '',
|
||||
ruleId: toIntOrNull(json['ruleId']),
|
||||
batchId: toIntOrNull(json['batchId']),
|
||||
isRead: _toBool(json['isRead']),
|
||||
createdAt: json['created_at'],
|
||||
);
|
||||
|
||||
/// 复制为已读,用于标记成功后本地更新,避免整页重拉。
|
||||
Notice copiedWithRead() => Notice(
|
||||
id: id,
|
||||
noticeType: noticeType,
|
||||
title: title,
|
||||
content: content,
|
||||
bizType: bizType,
|
||||
bizId: bizId,
|
||||
receiverType: receiverType,
|
||||
ruleId: ruleId,
|
||||
batchId: batchId,
|
||||
isRead: true,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// 消息分页结果。
|
||||
class NoticePage {
|
||||
const NoticePage({
|
||||
required this.list,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
});
|
||||
|
||||
final List<Notice> list;
|
||||
final int total;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
|
||||
factory NoticePage.fromJson(Map<String, dynamic> json) => NoticePage(
|
||||
list: (json['list'] as List? ?? const [])
|
||||
.map((e) => Notice.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(),
|
||||
total: toIntOr(json['total'], 0),
|
||||
page: toIntOr(json['page'], 1),
|
||||
pageSize: toIntOr(json['pageSize'], 10),
|
||||
);
|
||||
}
|
||||
|
||||
/// 站内消息中心接口,对应后端 `/api/worker-app/notices*`。
|
||||
class NoticeApi {
|
||||
NoticeApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
/// 消息列表。`isRead` 传 true/false 时按已读状态筛选,null 取全部。
|
||||
///
|
||||
/// 后端 `Number(query.isRead)` 解析,故这里显式传 1 / 0。
|
||||
Future<ApiResponse<NoticePage>> list({
|
||||
int page = 1,
|
||||
int pageSize = 10,
|
||||
bool? isRead,
|
||||
}) => _client.get<NoticePage>(
|
||||
'/worker-app/notices',
|
||||
query: {
|
||||
'page': page,
|
||||
'pageSize': pageSize,
|
||||
if (isRead != null) 'isRead': isRead ? 1 : 0,
|
||||
},
|
||||
parse: (d) => NoticePage.fromJson((d as Map).cast<String, dynamic>()),
|
||||
);
|
||||
|
||||
/// 未读消息数。
|
||||
Future<ApiResponse<int>> unreadCount() => _client.get<int>(
|
||||
'/worker-app/notices/unread-count',
|
||||
parse: (d) => toIntOr((d as Map)['count'], 0),
|
||||
);
|
||||
|
||||
/// 全部标记已读,返回更新条数。
|
||||
Future<ApiResponse<int>> readAll() => _client.put<int>(
|
||||
'/worker-app/notices/read-all',
|
||||
parse: (d) => toIntOr((d as Map)['updated'], 0),
|
||||
);
|
||||
|
||||
/// 单条标记已读。
|
||||
Future<ApiResponse<void>> markRead(int id) =>
|
||||
_client.put<void>('/worker-app/notices/$id/read');
|
||||
}
|
||||
|
||||
bool _toBool(Object? v) {
|
||||
if (v is bool) return v;
|
||||
if (v is num) return v != 0;
|
||||
final s = v?.toString().toLowerCase();
|
||||
return s == '1' || s == 'true';
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/utils/format_util.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_card.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_top_bar.dart';
|
||||
import '../data/notice_api.dart';
|
||||
|
||||
/// 消息中心:站内通知列表 + 已读标记 + 分页加载。
|
||||
///
|
||||
/// 列表在页面内维护(分页游标语义强于 Riverpod 缓存),未读数经
|
||||
/// [noticeUnreadCountProvider] 统一刷新。
|
||||
class NoticeCenterPage extends ConsumerStatefulWidget {
|
||||
const NoticeCenterPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<NoticeCenterPage> createState() => _NoticeCenterPageState();
|
||||
}
|
||||
|
||||
class _NoticeCenterPageState extends ConsumerState<NoticeCenterPage> {
|
||||
static const _pageSize = 10;
|
||||
|
||||
final _scroll = ScrollController();
|
||||
final _items = <Notice>[];
|
||||
|
||||
int _page = 0;
|
||||
bool _loading = false;
|
||||
bool _loadingMore = false;
|
||||
bool _hasMore = true;
|
||||
String? _error;
|
||||
|
||||
bool get _hasUnread => _items.any((n) => !n.isRead);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scroll.addListener(_onScroll);
|
||||
_load(reset: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scroll
|
||||
..removeListener(_onScroll)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (!_scroll.hasClients) return;
|
||||
final position = _scroll.position;
|
||||
if (position.pixels >= position.maxScrollExtent - 200) _loadMore();
|
||||
}
|
||||
|
||||
/// 加载一页。`reset` 为真时回到第 1 页(下拉刷新 / 重试)。
|
||||
Future<void> _load({bool reset = false}) async {
|
||||
if (reset) {
|
||||
if (_loading) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
} else {
|
||||
if (_loading || _loadingMore || !_hasMore) return;
|
||||
setState(() => _loadingMore = true);
|
||||
}
|
||||
|
||||
final nextPage = reset ? 1 : _page + 1;
|
||||
try {
|
||||
final res = await ref
|
||||
.read(noticeApiProvider)
|
||||
.list(page: nextPage, pageSize: _pageSize);
|
||||
if (!mounted) return;
|
||||
if (!res.isOk || res.data == null) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_loadingMore = false;
|
||||
if (reset) _error = res.message.isEmpty ? '消息加载失败' : res.message;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final data = res.data!;
|
||||
setState(() {
|
||||
if (reset) _items.clear();
|
||||
_items.addAll(data.list);
|
||||
_page = data.page;
|
||||
_hasMore = data.list.isNotEmpty && _items.length < data.total;
|
||||
_loading = false;
|
||||
_loadingMore = false;
|
||||
_error = null;
|
||||
});
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_loadingMore = false;
|
||||
if (reset) _error = e.message;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() => _load();
|
||||
|
||||
Future<void> _markRead(Notice notice) async {
|
||||
if (notice.isRead) return;
|
||||
try {
|
||||
final res = await ref.read(noticeApiProvider).markRead(notice.id);
|
||||
if (!mounted) return;
|
||||
if (!res.isOk) {
|
||||
showAppToast(res.message.isEmpty ? '操作失败' : res.message);
|
||||
return;
|
||||
}
|
||||
final index = _items.indexWhere((n) => n.id == notice.id);
|
||||
if (index >= 0) {
|
||||
setState(() => _items[index] = _items[index].copiedWithRead());
|
||||
}
|
||||
ref.read(noticeUnreadCountProvider.notifier).refresh();
|
||||
} on ApiException catch (e) {
|
||||
if (mounted) showAppToast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markAllRead() async {
|
||||
if (!_hasUnread) return;
|
||||
try {
|
||||
final res = await ref.read(noticeApiProvider).readAll();
|
||||
if (!mounted) return;
|
||||
if (!res.isOk) {
|
||||
showAppToast(res.message.isEmpty ? '操作失败' : res.message);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
for (var i = 0; i < _items.length; i++) {
|
||||
if (!_items[i].isRead) _items[i] = _items[i].copiedWithRead();
|
||||
}
|
||||
});
|
||||
showAppToast('已全部标记为已读', success: true);
|
||||
ref.read(noticeUnreadCountProvider.notifier).refresh();
|
||||
} on ApiException catch (e) {
|
||||
if (mounted) showAppToast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surfaceContainerLow,
|
||||
body: XPageShell(
|
||||
backgroundColor: AppColors.surfaceContainerLow,
|
||||
topBar: XTopBar.nav(
|
||||
title: '消息中心',
|
||||
onBack: () => context.pop(),
|
||||
trailing: _hasUnread
|
||||
? GestureDetector(
|
||||
onTap: _markAllRead,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||
child: Text(
|
||||
'全部已读',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
child: _buildBody(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_loading && _items.isEmpty) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.4,
|
||||
valueColor: AlwaysStoppedAnimation(AppColors.primary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_items.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
color: AppColors.primary,
|
||||
onRefresh: () => _load(reset: true),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
if (_error != null)
|
||||
XPlaceholder(text: '消息加载失败\n$_error')
|
||||
else
|
||||
const XPlaceholder(text: '暂无消息'),
|
||||
if (_error != null)
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () => _load(reset: true),
|
||||
child: const Text('点击重试'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
color: AppColors.primary,
|
||||
onRefresh: () => _load(reset: true),
|
||||
child: ListView.separated(
|
||||
controller: _scroll,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
itemCount: _items.length + 1,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _items.length) return _buildFooter();
|
||||
final notice = _items[index];
|
||||
return _NoticeTile(
|
||||
notice: notice,
|
||||
onTap: notice.isRead ? null : () => _markRead(notice),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
if (_loadingMore) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_hasMore) {
|
||||
return GestureDetector(
|
||||
onTap: () => _loadMore(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'加载更多',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'没有更多了',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条消息卡片:未读有蓝底高亮 + 红点,点击标记已读。
|
||||
class _NoticeTile extends StatelessWidget {
|
||||
const _NoticeTile({required this.notice, this.onTap});
|
||||
|
||||
final Notice notice;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final unread = !notice.isRead;
|
||||
return XCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
background: unread
|
||||
? AppColors.accentSubtle
|
||||
: AppColors.surfaceContainerLowest,
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (unread)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 7, right: 8),
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.error,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
notice.title.isEmpty ? '系统通知' : notice.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: unread ? FontWeight.w600 : FontWeight.w500,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
formatDateTime(notice.createdAt),
|
||||
style: AppTypography.labelCaps.copyWith(
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (notice.content.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: unread ? 16 : 0),
|
||||
child: Text(
|
||||
notice.content,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user