feat: 添加消息中心及顶栏未读角标功能
新增消息中心页面,实现未读消息轮询与展示 为各 Tab 页面顶栏添加通知图标及未读数角标 新增通知 API 接口与未读数状态管理 实现消息标记已读、全部已读及下拉刷新功能
This commit is contained in:
1 parent
66013ee069
commit
7ad604ff8b
9 files changed
+574
-4
No files matched your search
@@ -7,6 +7,7 @@ import '../features/auth/application/session_controller.dart';
|
|||||||
import '../features/auth/presentation/login_page.dart';
|
import '../features/auth/presentation/login_page.dart';
|
||||||
import '../features/customer/presentation/customer_detail_page.dart';
|
import '../features/customer/presentation/customer_detail_page.dart';
|
||||||
import '../features/customer/presentation/customer_edit_page.dart';
|
import '../features/customer/presentation/customer_edit_page.dart';
|
||||||
|
import '../features/notice/presentation/notice_center_page.dart';
|
||||||
import '../features/project/presentation/project_detail_page.dart';
|
import '../features/project/presentation/project_detail_page.dart';
|
||||||
import '../features/survey/presentation/survey_form_page.dart';
|
import '../features/survey/presentation/survey_form_page.dart';
|
||||||
import '../features/survey/presentation/survey_list_page.dart';
|
import '../features/survey/presentation/survey_list_page.dart';
|
||||||
@@ -50,6 +51,12 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
|
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
|
||||||
GoRoute(path: '/tabs', builder: (context, state) => const TabsPage()),
|
GoRoute(path: '/tabs', builder: (context, state) => const TabsPage()),
|
||||||
|
|
||||||
|
// 消息中心(二级页)
|
||||||
|
GoRoute(
|
||||||
|
path: '/notice',
|
||||||
|
builder: (context, state) => const NoticeCenterPage(),
|
||||||
|
),
|
||||||
|
|
||||||
// 客户模块
|
// 客户模块
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/customer/detail/:id',
|
path: '/customer/detail/:id',
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../features/auth/application/session_controller.dart';
|
||||||
|
import '../features/notice/data/notice_api.dart';
|
||||||
import 'env.dart';
|
import 'env.dart';
|
||||||
import 'network/api_client.dart';
|
import 'network/api_client.dart';
|
||||||
import 'storage/token_storage.dart';
|
import 'storage/token_storage.dart';
|
||||||
@@ -53,3 +57,37 @@ final apiClientProvider = Provider<ApiClient>((ref) {
|
|||||||
ref.onDispose(client.dispose);
|
ref.onDispose(client.dispose);
|
||||||
return client;
|
return client;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 站内消息未读数,供顶栏铃铛红点使用。
|
||||||
|
///
|
||||||
|
/// 仅在登录态下启用:登录后立即拉取一次,并按 60s 轮询(对齐 Web 端);
|
||||||
|
/// 登录态变化时自动重建,未登录返回 0 并停止轮询。
|
||||||
|
class NoticeUnreadCountNotifier extends Notifier<int> {
|
||||||
|
@override
|
||||||
|
int build() {
|
||||||
|
final loggedIn = ref.watch(sessionProvider.select((s) => s.isLoggedIn));
|
||||||
|
if (!loggedIn) return 0;
|
||||||
|
|
||||||
|
final timer = Timer.periodic(const Duration(seconds: 60), (_) => refresh());
|
||||||
|
ref.onDispose(timer.cancel);
|
||||||
|
// 首帧后立即拉取一次,避免等满 60s 才出现红点。
|
||||||
|
Future.microtask(refresh);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 立即刷新未读数;失败时保留上次结果(静默,不打扰用户)。
|
||||||
|
Future<void> refresh() async {
|
||||||
|
try {
|
||||||
|
final res = await ref.read(noticeApiProvider).unreadCount();
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
if (res.isOk && res.data != null) state = res.data!;
|
||||||
|
} catch (_) {
|
||||||
|
// 轮询失败:保留上次未读数
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final noticeUnreadCountProvider =
|
||||||
|
NotifierProvider<NoticeUnreadCountNotifier, int>(
|
||||||
|
NoticeUnreadCountNotifier.new,
|
||||||
|
);
|
||||||
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../app/tab_index.dart';
|
import '../../../app/tab_index.dart';
|
||||||
|
import '../../../core/providers.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/theme/app_typography.dart';
|
import '../../../core/theme/app_typography.dart';
|
||||||
@@ -134,6 +135,8 @@ class _CustomerTabState extends ConsumerState<CustomerTab> {
|
|||||||
topBar: XTopBar.brand(
|
topBar: XTopBar.brand(
|
||||||
title: '鑫潼智电',
|
title: '鑫潼智电',
|
||||||
trailingColor: AppColors.primary,
|
trailingColor: AppColors.primary,
|
||||||
|
onTrailingTap: () => context.push('/notice'),
|
||||||
|
trailingBadge: ref.watch(noticeUnreadCountProvider),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../app/tab_index.dart';
|
import '../../../app/tab_index.dart';
|
||||||
|
import '../../../core/providers.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../shared/widgets/app_feedback.dart';
|
import '../../../shared/widgets/app_feedback.dart';
|
||||||
import '../../../shared/widgets/x_card.dart';
|
import '../../../shared/widgets/x_card.dart';
|
||||||
@@ -138,7 +139,11 @@ class _MineTabState extends ConsumerState<MineTab> {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return XPageShell(
|
return XPageShell(
|
||||||
topBar: XTopBar.brand(title: '我的'),
|
topBar: XTopBar.brand(
|
||||||
|
title: '我的',
|
||||||
|
onTrailingTap: () => context.push('/notice'),
|
||||||
|
trailingBadge: ref.watch(noticeUnreadCountProvider),
|
||||||
|
),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 130),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 130),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../core/network/api_client.dart';
|
||||||
|
import '../../../core/network/api_response.dart';
|
||||||
|
import '../../../core/providers.dart';
|
||||||
|
import '../../../core/utils/id_util.dart';
|
||||||
|
|
||||||
|
final noticeApiProvider = Provider<NoticeApi>(
|
||||||
|
(ref) => NoticeApi(ref.watch(apiClientProvider)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 站内通知,与后端 `sys_notice` / `/api/notices` 契约对齐。
|
||||||
|
///
|
||||||
|
/// 管理端接口固定 `receiver_type='user'`,字段序列化为驼峰(主键 BIGINT 为字符串)。
|
||||||
|
class Notice {
|
||||||
|
const Notice({
|
||||||
|
required this.id,
|
||||||
|
required this.noticeType,
|
||||||
|
required this.title,
|
||||||
|
required this.content,
|
||||||
|
required this.bizType,
|
||||||
|
required this.bizId,
|
||||||
|
required this.isRead,
|
||||||
|
required this.receiverType,
|
||||||
|
required this.ruleId,
|
||||||
|
required this.batchId,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String noticeType;
|
||||||
|
final String title;
|
||||||
|
final String content;
|
||||||
|
final String bizType;
|
||||||
|
final int? bizId;
|
||||||
|
|
||||||
|
/// 0 未读 / 1 已读
|
||||||
|
final int isRead;
|
||||||
|
final String receiverType;
|
||||||
|
final int? ruleId;
|
||||||
|
final int? batchId;
|
||||||
|
final String? createdAt;
|
||||||
|
|
||||||
|
bool get unread => isRead == 0;
|
||||||
|
|
||||||
|
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']),
|
||||||
|
isRead: toIntOr(json['isRead'], 0),
|
||||||
|
receiverType: json['receiverType'] as String? ?? 'user',
|
||||||
|
ruleId: toIntOrNull(json['ruleId']),
|
||||||
|
batchId: toIntOrNull(json['batchId']),
|
||||||
|
createdAt: (json['created_at'] ?? json['createdAt']) as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Notice copyWith({int? isRead}) => Notice(
|
||||||
|
id: id,
|
||||||
|
noticeType: noticeType,
|
||||||
|
title: title,
|
||||||
|
content: content,
|
||||||
|
bizType: bizType,
|
||||||
|
bizId: bizId,
|
||||||
|
isRead: isRead ?? this.isRead,
|
||||||
|
receiverType: receiverType,
|
||||||
|
ruleId: ruleId,
|
||||||
|
batchId: batchId,
|
||||||
|
createdAt: createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/notices` 分页结果。
|
||||||
|
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, int fallbackPageSize) =>
|
||||||
|
NoticePage(
|
||||||
|
list: (json['list'] as List? ?? [])
|
||||||
|
.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'], fallbackPageSize),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 站内消息接口,对应后端 `notice.controller.ts`(管理端)。
|
||||||
|
class NoticeApi {
|
||||||
|
NoticeApi(this._client);
|
||||||
|
|
||||||
|
final ApiClient _client;
|
||||||
|
|
||||||
|
Future<ApiResponse<NoticePage>> list({
|
||||||
|
int page = 1,
|
||||||
|
int pageSize = 15,
|
||||||
|
int? isRead,
|
||||||
|
}) => _client.get<NoticePage>(
|
||||||
|
'/notices',
|
||||||
|
query: {'page': page, 'pageSize': pageSize, 'isRead': isRead},
|
||||||
|
parse: (d) =>
|
||||||
|
NoticePage.fromJson((d as Map).cast<String, dynamic>(), pageSize),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 未读数量(顶栏红点)。
|
||||||
|
Future<ApiResponse<int>> unreadCount() => _client.get<int>(
|
||||||
|
'/notices/unread-count',
|
||||||
|
parse: (d) => toIntOr((d as Map)['count'], 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 单条标记已读。
|
||||||
|
Future<ApiResponse<void>> markRead(int id) =>
|
||||||
|
_client.put<void>('/notices/$id/read');
|
||||||
|
|
||||||
|
/// 全部标记已读,返回本次更新条数。
|
||||||
|
Future<ApiResponse<int>> markAllRead() => _client.put<int>(
|
||||||
|
'/notices/read-all',
|
||||||
|
parse: (d) => toIntOr((d as Map)['updated'], 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.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_chip.dart';
|
||||||
|
import '../../../shared/widgets/x_common.dart';
|
||||||
|
import '../../../shared/widgets/x_icon.dart';
|
||||||
|
import '../../../shared/widgets/x_top_bar.dart';
|
||||||
|
import '../data/notice_api.dart';
|
||||||
|
|
||||||
|
/// 消息中心(二级页):列表 + 未读高亮 + 全部已读 + 下拉刷新 + 分页加载。
|
||||||
|
class NoticeCenterPage extends ConsumerStatefulWidget {
|
||||||
|
const NoticeCenterPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<NoticeCenterPage> createState() => _NoticeCenterPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoticeCenterPageState extends ConsumerState<NoticeCenterPage> {
|
||||||
|
static const int _pageSize = 15;
|
||||||
|
|
||||||
|
static const Map<String, String> _typeLabels = {
|
||||||
|
'cert_expire': '证照到期',
|
||||||
|
'contract_expire': '合同到期',
|
||||||
|
'worker_audit_result': '电工审核',
|
||||||
|
'dispatch_assign': '派单通知',
|
||||||
|
'visit_plan': '拜访计划',
|
||||||
|
'order_timeout': '工单超时',
|
||||||
|
'manual': '系统通知',
|
||||||
|
};
|
||||||
|
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
List<Notice> _list = const [];
|
||||||
|
int _total = 0;
|
||||||
|
int _page = 1;
|
||||||
|
bool _hasMore = true;
|
||||||
|
bool _loading = false;
|
||||||
|
bool _markingAll = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
_load(reset: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
final position = _scrollController.position;
|
||||||
|
if (position.pixels >= position.maxScrollExtent - 200) _loadMore();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load({bool reset = false}) async {
|
||||||
|
if (_loading) return;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
if (reset) {
|
||||||
|
_page = 1;
|
||||||
|
_hasMore = true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final res = await ref
|
||||||
|
.read(noticeApiProvider)
|
||||||
|
.list(page: _page, pageSize: _pageSize);
|
||||||
|
if (res.isOk && res.data != null && mounted) {
|
||||||
|
final data = res.data!;
|
||||||
|
setState(() {
|
||||||
|
_list = reset ? data.list : [..._list, ...data.list];
|
||||||
|
_total = data.total;
|
||||||
|
_hasMore = _list.length < data.total;
|
||||||
|
if (_hasMore) _page += 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) showAppToast('加载消息失败');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadMore() {
|
||||||
|
if (_hasMore && !_loading) _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refresh() async {
|
||||||
|
await _load(reset: true);
|
||||||
|
await ref.read(noticeUnreadCountProvider.notifier).refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markRead(Notice notice) async {
|
||||||
|
try {
|
||||||
|
final res = await ref.read(noticeApiProvider).markRead(notice.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!res.isOk) {
|
||||||
|
showAppToast(res.message.isEmpty ? '标记已读失败' : res.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_list = [
|
||||||
|
for (final e in _list) e.id == notice.id ? e.copyWith(isRead: 1) : e,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
unawaited(ref.read(noticeUnreadCountProvider.notifier).refresh());
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) showAppToast('标记已读失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _markAllRead() async {
|
||||||
|
if (_markingAll) return;
|
||||||
|
final ok = await showAppConfirm(
|
||||||
|
title: '全部已读',
|
||||||
|
content: '确定将全部消息标记为已读吗?',
|
||||||
|
);
|
||||||
|
if (!ok || !mounted) return;
|
||||||
|
_markingAll = true;
|
||||||
|
try {
|
||||||
|
final res = await ref.read(noticeApiProvider).markAllRead();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (res.isOk) {
|
||||||
|
setState(() {
|
||||||
|
_list = [for (final e in _list) e.copyWith(isRead: 1)];
|
||||||
|
});
|
||||||
|
unawaited(ref.read(noticeUnreadCountProvider.notifier).refresh());
|
||||||
|
showAppToast('已全部标记为已读', success: true);
|
||||||
|
} else {
|
||||||
|
showAppToast(res.message.isEmpty ? '操作失败' : res.message);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) showAppToast('操作失败');
|
||||||
|
} finally {
|
||||||
|
_markingAll = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _typeLabel(String type) =>
|
||||||
|
_typeLabels[type] ?? (type.isEmpty ? '通知' : type);
|
||||||
|
|
||||||
|
ChipTone _typeTone(String type) => switch (type) {
|
||||||
|
'cert_expire' || 'contract_expire' => ChipTone.warn,
|
||||||
|
'order_timeout' => ChipTone.error,
|
||||||
|
'manual' => ChipTone.primary,
|
||||||
|
_ => ChipTone.muted,
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final unread = ref.watch(noticeUnreadCountProvider);
|
||||||
|
|
||||||
|
return XPageShell(
|
||||||
|
topBar: XTopBar.nav(
|
||||||
|
title: '消息中心',
|
||||||
|
onBack: () => context.pop(),
|
||||||
|
trailing: unread > 0
|
||||||
|
? GestureDetector(
|
||||||
|
onTap: _markAllRead,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Text(
|
||||||
|
'全部已读',
|
||||||
|
style: AppTypography.labelBold.copyWith(
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: RefreshIndicator(
|
||||||
|
onRefresh: _refresh,
|
||||||
|
color: AppColors.primary,
|
||||||
|
child: ListView(
|
||||||
|
controller: _scrollController,
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 32),
|
||||||
|
children: _buildList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildList() {
|
||||||
|
if (_loading && _list.isEmpty) {
|
||||||
|
return const [XPlaceholder(text: '加载中...')];
|
||||||
|
}
|
||||||
|
if (_list.isEmpty) {
|
||||||
|
return const [XPlaceholder(text: '暂无消息')];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
for (final notice in _list) _buildCard(notice),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16, bottom: 4),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
_hasMore ? '加载中...' : '共 $_total 条',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCard(Notice notice) {
|
||||||
|
return XCard(
|
||||||
|
clip: true,
|
||||||
|
margin: const EdgeInsets.only(top: 12),
|
||||||
|
onTap: notice.unread ? () => _markRead(notice) : null,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Container(
|
||||||
|
width: 4,
|
||||||
|
color: notice.unread
|
||||||
|
? AppColors.primary
|
||||||
|
: Colors.transparent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 14, 14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (notice.unread) ...[
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 6),
|
||||||
|
child: _UnreadDot(),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
notice.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
XChip(
|
||||||
|
_typeLabel(notice.noticeType),
|
||||||
|
tone: _typeTone(notice.noticeType),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (notice.content.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6),
|
||||||
|
child: Text(
|
||||||
|
notice.content,
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
height: 1.4,
|
||||||
|
color: AppColors.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const XIcon(
|
||||||
|
'clock',
|
||||||
|
size: 14,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
formatDateTime(notice.createdAt),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (notice.unread)
|
||||||
|
Text(
|
||||||
|
'未读',
|
||||||
|
style: AppTypography.captionSm.copyWith(
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 未读消息左侧的实心圆点。
|
||||||
|
class _UnreadDot extends StatelessWidget {
|
||||||
|
const _UnreadDot();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: AppColors.statusError,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../app/tab_index.dart';
|
import '../../../app/tab_index.dart';
|
||||||
|
import '../../../core/providers.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/theme/app_typography.dart';
|
import '../../../core/theme/app_typography.dart';
|
||||||
@@ -131,7 +132,12 @@ class _ProjectTabState extends ConsumerState<ProjectTab> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return XPageShell(
|
return XPageShell(
|
||||||
topBar: XTopBar.brand(title: '鑫潼智电', trailingColor: AppColors.primary),
|
topBar: XTopBar.brand(
|
||||||
|
title: '鑫潼智电',
|
||||||
|
trailingColor: AppColors.primary,
|
||||||
|
onTrailingTap: () => context.push('/notice'),
|
||||||
|
trailingBadge: ref.watch(noticeUnreadCountProvider),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../../app/tab_index.dart';
|
import '../../../app/tab_index.dart';
|
||||||
|
import '../../../core/providers.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
import '../../../core/theme/app_dimens.dart';
|
||||||
import '../../../core/theme/app_typography.dart';
|
import '../../../core/theme/app_typography.dart';
|
||||||
@@ -136,7 +137,12 @@ class _WorkbenchTabState extends ConsumerState<WorkbenchTab> {
|
|||||||
: '工号: ${user.username.toUpperCase()}';
|
: '工号: ${user.username.toUpperCase()}';
|
||||||
|
|
||||||
return XPageShell(
|
return XPageShell(
|
||||||
topBar: XTopBar.brand(title: '鑫潼智电', trailingIcon: 'notification'),
|
topBar: XTopBar.brand(
|
||||||
|
title: '鑫潼智电',
|
||||||
|
trailingIcon: 'notification',
|
||||||
|
onTrailingTap: () => context.push('/notice'),
|
||||||
|
trailingBadge: ref.watch(noticeUnreadCountProvider),
|
||||||
|
),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 120),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 120),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class XTopBar extends StatelessWidget {
|
|||||||
String trailingIcon = 'notification',
|
String trailingIcon = 'notification',
|
||||||
Color trailingColor = AppColors.onSurfaceVariant,
|
Color trailingColor = AppColors.onSurfaceVariant,
|
||||||
VoidCallback? onTrailingTap,
|
VoidCallback? onTrailingTap,
|
||||||
|
int trailingBadge = 0,
|
||||||
}) {
|
}) {
|
||||||
return XTopBar._(
|
return XTopBar._(
|
||||||
title: title,
|
title: title,
|
||||||
@@ -46,7 +47,18 @@ class XTopBar extends StatelessWidget {
|
|||||||
trailing: GestureDetector(
|
trailing: GestureDetector(
|
||||||
onTap: onTrailingTap,
|
onTap: onTrailingTap,
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: XIcon(trailingIcon, size: 22, color: trailingColor),
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
XIcon(trailingIcon, size: 22, color: trailingColor),
|
||||||
|
if (trailingBadge > 0)
|
||||||
|
Positioned(
|
||||||
|
top: -6,
|
||||||
|
right: -8,
|
||||||
|
child: _TrailingBadge(count: trailingBadge),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
titleStyle: AppTypography.headlineMdMobile,
|
titleStyle: AppTypography.headlineMdMobile,
|
||||||
titleAlign: TextAlign.left,
|
titleAlign: TextAlign.left,
|
||||||
@@ -136,3 +148,34 @@ class XTopBar extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 顶栏图标右上角的未读角标:红底白字胶囊,超过 99 显示 `99+`。
|
||||||
|
class _TrailingBadge extends StatelessWidget {
|
||||||
|
const _TrailingBadge({required this.count});
|
||||||
|
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
constraints: const BoxConstraints(minWidth: 16),
|
||||||
|
height: 16,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.statusError,
|
||||||
|
borderRadius: BorderRadius.circular(9999),
|
||||||
|
border: Border.all(color: AppColors.surface, width: 1.5),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
count > 99 ? '99+' : '$count',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
height: 1,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.surface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user