diff --git a/client/lib/services/notices_api.dart b/client/lib/services/notices_api.dart new file mode 100644 index 0000000..be0b665 --- /dev/null +++ b/client/lib/services/notices_api.dart @@ -0,0 +1,92 @@ +// notices_api.dart — 系统通知代理端点封装(JWT 经 ApiClient 自动注入)。 +import '../l10n/app_text.dart'; +import 'api_client.dart'; + +class NoticeItem { + const NoticeItem({ + required this.id, + required this.type, + required this.titleZh, + required this.titleEn, + required this.bodyZh, + required this.bodyEn, + required this.link, + required this.publishedAt, + required this.unread, + }); + + final int id; + final String type, titleZh, titleEn, bodyZh, bodyEn, link; + final DateTime? publishedAt; + final bool unread; + + /// 单显文案:zh 取中文,非 zh(en/ja/ko/ru/es)一律取英文兜底。 + String title(AppLang lang) => lang == AppLang.zh ? titleZh : titleEn; + + factory NoticeItem.fromJson(Map j) { + DateTime? published; + final raw = j['published_at'] as String?; + if (raw != null && raw.isNotEmpty) { + try { + published = DateTime.parse(raw); + } catch (_) { + published = null; + } + } + return NoticeItem( + id: (j['id'] as num?)?.toInt() ?? 0, + type: j['type'] as String? ?? '', + titleZh: j['title_zh'] as String? ?? '', + titleEn: j['title_en'] as String? ?? '', + bodyZh: j['body_zh'] as String? ?? '', + bodyEn: j['body_en'] as String? ?? '', + link: j['link'] as String? ?? '', + publishedAt: published, + unread: j['unread'] as bool? ?? false, + ); + } + + NoticeItem copyWith({bool? unread}) => NoticeItem( + id: id, + type: type, + titleZh: titleZh, + titleEn: titleEn, + bodyZh: bodyZh, + bodyEn: bodyEn, + link: link, + publishedAt: publishedAt, + unread: unread ?? this.unread, + ); +} + +class NoticesData { + const NoticesData({required this.items, required this.unreadCount}); + + final List items; + final int unreadCount; + + factory NoticesData.fromJson(Map j) { + final rawItems = j['notices'] as List? ?? const []; + return NoticesData( + items: rawItems + .whereType>() + .map(NoticeItem.fromJson) + .toList(), + unreadCount: (j['unread_count'] as num?)?.toInt() ?? 0, + ); + } + + NoticesData copyWith({List? items, int? unreadCount}) => + NoticesData(items: items ?? this.items, unreadCount: unreadCount ?? this.unreadCount); +} + +class NoticesApi { + NoticesApi(this._c); + final ApiClient _c; + + Future fetch() async => NoticesData.fromJson(await _c.getJson('/v1/notices')); + + Future markRead() async { + await _c.postJson('/v1/notices/read'); + } +} diff --git a/client/lib/state/notices_provider.dart b/client/lib/state/notices_provider.dart new file mode 100644 index 0000000..bbc808e --- /dev/null +++ b/client/lib/state/notices_provider.dart @@ -0,0 +1,56 @@ +// notices_provider.dart — 系统通知状态装配:复用 apiClientProvider,未登录不打网络。 +// +// 对照 invite_provider.dart(装配模式:未登录 build 返回 null、不打网络)与 +// update_provider.dart(延迟首拉的可取消 Timer 模式)。回前台重拉由 Task 10 的 +// 消费方(WidgetsBindingObserver)调用 refresh(),本 provider 只暴露 refresh()。 +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../services/notices_api.dart'; +import 'account_providers.dart'; +import 'auth_provider.dart'; + +/// 首次拉取前的延迟(避开登录/首屏竞争)。 +const _kInitialDelay = Duration(seconds: 2); + +final noticesApiProvider = Provider((ref) => NoticesApi(ref.watch(apiClientProvider))); + +class NoticesNotifier extends AsyncNotifier { + Timer? _initialTimer; + + @override + Future build() async { + ref.onDispose(() => _initialTimer?.cancel()); + + // 未登录返回 null(不打网络)。 + final token = ref.watch(authProvider).accessToken; + if (token == null || token.isEmpty) return null; + + // 延迟首拉:用可取消的 Timer(而非 Future.delayed,其内部 timer 无法取消, + // provider 在延迟期间被 dispose 时会悬挂)。 + final ready = Completer(); + _initialTimer = Timer(_kInitialDelay, ready.complete); + await ready.future; // 若延迟期间被 dispose,_initialTimer 取消 → 永不 complete,build 中止 + + return ref.read(noticesApiProvider).fetch(); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() => ref.read(noticesApiProvider).fetch()); + } + + /// 标记全部已读:调服务端成功后,本地把 unreadCount 置 0、items 的 unread 置 false。 + Future markAllRead() async { + final current = state.value; + if (current == null) return; + await ref.read(noticesApiProvider).markRead(); + state = AsyncValue.data(current.copyWith( + items: current.items.map((n) => n.copyWith(unread: false)).toList(), + unreadCount: 0, + )); + } +} + +final noticesProvider = AsyncNotifierProvider(NoticesNotifier.new); diff --git a/client/test/unit/notices_api_test.dart b/client/test/unit/notices_api_test.dart new file mode 100644 index 0000000..d1c7124 --- /dev/null +++ b/client/test/unit/notices_api_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pangolin_vpn/l10n/app_text.dart'; +import 'package:pangolin_vpn/services/api_client.dart'; +import 'package:pangolin_vpn/services/notices_api.dart'; + +ApiClient _c(MockClient m) => ApiClient(baseUrl: 'http://x', getToken: () => 't', refresh: () async => false, client: m); + +const _utf8Json = {'content-type': 'application/json; charset=utf-8'}; + +void main() { + test('fetch 解析公告列表 + unread_count(双语字段)', () async { + final api = NoticesApi(_c(MockClient((req) async { + expect(req.url.path, '/v1/notices'); + return http.Response( + '{"notices":[' + '{"id":1,"type":"maintenance","title_zh":"维护通知","title_en":"Maintenance",' + '"body_zh":"今晚维护","body_en":"Tonight maintenance","link":"https://x/1",' + '"published_at":"2026-07-10T08:00:00Z","unread":true},' + '{"id":2,"type":"promo","title_zh":"活动","title_en":"Promo",' + '"published_at":"2026-07-09T08:00:00Z","unread":false}' + '],"unread_count":1}', + 200, + headers: _utf8Json); + }))); + final data = await api.fetch(); + expect(data.items.length, 2); + expect(data.unreadCount, 1); + + final first = data.items[0]; + expect(first.id, 1); + expect(first.type, 'maintenance'); + expect(first.titleZh, '维护通知'); + expect(first.titleEn, 'Maintenance'); + expect(first.bodyZh, '今晚维护'); + expect(first.bodyEn, 'Tonight maintenance'); + expect(first.link, 'https://x/1'); + expect(first.publishedAt, DateTime.parse('2026-07-10T08:00:00Z')); + expect(first.unread, true); + expect(first.title(AppLang.zh), '维护通知'); + expect(first.title(AppLang.en), 'Maintenance'); + + final second = data.items[1]; + expect(second.id, 2); + expect(second.bodyZh, ''); + expect(second.bodyEn, ''); + expect(second.link, ''); + expect(second.unread, false); + // 非 zh 语言取 en 兜底 + expect(second.title(AppLang.ja), second.titleEn); + }); + + test('fetch 缺字段安全默认(不 crash)', () async { + final api = NoticesApi(_c(MockClient((req) async { + return http.Response('{}', 200); + }))); + final data = await api.fetch(); + expect(data.items, isEmpty); + expect(data.unreadCount, 0); + }); + + test('markRead 打 POST /v1/notices/read', () async { + var called = false; + final api = NoticesApi(_c(MockClient((req) async { + called = true; + expect(req.method, 'POST'); + expect(req.url.path, '/v1/notices/read'); + return http.Response('{"ok":true}', 200); + }))); + await api.markRead(); + expect(called, true); + }); +}