From d87b2e1e3a02c3a316ee06ac845c2e9e4b1b05aa Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 3 Apr 2026 19:04:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E8=B5=B7=E5=8D=A6?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=B5=81=E7=A8=8B=E5=B9=B6=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E7=A7=AF=E5=88=86=E6=89=A3=E5=87=8F=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/AGENTS.md | 6 + apps/lib/app/app.dart | 33 +- apps/lib/core/network/api_problem_mapper.dart | 6 + apps/lib/data/network/api_client.dart | 16 + .../divination/data/apis/divination_api.dart | 230 ++++++++++++ .../models/divination_backend_models.dart | 347 ++++++++++++++++++ .../data/models/divination_params.dart | 13 - .../services/divination_result_builder.dart | 222 ----------- .../data/services/divination_run_service.dart | 147 ++++++++ .../screens/auto_divination_screen.dart | 29 +- .../screens/divination_processing_screen.dart | 184 ++++++++++ .../screens/divination_result_screen.dart | 282 +++++--------- .../screens/divination_screen.dart | 57 ++- .../screens/manual_divination_screen.dart | 254 +++++++++---- .../presentation/screens/home_screen.dart | 11 +- .../screens/general_settings_screen.dart | 61 +-- .../screens/legal_document_screen.dart | 52 ++- .../presentation/screens/settings_screen.dart | 54 +-- .../widgets/settings_section_widgets.dart | 76 ++-- apps/lib/l10n/app_en.arb | 7 +- apps/lib/l10n/app_localizations.dart | 30 ++ apps/lib/l10n/app_localizations_en.dart | 16 + apps/lib/l10n/app_localizations_zh.dart | 15 + apps/lib/l10n/app_zh.arb | 7 +- .../widgets/divination/divination_terms.dart | 6 + apps/pubspec.yaml | 3 +- .../divination_api_payload_test.dart | 108 ++++++ .../divination_backend_models_test.dart | 19 + .../divination/divination_params_test.dart | 20 +- .../divination_result_builder_test.dart | 36 -- .../divination_result_screen_test.dart | 173 ++++++++- .../divination/divination_screen_test.dart | 62 +++- .../manual_divination_screen_test.dart | 25 +- ...20260403_0004_remove_points_reason_code.py | 12 +- .../src/core/agentscope/events/agui_codec.py | 50 +-- .../core/agentscope/prompts/user_prompt.py | 18 + backend/src/core/agentscope/runtime/runner.py | 89 +++-- backend/src/core/divination/__init__.py | 3 + backend/src/core/divination/derivation.py | 281 ++++++++++++++ .../src/core/divination/gua_catalog_loader.py | 139 +++++++ backend/src/schemas/agent/forwarded_props.py | 12 + backend/src/schemas/domain/divination.py | 114 ++++++ backend/src/v1/agent/service.py | 12 + backend/src/v1/points/dependencies.py | 12 + backend/src/v1/points/repository.py | 11 + backend/src/v1/points/router.py | 28 ++ backend/src/v1/points/schemas.py | 13 + backend/src/v1/points/service.py | 26 ++ backend/src/v1/router.py | 2 + backend/tests/unit/test_agui_codec.py | 48 +++ .../2026-04-03-datetime-picker-design.md | 76 ++++ docs/plans/2026-04-03-datetime-picker-impl.md | 341 +++++++++++++++++ docs/protocols/common/http-error-codes.md | 1 + .../divination/divination-run-protocol.md | 172 +++++++++ .../points/points-balance-protocol.md | 48 +++ pyproject.toml | 4 + 56 files changed, 3310 insertions(+), 809 deletions(-) create mode 100644 apps/lib/features/divination/data/apis/divination_api.dart create mode 100644 apps/lib/features/divination/data/models/divination_backend_models.dart delete mode 100644 apps/lib/features/divination/data/services/divination_result_builder.dart create mode 100644 apps/lib/features/divination/data/services/divination_run_service.dart create mode 100644 apps/lib/features/divination/presentation/screens/divination_processing_screen.dart create mode 100644 apps/test/features/divination/divination_api_payload_test.dart create mode 100644 apps/test/features/divination/divination_backend_models_test.dart delete mode 100644 apps/test/features/divination/divination_result_builder_test.dart create mode 100644 backend/src/core/agentscope/prompts/user_prompt.py create mode 100644 backend/src/core/divination/__init__.py create mode 100644 backend/src/core/divination/derivation.py create mode 100644 backend/src/core/divination/gua_catalog_loader.py create mode 100644 backend/src/schemas/domain/divination.py create mode 100644 backend/src/v1/points/dependencies.py create mode 100644 backend/src/v1/points/router.py create mode 100644 backend/src/v1/points/schemas.py create mode 100644 backend/tests/unit/test_agui_codec.py create mode 100644 docs/plans/2026-04-03-datetime-picker-design.md create mode 100644 docs/plans/2026-04-03-datetime-picker-impl.md create mode 100644 docs/protocols/divination/divination-run-protocol.md create mode 100644 docs/protocols/points/points-balance-protocol.md diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 8f8bf8d..3efe608 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -45,6 +45,12 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable. - `AppTheme.light` / `AppTheme.dark` provide complete `ColorScheme` (light + dark). `MaterialApp` wires them via `theme:` / `darkTheme:`. - If a semantic slot is missing from `ColorScheme`, add it to `AppTheme` — do not bypass `colorScheme` with hardcoded values. +## Divination Terminology (Must) + +- Divination domain terminology must use fixed Chinese terms in code contracts, protocol fields, and UI semantic labels. +- Do not localize or translate canonical terms such as: 六爻、爻、动爻、静爻、六亲、六神、世爻、应爻、伏神、月建、日辰、月破、日冲、空亡、五行旺衰、上上签、中上签、中下签。 +- l10n can translate explanatory copy, but must not alter canonical divination terminology semantics. + ## Reuse & Composition (Must) - Prefer `apps/lib/shared/widgets/` before adding new components. diff --git a/apps/lib/app/app.dart b/apps/lib/app/app.dart index 453c472..7cce766 100644 --- a/apps/lib/app/app.dart +++ b/apps/lib/app/app.dart @@ -9,6 +9,7 @@ import '../features/auth/data/repositories/auth_repository.dart'; import '../features/auth/presentation/bloc/auth_bloc.dart'; import '../features/auth/presentation/bloc/auth_state.dart'; import '../features/auth/presentation/screens/login_screen.dart'; +import '../features/divination/data/apis/divination_api.dart'; import '../features/home/presentation/screens/home_screen.dart'; import '../features/settings/data/models/profile_settings.dart'; import '../l10n/app_localizations.dart'; @@ -26,10 +27,14 @@ class EryaoApp extends StatefulWidget { class _EryaoAppState extends State { final SessionStore _sessionStore = SessionStore(LocalKvStore()); late final AuthBloc _authBloc; + late final DivinationApi _divinationApi; Locale _locale = const Locale('zh'); ProfileSettingsV1 _profileSettings = ProfileSettingsV1.defaultsForLocale( const Locale('zh'), ); + int _creditsBalance = 0; + bool _loadingCredits = false; + String? _loadedCreditsUserEmail; @override void initState() { @@ -42,6 +47,7 @@ class _EryaoAppState extends State { }, ); final authApi = AuthApi(apiClient: apiClient); + _divinationApi = DivinationApi(apiClient: apiClient); final authRepository = AuthRepositoryImpl( authApi: authApi, sessionStore: _sessionStore, @@ -50,6 +56,30 @@ class _EryaoAppState extends State { _bootstrap(); } + void _ensureCreditsLoaded(String userEmail) { + if (_loadingCredits) { + return; + } + if (_loadedCreditsUserEmail == userEmail) { + return; + } + _loadingCredits = true; + _divinationApi + .getPointsBalance() + .then((balance) { + if (!mounted) { + return; + } + setState(() { + _creditsBalance = balance.availableBalance; + _loadedCreditsUserEmail = userEmail; + }); + }) + .whenComplete(() { + _loadingCredits = false; + }); + } + @override void dispose() { _authBloc.dispose(); @@ -118,12 +148,13 @@ class _EryaoAppState extends State { } if (state.status == AuthStatus.authenticated && state.user != null) { + _ensureCreditsLoaded(state.user!.email); return HomeScreen( account: state.user!.email, sessionStore: _sessionStore, currentLocale: _locale, profileSettings: _profileSettings, - coinBalance: 100, + coinBalance: _creditsBalance, onLocaleChanged: _handleInterfaceLanguageChanged, onLogout: _authBloc.logout, ); diff --git a/apps/lib/core/network/api_problem_mapper.dart b/apps/lib/core/network/api_problem_mapper.dart index ba623ee..14a61b5 100644 --- a/apps/lib/core/network/api_problem_mapper.dart +++ b/apps/lib/core/network/api_problem_mapper.dart @@ -11,6 +11,12 @@ String mapApiProblemToMessage(ApiProblem problem, AppLocalizations l10n) { return l10n.errorSessionExpired; case 'AUTH_SERVICE_UNAVAILABLE': return l10n.errorServiceUnavailable; + case 'POINTS_INSUFFICIENT_BALANCE': + return l10n.toastCoinInsufficient; + case 'AGENT_SESSION_RUN_LIMIT_EXCEEDED': + return l10n.errorRunLimitExceeded; + case 'AGENT_DIVINATION_PAYLOAD_REQUIRED': + return l10n.errorDivinationPayloadRequired; default: break; } diff --git a/apps/lib/data/network/api_client.dart b/apps/lib/data/network/api_client.dart index 1e78338..e3b8e6b 100644 --- a/apps/lib/data/network/api_client.dart +++ b/apps/lib/data/network/api_client.dart @@ -44,6 +44,8 @@ class ApiClient { final Dio _dio; final Logger _logger = getLogger('data.network.api_client'); + Dio get rawDio => _dio; + Future postNoContent(String path, {Map? data}) async { try { await _dio.post(path, data: data); @@ -90,6 +92,20 @@ class ApiClient { } } + Future> getJson(String path) async { + try { + final response = await _dio.get>(path); + return response.data ?? {}; + } on DioException catch (error, stackTrace) { + _logger.error( + message: 'GET json failed', + error: error, + stackTrace: stackTrace, + ); + throw _mapProblem(error); + } + } + ApiProblem _mapProblem(DioException error) { final status = error.response?.statusCode ?? 500; final data = error.response?.data; diff --git a/apps/lib/features/divination/data/apis/divination_api.dart b/apps/lib/features/divination/data/apis/divination_api.dart new file mode 100644 index 0000000..7c24d52 --- /dev/null +++ b/apps/lib/features/divination/data/apis/divination_api.dart @@ -0,0 +1,230 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +import '../../../../core/logging/logger.dart'; +import '../../../../core/network/api_problem.dart'; +import '../../../../data/network/api_client.dart'; +import '../models/divination_backend_models.dart'; +import '../models/divination_params.dart'; + +class DivinationApi { + const DivinationApi({required ApiClient apiClient}) : _apiClient = apiClient; + + final ApiClient _apiClient; + static final Logger _logger = getLogger('features.divination.api'); + + Future getPointsBalance() async { + final json = await _apiClient.getJson('/api/v1/points/balance'); + return PointsBalanceData.fromJson(json); + } + + Future enqueueRun({ + required DivinationParams params, + required List yaoStates, + required String threadId, + required String runId, + }) async { + final payload = buildDivinationRunPayload( + params: params, + yaoStates: yaoStates, + threadId: threadId, + runId: runId, + clientNow: DateTime.now(), + ); + final json = await _apiClient.postJson('/api/v1/agent/runs', data: payload); + return RunAcceptedData.fromJson(json); + } + + Stream> streamEvents({ + required String threadId, + required String runId, + }) async* { + Response response; + try { + response = await _apiClient.rawDio.get( + '/api/v1/agent/runs/$threadId/events', + queryParameters: {'runId': runId}, + options: Options(responseType: ResponseType.stream), + ); + } on DioException catch (error, stackTrace) { + _logger.error( + message: 'Failed to open SSE stream for divination run', + error: error, + stackTrace: stackTrace, + extra: {'threadId': threadId, 'runId': runId}, + ); + throw _mapProblem(error); + } + + final body = response.data; + if (body == null) { + return; + } + + String buffer = ''; + try { + await for (final textChunk in utf8.decoder.bind(body.stream)) { + buffer += textChunk.replaceAll('\r\n', '\n'); + while (true) { + final splitAt = buffer.indexOf('\n\n'); + if (splitAt < 0) { + break; + } + final frame = buffer.substring(0, splitAt); + buffer = buffer.substring(splitAt + 2); + final event = _parseSseFrame(frame); + if (event != null) { + yield event; + } + } + } + } on FormatException catch (error, stackTrace) { + _logger.error( + message: 'Failed to decode SSE stream chunk', + error: error, + stackTrace: stackTrace, + extra: { + 'threadId': threadId, + 'runId': runId, + 'bufferLength': buffer.length, + }, + ); + throw ApiProblem( + status: 502, + title: 'SSE parse error', + detail: error.message, + ); + } + + if (buffer.trim().isNotEmpty) { + final event = _parseSseFrame(buffer); + if (event != null) { + yield event; + } + } + } + + ApiProblem _mapProblem(DioException error) { + final status = error.response?.statusCode ?? 500; + final data = error.response?.data; + if (data is Map) { + return ApiProblem( + status: status, + title: (data['title'] as String?) ?? 'Request failed', + detail: (data['detail'] as String?) ?? '', + code: data['code'] as String?, + ); + } + return ApiProblem( + status: status, + title: 'Network error', + detail: error.message ?? 'Request failed', + ); + } + + Map? _parseSseFrame(String frame) { + if (frame.startsWith(':')) { + return null; + } + final lines = frame.split('\n'); + String eventType = ''; + final dataLines = []; + for (final raw in lines) { + final line = raw.trimRight(); + if (line.startsWith('event:')) { + eventType = line.substring(6).trim(); + continue; + } + if (line.startsWith('data:')) { + dataLines.add(line.substring(5).trimLeft()); + } + } + if (dataLines.isEmpty) { + return null; + } + final dataText = dataLines.join('\n'); + if (dataText.trim().isEmpty) { + return null; + } + final decoded = jsonDecode(dataText); + if (decoded is! Map) { + return null; + } + if (!decoded.containsKey('type') && eventType.isNotEmpty) { + decoded['type'] = eventType; + } + return decoded; + } +} + +Map buildDivinationRunPayload({ + required DivinationParams params, + required List yaoStates, + required String threadId, + required String runId, + required DateTime clientNow, +}) { + if (yaoStates.length != 6) { + throw ArgumentError('yaoStates must contain exactly 6 items'); + } + + return { + 'threadId': threadId, + 'runId': runId, + 'state': {}, + 'messages': [ + {'id': 'msg_${runId}_user_0', 'role': 'user', 'content': params.question}, + ], + 'tools': const >[], + 'context': const >[], + 'forwardedProps': { + 'runtime_mode': 'chat', + 'client_time': { + 'device_timezone': 'Asia/Shanghai', + 'client_now_iso': _toRfc3339Utc(clientNow), + 'client_epoch_ms': clientNow.millisecondsSinceEpoch, + }, + 'divinationPayload': { + 'divinationMethod': params.method == DivinationMethod.manual + ? '手动起卦' + : '自动起卦', + 'questionType': _questionTypeToText(params.questionType), + 'question': params.question, + 'divinationTimeIso': _toRfc3339Utc(params.divinationTime), + 'yaoLines': yaoStates.map(_yaoTypeToText).toList(growable: false), + }, + }, + }; +} + +String _toRfc3339Utc(DateTime value) { + return value.toUtc().toIso8601String(); +} + +String _questionTypeToText(QuestionType type) { + return switch (type) { + QuestionType.career => '事业', + QuestionType.love => '情感', + QuestionType.wealth => '财富', + QuestionType.fortune => '运势', + QuestionType.dream => '解梦', + QuestionType.health => '健康', + QuestionType.study => '学业', + QuestionType.search => '寻物', + QuestionType.other => '其他', + }; +} + +String _yaoTypeToText(YaoType type) { + return switch (type) { + YaoType.youngYang => '少阳', + YaoType.youngYin => '少阴', + YaoType.oldYang => '老阳', + YaoType.oldYin => '老阴', + YaoType.undetermined => throw ArgumentError( + 'yaoStates contains undetermined line', + ), + }; +} diff --git a/apps/lib/features/divination/data/models/divination_backend_models.dart b/apps/lib/features/divination/data/models/divination_backend_models.dart new file mode 100644 index 0000000..2290035 --- /dev/null +++ b/apps/lib/features/divination/data/models/divination_backend_models.dart @@ -0,0 +1,347 @@ +import 'divination_params.dart'; +import 'divination_result.dart'; + +class PointsBalanceData { + const PointsBalanceData({ + required this.balance, + required this.frozenBalance, + required this.availableBalance, + required this.runCost, + required this.canRun, + }); + + factory PointsBalanceData.fromJson(Map json) { + return PointsBalanceData( + balance: _requiredInt(json, 'balance'), + frozenBalance: _requiredInt(json, 'frozenBalance'), + availableBalance: _requiredInt(json, 'availableBalance'), + runCost: _requiredInt(json, 'runCost'), + canRun: _requiredBool(json, 'canRun'), + ); + } + + final int balance; + final int frozenBalance; + final int availableBalance; + final int runCost; + final bool canRun; +} + +class RunAcceptedData { + const RunAcceptedData({ + required this.taskId, + required this.threadId, + required this.runId, + required this.created, + }); + + factory RunAcceptedData.fromJson(Map json) { + return RunAcceptedData( + taskId: _requiredString(json, 'taskId'), + threadId: _requiredString(json, 'threadId'), + runId: _requiredString(json, 'runId'), + created: _requiredBool(json, 'created'), + ); + } + + final String taskId; + final String threadId; + final String runId; + final bool created; +} + +class DivinationRunAggregate { + const DivinationRunAggregate({ + required this.derived, + required this.signLevel, + required this.summary, + required this.conclusion, + required this.focusPoints, + required this.advice, + required this.keywords, + required this.answer, + }); + + final DerivedDivinationData derived; + final String signLevel; + final String summary; + final List conclusion; + final List focusPoints; + final List advice; + final List keywords; + final String answer; + + DivinationResultData toViewData(DivinationParams params) { + return DivinationResultData( + params: params, + binaryCode: derived.binaryCode, + changedBinaryCode: derived.changedBinaryCode, + guaName: derived.guaName, + targetGuaName: derived.targetGuaName, + upperName: derived.upperName, + lowerName: derived.lowerName, + signType: signLevel, + keywords: keywords.join('、'), + conclusion: _asBullet(conclusion), + analysis: summary.isEmpty ? answer : '$summary\n\n$answer', + suggestion: _asBullet(advice), + ganzhi: GanzhiData( + yearGanZhi: derived.ganzhi.yearGanZhi, + monthGanZhi: derived.ganzhi.monthGanZhi, + dayGanZhi: derived.ganzhi.dayGanZhi, + timeGanZhi: derived.ganzhi.timeGanZhi, + yearKongWang: derived.ganzhi.yearKongWang, + monthKongWang: derived.ganzhi.monthKongWang, + dayKongWang: derived.ganzhi.dayKongWang, + timeKongWang: derived.ganzhi.timeKongWang, + yueJian: derived.ganzhi.yueJian, + riChen: derived.ganzhi.riChen, + yuePo: derived.ganzhi.yuePo, + riChong: derived.ganzhi.riChong, + ), + wuXingStatus: derived.wuXingStatuses, + yaoLines: derived.yaoInfoList + .map((line) => line.toViewModel()) + .toList(growable: false), + targetYaoLines: derived.targetYaoInfoList + .map((line) => line.toViewModel()) + .toList(growable: false), + ); + } + + String _asBullet(List lines) { + if (lines.isEmpty) { + return ''; + } + return List.generate( + lines.length, + (i) => '${i + 1}. ${lines[i]}', + growable: false, + ).join('\n'); + } +} + +class DerivedDivinationData { + const DerivedDivinationData({ + required this.question, + required this.questionType, + required this.divinationMethod, + required this.divinationTime, + required this.binaryCode, + required this.changedBinaryCode, + required this.guaName, + required this.upperName, + required this.lowerName, + required this.targetGuaName, + required this.worldPosition, + required this.responsePosition, + required this.hasChangingYao, + required this.ganzhi, + required this.wuXingStatuses, + required this.yaoInfoList, + required this.targetYaoInfoList, + }); + + factory DerivedDivinationData.fromJson(Map json) { + final wuXingRaw = _requiredMap(json, 'wuXingStatuses'); + return DerivedDivinationData( + question: _requiredString(json, 'question'), + questionType: _requiredString(json, 'questionType'), + divinationMethod: _requiredString(json, 'divinationMethod'), + divinationTime: _requiredString(json, 'divinationTime'), + binaryCode: _requiredString(json, 'binaryCode'), + changedBinaryCode: _requiredString(json, 'changedBinaryCode'), + guaName: _requiredString(json, 'guaName'), + upperName: _requiredString(json, 'upperName'), + lowerName: _requiredString(json, 'lowerName'), + targetGuaName: _requiredString(json, 'targetGuaName'), + worldPosition: _requiredInt(json, 'worldPosition'), + responsePosition: _requiredInt(json, 'responsePosition'), + hasChangingYao: _requiredBool(json, 'hasChangingYao'), + ganzhi: GanzhiBackend.fromJson(_requiredMap(json, 'ganzhi')), + wuXingStatuses: wuXingRaw.map( + (key, value) => MapEntry(key, value.toString()), + ), + yaoInfoList: _parseYaoList(json['yaoInfoList']), + targetYaoInfoList: _parseYaoList(json['targetYaoInfoList']), + ); + } + + final String question; + final String questionType; + final String divinationMethod; + final String divinationTime; + final String binaryCode; + final String changedBinaryCode; + final String guaName; + final String upperName; + final String lowerName; + final String targetGuaName; + final int worldPosition; + final int responsePosition; + final bool hasChangingYao; + final GanzhiBackend ganzhi; + final Map wuXingStatuses; + final List yaoInfoList; + final List targetYaoInfoList; + + static List _parseYaoList(Object? raw) { + final list = raw as List?; + if (list == null) { + throw const FormatException( + 'Missing required list: yaoInfoList/targetYaoInfoList', + ); + } + return list + .map((item) { + if (item is! Map) { + throw const FormatException('Invalid yao line item'); + } + return YaoBackendLine.fromJson(item); + }) + .toList(growable: false); + } +} + +class GanzhiBackend { + const GanzhiBackend({ + required this.yearGanZhi, + required this.monthGanZhi, + required this.dayGanZhi, + required this.timeGanZhi, + required this.yearKongWang, + required this.monthKongWang, + required this.dayKongWang, + required this.timeKongWang, + required this.yueJian, + required this.riChen, + required this.yuePo, + required this.riChong, + }); + + factory GanzhiBackend.fromJson(Map json) { + return GanzhiBackend( + yearGanZhi: _requiredString(json, 'yearGanZhi'), + monthGanZhi: _requiredString(json, 'monthGanZhi'), + dayGanZhi: _requiredString(json, 'dayGanZhi'), + timeGanZhi: _requiredString(json, 'timeGanZhi'), + yearKongWang: _requiredString(json, 'yearKongWang'), + monthKongWang: _requiredString(json, 'monthKongWang'), + dayKongWang: _requiredString(json, 'dayKongWang'), + timeKongWang: _requiredString(json, 'timeKongWang'), + yueJian: _requiredString(json, 'yueJian'), + riChen: _requiredString(json, 'riChen'), + yuePo: _requiredString(json, 'yuePo'), + riChong: _requiredString(json, 'riChong'), + ); + } + + final String yearGanZhi; + final String monthGanZhi; + final String dayGanZhi; + final String timeGanZhi; + final String yearKongWang; + final String monthKongWang; + final String dayKongWang; + final String timeKongWang; + final String yueJian; + final String riChen; + final String yuePo; + final String riChong; +} + +class YaoBackendLine { + const YaoBackendLine({ + required this.position, + required this.spiritName, + required this.relationName, + required this.tiganName, + required this.elementName, + required this.isYang, + required this.isChanging, + required this.specialMark, + }); + + factory YaoBackendLine.fromJson(Map json) { + return YaoBackendLine( + position: _requiredInt(json, 'position'), + spiritName: _requiredString(json, 'spiritName'), + relationName: _requiredString(json, 'relationName'), + tiganName: _requiredString(json, 'tiganName'), + elementName: _requiredString(json, 'elementName'), + isYang: _requiredBool(json, 'isYang'), + isChanging: _requiredBool(json, 'isChanging'), + specialMark: _requiredStringAllowEmpty(json, 'specialMark'), + ); + } + + final int position; + final String spiritName; + final String relationName; + final String tiganName; + final String elementName; + final bool isYang; + final bool isChanging; + final String specialMark; + + YaoLineData toViewModel() { + final type = switch ((isYang, isChanging)) { + (true, false) => YaoType.youngYang, + (false, false) => YaoType.youngYin, + (true, true) => YaoType.oldYang, + (false, true) => YaoType.oldYin, + }; + return YaoLineData( + index: position - 1, + spirit: spiritName, + relation: relationName, + branch: tiganName, + element: elementName, + type: type, + mark: specialMark, + ); + } +} + +String _requiredStringAllowEmpty(Map json, String key) { + if (!json.containsKey(key)) { + throw FormatException('Invalid or missing field: $key'); + } + final value = json[key]; + if (value is! String) { + throw FormatException('Invalid or missing field: $key'); + } + return value; +} + +String _requiredString(Map json, String key) { + final value = json[key]; + if (value is! String || value.isEmpty) { + throw FormatException('Invalid or missing field: $key'); + } + return value; +} + +int _requiredInt(Map json, String key) { + final value = json[key]; + if (value is! num) { + throw FormatException('Invalid or missing field: $key'); + } + return value.toInt(); +} + +bool _requiredBool(Map json, String key) { + final value = json[key]; + if (value is! bool) { + throw FormatException('Invalid or missing field: $key'); + } + return value; +} + +Map _requiredMap(Map json, String key) { + final value = json[key]; + if (value is! Map) { + throw FormatException('Invalid or missing field: $key'); + } + return value; +} diff --git a/apps/lib/features/divination/data/models/divination_params.dart b/apps/lib/features/divination/data/models/divination_params.dart index daaea09..29ce8db 100644 --- a/apps/lib/features/divination/data/models/divination_params.dart +++ b/apps/lib/features/divination/data/models/divination_params.dart @@ -85,16 +85,3 @@ class DivinationParams { .join(); } } - -class DivinationMockData { - static DivinationParams initial() { - return DivinationParams( - method: DivinationMethod.manual, - questionType: QuestionType.career, - question: '', - divinationTime: DateTime.now(), - coinBalance: 8, - userId: 'mock_user_10001', - ); - } -} diff --git a/apps/lib/features/divination/data/services/divination_result_builder.dart b/apps/lib/features/divination/data/services/divination_result_builder.dart deleted file mode 100644 index 94e8bd0..0000000 --- a/apps/lib/features/divination/data/services/divination_result_builder.dart +++ /dev/null @@ -1,222 +0,0 @@ -import '../models/divination_params.dart'; -import '../models/divination_result.dart'; - -class DivinationResultBuilder { - DivinationResultData build({ - required DivinationParams params, - required List yaoStates, - }) { - final binaryCode = params.toBinary(yaoStates); - final changedBinaryCode = params.toChangedBinary(yaoStates); - final baseHexagram = _hexagramMap[binaryCode]; - final changedHexagram = _hexagramMap[changedBinaryCode]; - if (baseHexagram == null || changedHexagram == null) { - throw StateError( - 'Unknown hexagram mapping for binary=$binaryCode changed=$changedBinaryCode', - ); - } - - final signType = _signByStates(yaoStates); - final content = _mockContent( - params.questionType, - params.question, - signType, - ); - - final lineData = _buildYaoLines(yaoStates, false); - final targetStates = _toChangedStates(yaoStates); - final targetLineData = _buildYaoLines(targetStates, true); - - return DivinationResultData( - params: params, - binaryCode: binaryCode, - changedBinaryCode: changedBinaryCode, - guaName: baseHexagram.name, - targetGuaName: changedHexagram.name, - upperName: baseHexagram.upper, - lowerName: baseHexagram.lower, - signType: signType, - keywords: content.keywords, - conclusion: content.conclusion, - analysis: content.analysis, - suggestion: content.suggestion, - ganzhi: const GanzhiData( - yearGanZhi: '丙午', - monthGanZhi: '甲辰', - dayGanZhi: '辛亥', - timeGanZhi: '乙巳', - yearKongWang: '子丑', - monthKongWang: '申酉', - dayKongWang: '寅卯', - timeKongWang: '午未', - yueJian: '辰', - riChen: '亥', - yuePo: '戌', - riChong: '巳', - ), - wuXingStatus: const {'木': '旺', '火': '相', '土': '休', '金': '囚', '水': '死'}, - yaoLines: lineData, - targetYaoLines: targetLineData, - ); - } - - List _buildYaoLines(List states, bool target) { - const spirits = ['龙', '雀', '勾', '蛇', '虎', '玄']; - const relations = ['父母', '兄弟', '官鬼', '妻财', '子孙', '父母']; - const branches = ['子', '寅', '辰', '午', '申', '戌']; - const elements = ['水', '木', '土', '火', '金', '土']; - return List.generate(6, (idx) { - final mark = switch (idx) { - 1 => '应', - 4 => '世', - _ => '', - }; - return YaoLineData( - index: idx, - spirit: spirits[idx], - relation: relations[idx], - branch: branches[idx], - element: elements[idx], - type: states[idx], - mark: target ? '' : mark, - ); - }); - } - - List _toChangedStates(List source) { - return source.map((state) { - return switch (state) { - YaoType.oldYang => YaoType.youngYin, - YaoType.oldYin => YaoType.youngYang, - _ => state, - }; - }).toList(); - } - - String _signByStates(List states) { - final dynamicCount = states - .where((e) => e == YaoType.oldYang || e == YaoType.oldYin) - .length; - if (dynamicCount <= 1) { - return '上上签'; - } - if (dynamicCount <= 3) { - return '中上签'; - } - return '中下签'; - } - - _MockContent _mockContent( - QuestionType type, - String question, - String signType, - ) { - final domain = switch (type) { - QuestionType.career || QuestionType.study => '事业与成长', - QuestionType.love => '关系与情感', - QuestionType.wealth => '财富与资源', - QuestionType.fortune => '阶段运势', - QuestionType.dream => '潜意识信号', - QuestionType.health => '身心节律', - QuestionType.search => '寻物线索', - QuestionType.other => '综合事项', - }; - return _MockContent( - keywords: '$signType · $domain', - conclusion: '这个卦象的结果为$signType。你关注的“$question”处于可推进阶段,当前节奏重在稳步而行,不宜急进。', - analysis: - '本卦显示外在条件逐步成形,内在决心也在增强。若短期遇到反复,通常是资源重组与信息修正,并非方向错误。建议将目标拆分为可验证的小节点,持续复盘。', - suggestion: - '建议一:先定三周内可执行动作并按日推进。\n建议二:重要决定留有缓冲期,避免情绪化判断。\n建议三:遇到阻滞先调整节奏,再补关键资源。', - ); - } -} - -class _MockContent { - const _MockContent({ - required this.keywords, - required this.conclusion, - required this.analysis, - required this.suggestion, - }); - - final String keywords; - final String conclusion; - final String analysis; - final String suggestion; -} - -class _HexagramShort { - const _HexagramShort(this.name, this.upper, this.lower); - - final String name; - final String upper; - final String lower; -} - -const Map _hexagramMap = { - '111111': _HexagramShort('乾为天', '乾', '乾'), - '011111': _HexagramShort('天风姤', '乾', '巽'), - '001111': _HexagramShort('天山遁', '乾', '艮'), - '000111': _HexagramShort('天地否', '乾', '坤'), - '000011': _HexagramShort('风地观', '巽', '坤'), - '000001': _HexagramShort('山地剥', '艮', '坤'), - '000101': _HexagramShort('火地晋', '离', '坤'), - '111101': _HexagramShort('火天大有', '离', '乾'), - '010010': _HexagramShort('坎为水', '坎', '坎'), - '110010': _HexagramShort('水泽节', '坎', '兑'), - '100010': _HexagramShort('水雷屯', '坎', '震'), - '101010': _HexagramShort('水火既济', '坎', '离'), - '101110': _HexagramShort('泽火革', '兑', '离'), - '101100': _HexagramShort('雷火丰', '震', '离'), - '101000': _HexagramShort('地火明夷', '坤', '离'), - '010000': _HexagramShort('地水师', '坤', '坎'), - '001001': _HexagramShort('艮为山', '艮', '艮'), - '101001': _HexagramShort('山火贲', '艮', '离'), - '111001': _HexagramShort('山天大畜', '艮', '乾'), - '110001': _HexagramShort('山泽损', '艮', '兑'), - '110101': _HexagramShort('火泽睽', '离', '兑'), - '110111': _HexagramShort('天泽履', '乾', '兑'), - '110011': _HexagramShort('风泽中孚', '巽', '兑'), - '001011': _HexagramShort('风山渐', '巽', '艮'), - '100100': _HexagramShort('震为雷', '震', '震'), - '000100': _HexagramShort('雷地豫', '震', '坤'), - '010100': _HexagramShort('雷水解', '震', '坎'), - '011100': _HexagramShort('雷风恒', '震', '巽'), - '011000': _HexagramShort('地风升', '坤', '巽'), - '011010': _HexagramShort('水风井', '坎', '巽'), - '011110': _HexagramShort('泽风大过', '兑', '巽'), - '100110': _HexagramShort('泽雷随', '兑', '震'), - '011011': _HexagramShort('巽为风', '巽', '巽'), - '111011': _HexagramShort('风天小畜', '巽', '乾'), - '101011': _HexagramShort('风火家人', '巽', '离'), - '100011': _HexagramShort('风雷益', '巽', '震'), - '100111': _HexagramShort('天雷无妄', '乾', '震'), - '100101': _HexagramShort('火雷噬嗑', '离', '震'), - '100001': _HexagramShort('山雷颐', '艮', '震'), - '011001': _HexagramShort('山风蛊', '艮', '巽'), - '101101': _HexagramShort('离为火', '离', '离'), - '001101': _HexagramShort('火山旅', '离', '艮'), - '011101': _HexagramShort('火风鼎', '离', '巽'), - '010101': _HexagramShort('火水未济', '离', '坎'), - '010001': _HexagramShort('山水蒙', '艮', '坎'), - '010011': _HexagramShort('风水涣', '巽', '坎'), - '010111': _HexagramShort('天水讼', '乾', '坎'), - '101111': _HexagramShort('天火同人', '乾', '离'), - '000000': _HexagramShort('坤为地', '坤', '坤'), - '100000': _HexagramShort('地雷复', '坤', '震'), - '110000': _HexagramShort('地泽临', '坤', '兑'), - '111000': _HexagramShort('地天泰', '坤', '乾'), - '111100': _HexagramShort('雷天大壮', '震', '乾'), - '111110': _HexagramShort('泽天夬', '兑', '乾'), - '111010': _HexagramShort('水天需', '坎', '乾'), - '000010': _HexagramShort('水地比', '坎', '坤'), - '110110': _HexagramShort('兑为泽', '兑', '兑'), - '010110': _HexagramShort('泽水困', '兑', '坎'), - '000110': _HexagramShort('泽地萃', '兑', '坤'), - '001110': _HexagramShort('泽山咸', '兑', '艮'), - '001010': _HexagramShort('水山蹇', '坎', '艮'), - '001000': _HexagramShort('地山谦', '坤', '艮'), - '001100': _HexagramShort('雷山小过', '震', '艮'), - '110100': _HexagramShort('雷泽归妹', '震', '兑'), -}; diff --git a/apps/lib/features/divination/data/services/divination_run_service.dart b/apps/lib/features/divination/data/services/divination_run_service.dart new file mode 100644 index 0000000..8e6d139 --- /dev/null +++ b/apps/lib/features/divination/data/services/divination_run_service.dart @@ -0,0 +1,147 @@ +import 'dart:math'; + +import '../../../../core/logging/logger.dart'; +import '../../../../core/network/api_problem.dart'; +import '../apis/divination_api.dart'; +import '../models/divination_backend_models.dart'; +import '../models/divination_params.dart'; + +class DivinationRunService { + const DivinationRunService({required DivinationApi api}) : _api = api; + + final DivinationApi _api; + static final Logger _logger = getLogger('features.divination.run_service'); + + Future run({ + required DivinationParams params, + required List yaoStates, + void Function()? onDerived, + void Function()? onTextMessageEnd, + }) async { + final threadId = _uuidV4(); + final runId = 'run_${DateTime.now().millisecondsSinceEpoch}'; + await _api.enqueueRun( + params: params, + yaoStates: yaoStates, + threadId: threadId, + runId: runId, + ); + + DerivedDivinationData? derived; + String signLevel = ''; + String summary = ''; + List conclusion = const []; + List focusPoints = const []; + List advice = const []; + List keywords = const []; + String answer = ''; + + await for (final event in _api.streamEvents( + threadId: threadId, + runId: runId, + )) { + final type = event['type'] as String? ?? ''; + _logger.debug( + message: 'Received run SSE event', + extra: { + 'threadId': threadId, + 'runId': runId, + 'type': type, + }, + ); + if (type == 'DIVINATION_DERIVED') { + final payload = event['divination']; + if (payload is! Map) { + throw const FormatException('DIVINATION_DERIVED 缺少 divination 结构'); + } + derived = DerivedDivinationData.fromJson(payload); + onDerived?.call(); + continue; + } + if (type == 'TEXT_MESSAGE_END') { + signLevel = _requiredString(event, 'sign_level'); + summary = _requiredString(event, 'summary'); + conclusion = _requiredStringList(event, 'conclusion'); + focusPoints = _requiredStringList(event, 'focus_points'); + advice = _requiredStringList(event, 'advice'); + keywords = _requiredStringList(event, 'keywords'); + answer = _requiredString(event, 'answer'); + onTextMessageEnd?.call(); + continue; + } + if (type == 'RUN_ERROR') { + _logger.warning( + message: 'Run ended with RUN_ERROR event', + extra: { + 'threadId': threadId, + 'runId': runId, + 'code': event['code'], + 'detail': event['detail'], + 'message': event['message'], + }, + ); + throw ApiProblem( + status: 500, + title: 'Run failed', + detail: event['detail'] as String? ?? '解卦失败', + code: event['code'] as String?, + ); + } + if (type == 'RUN_FINISHED') { + break; + } + } + + if (derived == null) { + _logger.warning( + message: 'Missing DIVINATION_DERIVED before RUN_FINISHED', + extra: {'threadId': threadId, 'runId': runId}, + ); + throw const FormatException('未收到 DIVINATION_DERIVED 事件'); + } + + return DivinationRunAggregate( + derived: derived, + signLevel: signLevel, + summary: summary, + conclusion: conclusion, + focusPoints: focusPoints, + advice: advice, + keywords: keywords, + answer: answer, + ); + } + + String _requiredString(Map json, String key) { + final value = json[key]; + if (value is! String || value.isEmpty) { + throw FormatException('缺少字段: $key'); + } + return value; + } + + List _requiredStringList(Map json, String key) { + final raw = json[key]; + if (raw is! List) { + throw FormatException('缺少列表字段: $key'); + } + return raw + .map((item) { + if (item is! String || item.isEmpty) { + throw FormatException('字段 $key 存在非法项'); + } + return item; + }) + .toList(growable: false); + } + + String _uuidV4() { + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + String toHex(int b) => b.toRadixString(16).padLeft(2, '0'); + final b = bytes.map(toHex).toList(growable: false); + return '${b[0]}${b[1]}${b[2]}${b[3]}-${b[4]}${b[5]}-${b[6]}${b[7]}-${b[8]}${b[9]}-${b[10]}${b[11]}${b[12]}${b[13]}${b[14]}${b[15]}'; + } +} diff --git a/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart b/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart index d5600f5..c78f3b4 100644 --- a/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart +++ b/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart @@ -7,8 +7,6 @@ import 'package:intl/intl.dart'; import 'package:sensors_plus/sensors_plus.dart'; import 'package:vibration/vibration.dart'; -import '../../../../core/network/api_problem.dart'; -import '../../../../core/network/api_problem_mapper.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/widgets/divination/divination_shared_widgets.dart'; @@ -16,11 +14,9 @@ import '../../../../shared/widgets/divination/divination_terms.dart'; import '../../../../shared/widgets/divination/yao_legend.dart'; import '../../../../shared/widgets/divination/yao_line_row.dart'; import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart'; -import '../../../../shared/widgets/toast/toast.dart'; -import '../../../../shared/widgets/toast/toast_type.dart'; import '../../data/models/divination_params.dart'; import '../../data/services/divination_run_service.dart'; -import 'divination_result_screen.dart'; +import 'divination_processing_screen.dart'; class AutoDivinationScreen extends StatefulWidget { const AutoDivinationScreen({ @@ -220,35 +216,22 @@ class _AutoDivinationScreenState extends State } Future _submitRun() async { - final l10n = AppLocalizations.of(context)!; setState(() { _submitting = true; }); try { - final aggregate = await widget.runService.run( - params: widget.params.copyWith(divinationTime: _selectedTime), - yaoStates: _yaoStates, - ); if (!mounted) { return; } - Navigator.of(context).push( + await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => DivinationResultScreen( - data: aggregate.toViewData( - widget.params.copyWith(divinationTime: _selectedTime), - ), + builder: (_) => DivinationProcessingScreen( + params: widget.params.copyWith(divinationTime: _selectedTime), + yaoStates: _yaoStates, + runService: widget.runService, ), ), ); - } catch (error) { - if (!mounted) { - return; - } - final message = error is ApiProblem - ? mapApiProblemToMessage(error, l10n) - : l10n.errorRequestGeneric; - Toast.show(context, message, type: ToastType.error); } finally { if (!mounted) { return; diff --git a/apps/lib/features/divination/presentation/screens/divination_processing_screen.dart b/apps/lib/features/divination/presentation/screens/divination_processing_screen.dart new file mode 100644 index 0000000..a4c117e --- /dev/null +++ b/apps/lib/features/divination/presentation/screens/divination_processing_screen.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/logging/logger.dart'; +import '../../../../core/network/api_problem.dart'; +import '../../../../core/network/api_problem_mapper.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/widgets/toast/toast.dart'; +import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../data/models/divination_params.dart'; +import '../../data/models/divination_result.dart'; +import '../../data/services/divination_run_service.dart'; +import 'divination_result_screen.dart'; + +enum _ProcessingStep { preparing, deriving, done } + +class DivinationProcessingScreen extends StatefulWidget { + const DivinationProcessingScreen({ + super.key, + required this.params, + required this.yaoStates, + required this.runService, + }); + + final DivinationParams params; + final List yaoStates; + final DivinationRunService runService; + + @override + State createState() => + _DivinationProcessingScreenState(); +} + +class _DivinationProcessingScreenState + extends State { + static final Logger _logger = getLogger( + 'features.divination.processing_screen', + ); + _ProcessingStep _step = _ProcessingStep.preparing; + DivinationResultData? _resultData; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _startRun(); + } + + Future _startRun() async { + try { + final aggregate = await widget.runService.run( + params: widget.params, + yaoStates: widget.yaoStates, + onDerived: () { + if (!mounted) { + return; + } + setState(() { + _step = _ProcessingStep.deriving; + }); + }, + onTextMessageEnd: () { + if (!mounted) { + return; + } + setState(() { + _step = _ProcessingStep.done; + }); + }, + ); + if (!mounted) { + return; + } + setState(() { + _resultData = aggregate.toViewData(widget.params); + _step = _ProcessingStep.done; + }); + } catch (error, stackTrace) { + _logger.error( + message: 'Divination processing failed while waiting result events', + error: error, + stackTrace: stackTrace, + extra: { + 'step': _step.name, + 'method': widget.params.method.name, + 'questionType': widget.params.questionType.name, + }, + ); + if (!mounted) { + return; + } + final l10n = AppLocalizations.of(context)!; + final message = error is ApiProblem + ? mapApiProblemToMessage(error, l10n) + : l10n.errorRequestGeneric; + setState(() { + _errorMessage = message; + }); + Toast.show(context, message, type: ToastType.error); + } + } + + void _openResult() { + final data = _resultData; + if (data == null) { + return; + } + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => DivinationResultScreen(data: data), + ), + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + + final text = switch (_step) { + _ProcessingStep.preparing => l10n.transitionPreparing, + _ProcessingStep.deriving => l10n.transitionDeriving, + _ProcessingStep.done => l10n.transitionDone, + }; + + final canContinue = _step == _ProcessingStep.done && _resultData != null; + + return Scaffold( + backgroundColor: colors.surface, + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: _errorMessage == null + ? GestureDetector( + onTap: canContinue ? _openResult : null, + child: Container( + width: 220, + height: 320, + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all( + color: colors.primary.withValues(alpha: 0.2), + ), + boxShadow: [ + BoxShadow( + color: colors.shadow.withValues(alpha: 0.25), + blurRadius: 22, + offset: const Offset(0, 12), + ), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + canContinue ? Icons.visibility : Icons.auto_awesome, + color: colors.primary, + size: 34, + ), + const SizedBox(height: AppSpacing.md), + Text( + text, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ), + ) + : Text( + _errorMessage!, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: colors.error), + ), + ), + ), + ), + ); + } +} diff --git a/apps/lib/features/divination/presentation/screens/divination_result_screen.dart b/apps/lib/features/divination/presentation/screens/divination_result_screen.dart index 9821cbc..62de036 100644 --- a/apps/lib/features/divination/presentation/screens/divination_result_screen.dart +++ b/apps/lib/features/divination/presentation/screens/divination_result_screen.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -25,41 +24,30 @@ class DivinationResultScreen extends StatefulWidget { State createState() => _DivinationResultScreenState(); } -enum _ResultTransitionStep { preparing, deriving, done } - class _DivinationResultScreenState extends State { - _ResultTransitionStep _step = _ResultTransitionStep.preparing; - bool _showOverlay = true; + bool _showIntro = true; + bool _introCollapsed = false; @override void initState() { super.initState(); - _playSequence(); + _playIntro(); } - Future _playSequence() async { - await Future.delayed(const Duration(milliseconds: 420)); + Future _playIntro() async { + await Future.delayed(const Duration(milliseconds: 120)); if (!mounted) { return; } setState(() { - _step = _ResultTransitionStep.deriving; + _introCollapsed = true; }); - await Future.delayed(const Duration(milliseconds: 820)); + await Future.delayed(const Duration(milliseconds: 760)); if (!mounted) { return; } setState(() { - _step = _ResultTransitionStep.done; - }); - } - - void _dismissOverlay() { - if (_step != _ResultTransitionStep.done) { - return; - } - setState(() { - _showOverlay = false; + _showIntro = false; }); } @@ -78,172 +66,106 @@ class _DivinationResultScreenState extends State { ), body: Stack( children: [ - SingleChildScrollView( - padding: const EdgeInsets.fromLTRB( - AppSpacing.xl, - AppSpacing.lg, - AppSpacing.xl, - AppSpacing.xl, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _ResultHeader(data: widget.data), - const SizedBox(height: AppSpacing.md), - _SignCard(signType: widget.data.signType), - const SizedBox(height: AppSpacing.md), - _KeywordCard(keywords: widget.data.keywords), - const SizedBox(height: AppSpacing.md), - _AnalysisCard( - title: l10n.resultConclusion, - content: widget.data.conclusion, - ), - const SizedBox(height: AppSpacing.md), - _AnalysisCard( - title: l10n.resultAnalysis, - content: widget.data.analysis, - ), - const SizedBox(height: AppSpacing.md), - _AnalysisCard( - title: l10n.resultSuggestion, - content: widget.data.suggestion, - ), - const SizedBox(height: AppSpacing.md), - Container( - width: double.infinity, - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: palette.warningContainer, - borderRadius: BorderRadius.circular(AppRadius.md), + AnimatedOpacity( + opacity: _showIntro ? 0 : 1, + duration: const Duration(milliseconds: 260), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.lg, + AppSpacing.xl, + AppSpacing.xl, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ResultHeader(data: widget.data), + const SizedBox(height: AppSpacing.md), + _SignCard(signType: widget.data.signType), + const SizedBox(height: AppSpacing.md), + _KeywordCard(keywords: widget.data.keywords), + const SizedBox(height: AppSpacing.md), + _AnalysisCard( + title: l10n.resultConclusion, + content: widget.data.conclusion, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.warning, color: palette.warning, size: 20), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - l10n.resultWarning, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: palette.warning, - fontWeight: FontWeight.w600, - height: 1.35, - ), + const SizedBox(height: AppSpacing.md), + _AnalysisCard( + title: l10n.resultAnalysis, + content: widget.data.analysis, + ), + const SizedBox(height: AppSpacing.md), + _AnalysisCard( + title: l10n.resultSuggestion, + content: widget.data.suggestion, + ), + const SizedBox(height: AppSpacing.md), + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: palette.warningContainer, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning, color: palette.warning, size: 20), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + l10n.resultWarning, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: palette.warning, + fontWeight: FontWeight.w600, + height: 1.35, + ), + ), ), - ), - ], - ), - ), - const SizedBox(height: AppSpacing.xl), - Text( - l10n.resultBasicInfo, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: AppSpacing.md), - _InfoCard(data: widget.data), - const SizedBox(height: AppSpacing.xl), - Text( - l10n.resultHexagramDetail, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: AppSpacing.md), - _HexagramDetailCard(data: widget.data), - ], - ), - ), - if (_showOverlay) - _ResultTransitionOverlay(step: _step, onTapDone: _dismissOverlay), - ], - ), - ); - } -} - -class _ResultTransitionOverlay extends StatelessWidget { - const _ResultTransitionOverlay({required this.step, required this.onTapDone}); - - final _ResultTransitionStep step; - final VoidCallback onTapDone; - - @override - Widget build(BuildContext context) { - final colors = Theme.of(context).colorScheme; - final l10n = AppLocalizations.of(context)!; - final cardText = switch (step) { - _ResultTransitionStep.preparing => l10n.transitionPreparing, - _ResultTransitionStep.deriving => l10n.transitionDeriving, - _ResultTransitionStep.done => l10n.transitionDone, - }; - - return Positioned.fill( - child: Material( - color: colors.surface, - child: Center( - child: GestureDetector( - key: const Key('result_transition_overlay_tap'), - onTap: onTapDone, - child: TweenAnimationBuilder( - key: ValueKey<_ResultTransitionStep>(step), - tween: Tween(begin: pi / 2, end: 0), - duration: const Duration(milliseconds: 460), - curve: Curves.easeOutCubic, - builder: (context, angle, child) { - final opacity = (1 - angle / (pi / 2)).clamp(0.0, 1.0); - return Opacity( - opacity: opacity, - child: Transform( - alignment: Alignment.center, - transform: Matrix4.identity() - ..setEntry(3, 2, 0.001) - ..rotateY(angle), - child: child, - ), - ); - }, - child: Container( - width: 220, - height: 320, - decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all( - color: colors.primary.withValues(alpha: 0.2), - ), - boxShadow: [ - BoxShadow( - color: colors.shadow.withValues(alpha: 0.25), - blurRadius: 22, - offset: const Offset(0, 12), + ], ), - ], - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - step == _ResultTransitionStep.done - ? Icons.visibility - : Icons.auto_awesome, - color: colors.primary, - size: 34, - ), - const SizedBox(height: AppSpacing.md), - Text( - cardText, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: colors.primary, - fontWeight: FontWeight.w700, - height: 1.4, - ), - ), - ], - ), + ), + const SizedBox(height: AppSpacing.xl), + Text( + l10n.resultBasicInfo, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: AppSpacing.md), + _InfoCard(data: widget.data), + const SizedBox(height: AppSpacing.xl), + Text( + l10n.resultHexagramDetail, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: AppSpacing.md), + _HexagramDetailCard(data: widget.data), + ], ), ), ), - ), + if (_showIntro) + Positioned.fill( + child: Material( + color: colors.surface, + child: SafeArea( + child: AnimatedAlign( + duration: const Duration(milliseconds: 760), + curve: Curves.easeInOutCubic, + alignment: _introCollapsed + ? const Alignment(0, -0.86) + : Alignment.center, + child: AnimatedContainer( + duration: const Duration(milliseconds: 760), + curve: Curves.easeInOutCubic, + width: _introCollapsed ? 150 : 290, + child: _SignCard(signType: widget.data.signType), + ), + ), + ), + ), + ), + ], ), ); } diff --git a/apps/lib/features/divination/presentation/screens/divination_screen.dart b/apps/lib/features/divination/presentation/screens/divination_screen.dart index 8e26233..5f92be2 100644 --- a/apps/lib/features/divination/presentation/screens/divination_screen.dart +++ b/apps/lib/features/divination/presentation/screens/divination_screen.dart @@ -1,17 +1,30 @@ import 'package:flutter/material.dart'; +import '../../../../app/di/injection.dart'; +import '../../../../core/auth/session_store.dart'; +import '../../../../data/network/api_client.dart'; import '../../../../l10n/app_localizations.dart'; -import '../../../../shared/theme/app_color_palette.dart'; import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/widgets/divination/divination_shared_widgets.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../data/apis/divination_api.dart'; import '../../data/models/divination_params.dart'; +import '../../data/services/divination_run_service.dart'; import 'auto_divination_screen.dart'; import 'manual_divination_screen.dart'; class DivinationScreen extends StatefulWidget { - const DivinationScreen({super.key}); + const DivinationScreen({ + super.key, + required this.sessionStore, + required this.userId, + this.runServiceOverride, + }); + + final SessionStore sessionStore; + final String userId; + final DivinationRunService? runServiceOverride; @override State createState() => _DivinationScreenState(); @@ -20,11 +33,26 @@ class DivinationScreen extends StatefulWidget { class _DivinationScreenState extends State { late DivinationParams _params; final TextEditingController _questionController = TextEditingController(); + late final DivinationRunService _runService; @override void initState() { super.initState(); - _params = DivinationMockData.initial(); + final apiClient = ApiClient( + baseUrl: appDependencies.backendUrl, + tokenProvider: widget.sessionStore.getToken, + ); + _runService = + widget.runServiceOverride ?? + DivinationRunService(api: DivinationApi(apiClient: apiClient)); + _params = DivinationParams( + method: DivinationMethod.manual, + questionType: QuestionType.career, + question: '', + divinationTime: DateTime.now(), + coinBalance: 0, + userId: widget.userId, + ); _questionController.addListener(_syncQuestion); } @@ -58,7 +86,6 @@ class _DivinationScreenState extends State { } Widget _buildBody(BuildContext context, AppLocalizations l10n) { - final palette = Theme.of(context).extension()!; return GestureDetector( onTap: () => FocusScope.of(context).unfocus(), child: SingleChildScrollView( @@ -106,15 +133,6 @@ class _DivinationScreenState extends State { _QuestionTextField(controller: _questionController, l10n: l10n), const SizedBox(height: AppSpacing.xl), _StartButton(onPressed: _onStart, l10n: l10n), - const SizedBox(height: AppSpacing.sm), - Center( - child: Text( - l10n.divinationCoinBalance(_params.coinBalance), - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: palette.warning), - ), - ), ], ), ), @@ -132,16 +150,14 @@ class _DivinationScreenState extends State { return; } - if (_params.coinBalance <= 0) { - Toast.show(context, l10n.toastCoinInsufficient, type: ToastType.warning); - return; - } - if (_params.method == DivinationMethod.manual) { final nextParams = _params.copyWith(divinationTime: DateTime.now()); Navigator.of(context).push( MaterialPageRoute( - builder: (_) => ManualDivinationScreen(params: nextParams), + builder: (_) => ManualDivinationScreen( + params: nextParams, + runService: _runService, + ), ), ); return; @@ -150,7 +166,8 @@ class _DivinationScreenState extends State { final nextParams = _params.copyWith(divinationTime: DateTime.now()); Navigator.of(context).push( MaterialPageRoute( - builder: (_) => AutoDivinationScreen(params: nextParams), + builder: (_) => + AutoDivinationScreen(params: nextParams, runService: _runService), ), ); } diff --git a/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart b/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart index 3bd6be2..8e065c8 100644 --- a/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart +++ b/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart @@ -2,20 +2,16 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; -import '../../../../core/network/api_problem.dart'; -import '../../../../core/network/api_problem_mapper.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/widgets/divination/divination_shared_widgets.dart'; import '../../../../shared/widgets/divination/divination_terms.dart'; import '../../../../shared/widgets/divination/yao_legend.dart'; import '../../../../shared/widgets/divination/yao_line_row.dart'; -import '../../../../shared/widgets/toast/toast.dart'; -import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart'; import '../../data/models/divination_params.dart'; import '../../data/services/divination_run_service.dart'; -import 'divination_result_screen.dart'; +import 'divination_processing_screen.dart'; class ManualDivinationScreen extends StatefulWidget { const ManualDivinationScreen({ @@ -126,12 +122,7 @@ class _ManualDivinationScreenState extends State builder: (_) { return DivinationGuideDialog( title: l10n.manualSelectYaoTitle, - guideImages: const [ - 'assets/images/qigua/lc2.jpg', - 'assets/images/qigua/lc3.jpg', - 'assets/images/qigua/lc4.jpg', - 'assets/images/qigua/lc5.jpg', - ], + guideImages: const ['lc2.jpg', 'lc3.jpg', 'lc4.jpg', 'lc5.jpg'], instructionText: l10n.manualYaoTipContent, ); }, @@ -180,35 +171,22 @@ class _ManualDivinationScreenState extends State } Future _submitRun() async { - final l10n = AppLocalizations.of(context)!; setState(() { _submitting = true; }); try { - final aggregate = await widget.runService.run( - params: widget.params.copyWith(divinationTime: _selectedTime), - yaoStates: _selectedYaos.cast(), - ); if (!mounted) { return; } - Navigator.of(context).push( + await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => DivinationResultScreen( - data: aggregate.toViewData( - widget.params.copyWith(divinationTime: _selectedTime), - ), + builder: (_) => DivinationProcessingScreen( + params: widget.params.copyWith(divinationTime: _selectedTime), + yaoStates: _selectedYaos.cast(), + runService: widget.runService, ), ), ); - } catch (error) { - if (!mounted) { - return; - } - final message = error is ApiProblem - ? mapApiProblemToMessage(error, l10n) - : l10n.errorRequestGeneric; - Toast.show(context, message, type: ToastType.error); } finally { if (!mounted) { return; @@ -373,70 +351,206 @@ class _YaoSelectionCard extends StatelessWidget { void Function(int, YaoType) onSelect, ) { final l10n = AppLocalizations.of(context)!; - final options = <(String, YaoType)>[ + final colors = Theme.of(context).colorScheme; + final options = <(String, YaoType, String)>[ ( - '${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYang]} (\u2014)\uFF1A\u82B1\u5B57\u5B57', + '${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYang]}(${DivinationTerms.youngYangSymbol})', YaoType.youngYang, + '字字字', ), ( - '${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYin]} (--)\uFF1A\u5B57\u82B1\u82B1', + '${DivinationTerms.yaoTypeLabels[YaoTypeLabel.youngYin]}(${DivinationTerms.youngYinSymbol})', YaoType.youngYin, + '花花花', ), ( - '${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYang]} (\u2014\u25CB)\uFF1A\u82B1\u82B1\u82B1', + '${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYang]}(${DivinationTerms.oldYangSymbol})', YaoType.oldYang, + '字字字', ), ( - '${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYin]} (--\u00D7)\uFF1A\u5B57\u5B57\u5B57', + '${DivinationTerms.yaoTypeLabels[YaoTypeLabel.oldYin]}(${DivinationTerms.oldYinSymbol})', YaoType.oldYin, + '花花花', ), ]; return showDialog( context: context, builder: (context) { - return AlertDialog( - title: Text(l10n.manualSelectYaoTitle), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final option in options) - Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.sm), - child: OutlinedButton( - onPressed: () { - onSelect(yaoIndex, option.$2); - Navigator.of(context).pop(); - }, - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(44), + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + l10n.manualSelectYaoTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), ), - child: Text(option.$1), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + ...options.map((option) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: _YaoOptionCard( + label: option.$1, + pattern: option.$3, + isSelected: false, + onTap: () { + onSelect(yaoIndex, option.$2); + Navigator.of(context).pop(); + }, + ), + ); + }), + const SizedBox(height: AppSpacing.md), + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.coinFaceGuideTitle, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith( + color: colors.onSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.sm), + ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.sm), + child: Image.asset( + 'assets/images/qigua/zihua.jpg', + width: double.infinity, + height: 120, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => Container( + height: 120, + color: colors.errorContainer, + child: Center( + child: Text( + l10n.divinationClose, + style: TextStyle( + color: colors.onErrorContainer, + ), + ), + ), + ), + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + l10n.coinFaceGuideDescription, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colors.onSurfaceVariant), + ), + ], ), ), - const SizedBox(height: AppSpacing.sm), - const Align( - alignment: Alignment.centerLeft, - child: Text('字花图片说明:'), - ), - const SizedBox(height: AppSpacing.sm), - Image.asset( - 'assets/images/qigua/zihua.jpg', - width: double.infinity, - height: 180, - fit: BoxFit.contain, - ), - ], + ], + ), ), ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.divinationClose), - ), - ], ); }, ); } } + +class _YaoOptionCard extends StatelessWidget { + const _YaoOptionCard({ + required this.label, + required this.pattern, + required this.isSelected, + required this.onTap, + }); + + final String label; + final String pattern; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return Material( + color: isSelected ? colors.primaryContainer : colors.surface, + borderRadius: BorderRadius.circular(AppRadius.md), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.md), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 4, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all( + color: isSelected ? colors.primary : colors.outlineVariant, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: isSelected + ? colors.onPrimaryContainer + : colors.onSurface, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + pattern, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: isSelected + ? colors.onPrimaryContainer.withValues(alpha: 0.7) + : colors.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: isSelected + ? colors.onPrimaryContainer + : colors.onSurfaceVariant, + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index 85401cd..332cf94 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -278,9 +278,14 @@ class _HomeScreenState extends State { } void _onStartDivination() { - Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const DivinationScreen())); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DivinationScreen( + sessionStore: widget.sessionStore, + userId: widget.account, + ), + ), + ); } void _showSnack(BuildContext context, String message) { diff --git a/apps/lib/features/settings/presentation/screens/general_settings_screen.dart b/apps/lib/features/settings/presentation/screens/general_settings_screen.dart index 19a5092..6c1dd80 100644 --- a/apps/lib/features/settings/presentation/screens/general_settings_screen.dart +++ b/apps/lib/features/settings/presentation/screens/general_settings_screen.dart @@ -4,7 +4,6 @@ import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/design_tokens.dart'; import '../../data/models/profile_settings.dart'; import 'language_settings_screen.dart'; -import 'settings_placeholder_screen.dart'; import '../widgets/settings_section_widgets.dart'; class GeneralSettingsScreen extends StatefulWidget { @@ -70,50 +69,8 @@ class _GeneralSettingsScreenState extends State { ), tint: colors.primary, background: colors.surfaceContainerHighest, - onTap: _openLanguageSettings, - ), - SettingsMenuTile( - icon: Icons.auto_awesome_rounded, - title: l10n.settingsAiLanguage, - subtitle: displayLanguageLabel( - l10n, - _settings.preferences.aiLanguage, - ), - tint: colors.secondary, - background: colors.surfaceContainerHighest, - onTap: () => _openPlaceholder( - title: l10n.settingsAiLanguage, - value: displayLanguageLabel( - l10n, - _settings.preferences.aiLanguage, - ), - description: l10n.settingsAiLanguageHint, - ), - ), - SettingsMenuTile( - icon: Icons.public_rounded, - title: l10n.settingsTimezone, - subtitle: _settings.preferences.timezone, - tint: colors.primary, - background: colors.surfaceContainerHighest, - onTap: () => _openPlaceholder( - title: l10n.settingsTimezone, - value: _settings.preferences.timezone, - description: l10n.settingsTimezoneHint, - ), - ), - SettingsMenuTile( - icon: Icons.flag_outlined, - title: l10n.settingsCountry, - subtitle: _settings.preferences.country, - tint: colors.primary, - background: colors.surfaceContainerHighest, showDivider: false, - onTap: () => _openPlaceholder( - title: l10n.settingsCountry, - value: _settings.preferences.country, - description: l10n.settingsCountryHint, - ), + onTap: _openLanguageSettings, ), ], ), @@ -144,20 +101,4 @@ class _GeneralSettingsScreenState extends State { ); }); } - - Future _openPlaceholder({ - required String title, - required String value, - required String description, - }) async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => SettingsPlaceholderScreen( - title: title, - value: value, - description: description, - ), - ), - ); - } } diff --git a/apps/lib/features/settings/presentation/screens/legal_document_screen.dart b/apps/lib/features/settings/presentation/screens/legal_document_screen.dart index 4104c5c..194d8f7 100644 --- a/apps/lib/features/settings/presentation/screens/legal_document_screen.dart +++ b/apps/lib/features/settings/presentation/screens/legal_document_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; +import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; @@ -15,9 +16,23 @@ class LegalDocumentScreen extends StatelessWidget { final String title; final String assetPath; + Future _loadAsset() async { + try { + final data = await rootBundle.loadString(assetPath); + if (data.isEmpty) { + throw Exception('Asset file is empty: $assetPath'); + } + return data; + } catch (e) { + debugPrint('Failed to load asset: $assetPath, error: $e'); + rethrow; + } + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return Scaffold( backgroundColor: colors.surfaceContainerLow, appBar: AppBar( @@ -27,8 +42,43 @@ class LegalDocumentScreen extends StatelessWidget { surfaceTintColor: colors.surfaceContainerLow, ), body: FutureBuilder( - future: rootBundle.loadString(assetPath), + future: _loadAsset(), builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 48, color: colors.error), + const SizedBox(height: AppSpacing.md), + Text( + l10n.legalDocumentLoadFailedTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.sm), + Text( + '${l10n.legalDocumentLoadFailedPathPrefix}: $assetPath', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colors.error), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.xs), + Text( + '${l10n.legalDocumentLoadFailedErrorPrefix}: ${snapshot.error}', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colors.error), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + if (!snapshot.hasData) { return const Center( child: AppLoadingIndicator(variant: AppLoadingVariant.surface), diff --git a/apps/lib/features/settings/presentation/screens/settings_screen.dart b/apps/lib/features/settings/presentation/screens/settings_screen.dart index e71cec9..556b2a8 100644 --- a/apps/lib/features/settings/presentation/screens/settings_screen.dart +++ b/apps/lib/features/settings/presentation/screens/settings_screen.dart @@ -1,14 +1,12 @@ import 'package:flutter/material.dart'; import '../../../../l10n/app_localizations.dart'; -import '../../../../shared/theme/app_color_palette.dart'; import '../../../../shared/theme/design_tokens.dart'; import '../../data/models/profile_settings.dart'; import '../widgets/settings_section_widgets.dart'; import 'coin_center_screen.dart'; import 'general_settings_screen.dart'; import 'legal_center_screen.dart'; -import 'privacy_notification_settings_screen.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({ @@ -44,7 +42,6 @@ class _SettingsScreenState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final colors = Theme.of(context).colorScheme; - final palette = Theme.of(context).extension()!; return Scaffold( backgroundColor: colors.surfaceContainerLow, @@ -62,10 +59,7 @@ class _SettingsScreenState extends State { AppSpacing.xl, ), children: [ - ProfileHeaderCard( - account: widget.account, - version: _settings.version, - ), + ProfileHeaderCard(account: widget.account), const SizedBox(height: AppSpacing.lg), WalletHeroCard( balance: widget.coinBalance, @@ -76,39 +70,16 @@ class _SettingsScreenState extends State { SectionLabel(text: l10n.settingsSectionQuickAccess), SettingsGroupCard( children: [ - SettingsMenuTile( - icon: Icons.toll_rounded, - title: l10n.settingsCoinCenterTitle, - subtitle: l10n.settingsCoinCenterSubtitle(widget.coinBalance), - tint: palette.historyGoldText, - background: palette.historyGoldBg, - onTap: _openCoinCenter, - ), SettingsMenuTile( icon: Icons.tune_rounded, title: l10n.settingsGeneralTitle, - subtitle: l10n.settingsGeneralSubtitle( - displayLanguageLabel( - l10n, - _settings.preferences.interfaceLanguage, - ), - ), tint: colors.primary, background: colors.surfaceContainerHighest, onTap: _openGeneralSettings, ), - SettingsMenuTile( - icon: Icons.privacy_tip_outlined, - title: l10n.settingsPrivacyAndNotificationTitle, - subtitle: l10n.settingsPrivacyAndNotificationSubtitle, - tint: palette.warning, - background: palette.warningContainer, - onTap: _openPrivacyAndNotification, - ), SettingsMenuTile( icon: Icons.description_outlined, title: l10n.settingsLegalCenterTitle, - subtitle: l10n.settingsLegalCenterSubtitle, tint: colors.secondary, background: colors.surfaceContainerHighest, showDivider: false, @@ -117,21 +88,6 @@ class _SettingsScreenState extends State { ], ), const SizedBox(height: AppSpacing.xl), - SectionLabel(text: l10n.settingsSectionAccount), - SettingsGroupCard( - children: [ - SettingsMenuTile( - icon: Icons.logout_rounded, - title: l10n.logout, - subtitle: l10n.settingsLogoutSubtitle, - tint: colors.error, - background: colors.error.withValues(alpha: 0.08), - showDivider: false, - onTap: _confirmLogout, - ), - ], - ), - const SizedBox(height: AppSpacing.xl), FilledButton( onPressed: _isLoggingOut ? null : _confirmLogout, style: FilledButton.styleFrom( @@ -175,14 +131,6 @@ class _SettingsScreenState extends State { }); } - Future _openPrivacyAndNotification() async { - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => PrivacyNotificationSettingsScreen(settings: _settings), - ), - ); - } - Future _openLegalCenter() async { await Navigator.of(context).push( MaterialPageRoute(builder: (_) => const LegalCenterScreen()), diff --git a/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart b/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart index 278e93f..c1c8e19 100644 --- a/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart +++ b/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart @@ -55,18 +55,18 @@ class SettingsMenuTile extends StatelessWidget { super.key, required this.icon, required this.title, - required this.subtitle, required this.tint, required this.background, required this.onTap, this.showDivider = true, this.showChevron = true, this.trailing, + this.subtitle, }); final IconData icon; final String title; - final String subtitle; + final String? subtitle; final Color tint; final Color background; final VoidCallback onTap; @@ -95,10 +95,12 @@ class SettingsMenuTile extends StatelessWidget { child: Icon(icon, color: tint), ), title: Text(title), - subtitle: Padding( - padding: const EdgeInsets.only(top: AppSpacing.xs), - child: Text(subtitle), - ), + subtitle: subtitle == null + ? null + : Padding( + padding: const EdgeInsets.only(top: AppSpacing.xs), + child: Text(subtitle!), + ), trailing: trailing ?? (showChevron @@ -118,18 +120,12 @@ class SettingsMenuTile extends StatelessWidget { } class ProfileHeaderCard extends StatelessWidget { - const ProfileHeaderCard({ - super.key, - required this.account, - required this.version, - }); + const ProfileHeaderCard({super.key, required this.account}); final String account; - final int version; @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; final colors = Theme.of(context).colorScheme; return Card( margin: EdgeInsets.zero, @@ -153,14 +149,10 @@ class ProfileHeaderCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(account, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: AppSpacing.xs), - Text( - '${l10n.settingsVersionLabel}: v$version', - style: Theme.of(context).textTheme.bodySmall, - ), ], ), ), + Icon(Icons.edit_outlined, color: colors.outline, size: 20), ], ), ), @@ -185,17 +177,18 @@ class WalletHeroCard extends StatelessWidget { final l10n = AppLocalizations.of(context)!; final colors = Theme.of(context).colorScheme; final palette = Theme.of(context).extension()!; - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AppRadius.xl), - child: Ink( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppRadius.xl), - gradient: LinearGradient( - colors: [colors.primary, palette.accentPurple], + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppRadius.xl), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.xl), + gradient: LinearGradient( + colors: [colors.primary, palette.accentPurple], + ), ), - ), - child: Padding( padding: const EdgeInsets.all(AppSpacing.xl), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -213,6 +206,13 @@ class WalletHeroCard extends StatelessWidget { color: colors.onPrimary, ), ), + const SizedBox(width: AppSpacing.lg), + Text( + l10n.settingsCoinBalanceValue(balance), + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: colors.onPrimary, + ), + ), const Spacer(), Icon( Icons.chevron_right_rounded, @@ -220,18 +220,14 @@ class WalletHeroCard extends StatelessWidget { ), ], ), - const SizedBox(height: AppSpacing.lg), - Text( - l10n.settingsCoinBalanceValue(balance), - style: Theme.of( - context, - ).textTheme.headlineMedium?.copyWith(color: colors.onPrimary), - ), - const SizedBox(height: AppSpacing.xs), - Text( - subtitle, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colors.onPrimary.withValues(alpha: 0.92), + const SizedBox(height: AppSpacing.md), + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + subtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onPrimary.withValues(alpha: 0.92), + ), ), ), ], diff --git a/apps/lib/l10n/app_en.arb b/apps/lib/l10n/app_en.arb index b479059..a7afb04 100644 --- a/apps/lib/l10n/app_en.arb +++ b/apps/lib/l10n/app_en.arb @@ -24,6 +24,9 @@ "privacyPolicySubtitle": "Learn how we protect user privacy", "termsOfService": "Terms of Service", "termsOfServiceSubtitle": "Learn the service agreement for users", + "legalDocumentLoadFailedTitle": "Failed to load document", + "legalDocumentLoadFailedPathPrefix": "Path", + "legalDocumentLoadFailedErrorPrefix": "Error", "disclaimer": "Disclaimer", "icp": "Yue ICP 2025428416-1A", "invalidPhone": "Please enter a valid phone number", @@ -306,5 +309,7 @@ "timeTab": "Time", "confirm": "Confirm", "cancel": "Cancel", - "autoSelectTime": "Select Time" + "autoSelectTime": "Select Time", + "coinFaceGuideTitle": "Coin Face Guide", + "coinFaceGuideDescription": "字 = side with inscription\n花 = side with pattern" } diff --git a/apps/lib/l10n/app_localizations.dart b/apps/lib/l10n/app_localizations.dart index 98bca97..c9b89c2 100644 --- a/apps/lib/l10n/app_localizations.dart +++ b/apps/lib/l10n/app_localizations.dart @@ -200,6 +200,24 @@ abstract class AppLocalizations { /// **'了解用户服务协议'** String get termsOfServiceSubtitle; + /// No description provided for @legalDocumentLoadFailedTitle. + /// + /// In zh, this message translates to: + /// **'文档加载失败'** + String get legalDocumentLoadFailedTitle; + + /// No description provided for @legalDocumentLoadFailedPathPrefix. + /// + /// In zh, this message translates to: + /// **'路径'** + String get legalDocumentLoadFailedPathPrefix; + + /// No description provided for @legalDocumentLoadFailedErrorPrefix. + /// + /// In zh, this message translates to: + /// **'错误'** + String get legalDocumentLoadFailedErrorPrefix; + /// No description provided for @disclaimer. /// /// In zh, this message translates to: @@ -1429,6 +1447,18 @@ abstract class AppLocalizations { /// In zh, this message translates to: /// **'取消'** String get cancel; + + /// No description provided for @coinFaceGuideTitle. + /// + /// In zh, this message translates to: + /// **'字花图片说明'** + String get coinFaceGuideTitle; + + /// No description provided for @coinFaceGuideDescription. + /// + /// In zh, this message translates to: + /// **'字=铜钱有字的一面\n花=铜钱有花纹的一面'** + String get coinFaceGuideDescription; } class _AppLocalizationsDelegate diff --git a/apps/lib/l10n/app_localizations_en.dart b/apps/lib/l10n/app_localizations_en.dart index 43cc6ab..592cd27 100644 --- a/apps/lib/l10n/app_localizations_en.dart +++ b/apps/lib/l10n/app_localizations_en.dart @@ -62,6 +62,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get termsOfServiceSubtitle => 'Learn the service agreement for users'; + @override + String get legalDocumentLoadFailedTitle => 'Failed to load document'; + + @override + String get legalDocumentLoadFailedPathPrefix => 'Path'; + + @override + String get legalDocumentLoadFailedErrorPrefix => 'Error'; + @override String get disclaimer => 'Disclaimer'; @@ -731,4 +740,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get cancel => 'Cancel'; + + @override + String get coinFaceGuideTitle => 'Coin Face Guide'; + + @override + String get coinFaceGuideDescription => + '字 = side with inscription\n花 = side with pattern'; } diff --git a/apps/lib/l10n/app_localizations_zh.dart b/apps/lib/l10n/app_localizations_zh.dart index b2bd163..f4ebb6b 100644 --- a/apps/lib/l10n/app_localizations_zh.dart +++ b/apps/lib/l10n/app_localizations_zh.dart @@ -61,6 +61,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get termsOfServiceSubtitle => '了解用户服务协议'; + @override + String get legalDocumentLoadFailedTitle => '文档加载失败'; + + @override + String get legalDocumentLoadFailedPathPrefix => '路径'; + + @override + String get legalDocumentLoadFailedErrorPrefix => '错误'; + @override String get disclaimer => '免责声明'; @@ -712,4 +721,10 @@ class AppLocalizationsZh extends AppLocalizations { @override String get cancel => '取消'; + + @override + String get coinFaceGuideTitle => '字花图片说明'; + + @override + String get coinFaceGuideDescription => '字=铜钱有字的一面\n花=铜钱有花纹的一面'; } diff --git a/apps/lib/l10n/app_zh.arb b/apps/lib/l10n/app_zh.arb index c4f30da..87c8d5a 100644 --- a/apps/lib/l10n/app_zh.arb +++ b/apps/lib/l10n/app_zh.arb @@ -24,6 +24,9 @@ "privacyPolicySubtitle": "了解用户隐私保护政策", "termsOfService": "服务条款", "termsOfServiceSubtitle": "了解用户服务协议", + "legalDocumentLoadFailedTitle": "文档加载失败", + "legalDocumentLoadFailedPathPrefix": "路径", + "legalDocumentLoadFailedErrorPrefix": "错误", "disclaimer": "免责声明", "icp": "粤ICP备2025428416号-1A", "invalidPhone": "请输入正确的手机号码", @@ -306,5 +309,7 @@ "timeTab": "时间", "confirm": "确认", "cancel": "取消", - "autoSelectTime": "选择时间" + "autoSelectTime": "选择时间", + "coinFaceGuideTitle": "字花图片说明", + "coinFaceGuideDescription": "字=铜钱有字的一面\n花=铜钱有花纹的一面" } diff --git a/apps/lib/shared/widgets/divination/divination_terms.dart b/apps/lib/shared/widgets/divination/divination_terms.dart index b2504a9..d5d6991 100644 --- a/apps/lib/shared/widgets/divination/divination_terms.dart +++ b/apps/lib/shared/widgets/divination/divination_terms.dart @@ -19,6 +19,12 @@ abstract final class DivinationTerms { static const changeMarkOldYang = '○'; static const changeMarkOldYin = '×'; + static const yinYangSymbol = {true: '—', false: '--'}; + static const youngYangSymbol = '—'; + static const youngYinSymbol = '--'; + static const oldYangSymbol = '—○'; + static const oldYinSymbol = '--×'; + static const signBest = '上上签'; static const signGood = '中上签'; static const signNormal = '中下签'; diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index d593132..855668a 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -79,7 +79,8 @@ flutter: assets: - assets/images/logo.png - assets/images/qigua/ - - assets/legal/ + - assets/legal/en/ + - assets/legal/zh/ # To add assets to your application, add an assets section, like this: # assets: diff --git a/apps/test/features/divination/divination_api_payload_test.dart b/apps/test/features/divination/divination_api_payload_test.dart new file mode 100644 index 0000000..f11f485 --- /dev/null +++ b/apps/test/features/divination/divination_api_payload_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meeyao_qianwen/features/divination/data/apis/divination_api.dart'; +import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart'; + +void main() { + test('buildDivinationRunPayload contains required AG-UI fields', () { + final params = DivinationParams( + method: DivinationMethod.auto, + questionType: QuestionType.career, + question: '什么时候找到工作', + divinationTime: DateTime(2026, 4, 3, 18, 0, 2), + coinBalance: 0, + userId: 'u_test', + ); + final payload = buildDivinationRunPayload( + params: params, + yaoStates: const [ + YaoType.youngYang, + YaoType.youngYang, + YaoType.youngYang, + YaoType.oldYang, + YaoType.oldYin, + YaoType.youngYang, + ], + threadId: 'de44f2fb-de0a-46b9-bbf2-e99ee36f2a2d', + runId: 'run_1775210431957', + clientNow: DateTime(2026, 4, 3, 18, 0, 31, 958), + ); + + expect(payload['state'], isA>()); + expect(payload['tools'], isA>()); + expect(payload['context'], isA>()); + + final forwardedProps = payload['forwardedProps'] as Map; + expect(forwardedProps['runtime_mode'], 'chat'); + final clientTime = forwardedProps['client_time'] as Map; + expect((clientTime['client_now_iso'] as String).endsWith('Z'), isTrue); + + final divinationPayload = + forwardedProps['divinationPayload'] as Map; + expect( + (divinationPayload['divinationTimeIso'] as String).endsWith('Z'), + isTrue, + ); + expect((divinationPayload['yaoLines'] as List).length, 6); + + final messages = payload['messages'] as List; + expect(messages.length, 1); + final userMessage = messages.first as Map; + expect(userMessage['id'], isNotEmpty); + expect(userMessage['role'], 'user'); + expect(userMessage['content'], '什么时候找到工作'); + }); + + test('buildDivinationRunPayload throws when yaoStates length is not 6', () { + final params = DivinationParams( + method: DivinationMethod.auto, + questionType: QuestionType.career, + question: '测试', + divinationTime: DateTime(2026, 4, 3, 18, 0, 2), + coinBalance: 0, + userId: 'u_test', + ); + + expect( + () => buildDivinationRunPayload( + params: params, + yaoStates: const [YaoType.youngYang], + threadId: 'de44f2fb-de0a-46b9-bbf2-e99ee36f2a2d', + runId: 'run_1775210431957', + clientNow: DateTime(2026, 4, 3, 18, 0, 31, 958), + ), + throwsArgumentError, + ); + }); + + test( + 'buildDivinationRunPayload throws when yaoStates contains undetermined', + () { + final params = DivinationParams( + method: DivinationMethod.auto, + questionType: QuestionType.career, + question: '测试', + divinationTime: DateTime(2026, 4, 3, 18, 0, 2), + coinBalance: 0, + userId: 'u_test', + ); + + expect( + () => buildDivinationRunPayload( + params: params, + yaoStates: const [ + YaoType.youngYang, + YaoType.youngYang, + YaoType.youngYang, + YaoType.oldYang, + YaoType.oldYin, + YaoType.undetermined, + ], + threadId: 'de44f2fb-de0a-46b9-bbf2-e99ee36f2a2d', + runId: 'run_1775210431957', + clientNow: DateTime(2026, 4, 3, 18, 0, 31, 958), + ), + throwsArgumentError, + ); + }, + ); +} diff --git a/apps/test/features/divination/divination_backend_models_test.dart b/apps/test/features/divination/divination_backend_models_test.dart new file mode 100644 index 0000000..752cb5d --- /dev/null +++ b/apps/test/features/divination/divination_backend_models_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meeyao_qianwen/features/divination/data/models/divination_backend_models.dart'; + +void main() { + test('YaoBackendLine accepts empty specialMark', () { + final line = YaoBackendLine.fromJson({ + 'position': 1, + 'spiritName': '龙', + 'relationName': '父母', + 'tiganName': '卯', + 'elementName': '木', + 'isYang': true, + 'isChanging': false, + 'specialMark': '', + }); + + expect(line.specialMark, ''); + }); +} diff --git a/apps/test/features/divination/divination_params_test.dart b/apps/test/features/divination/divination_params_test.dart index c8b63a8..9b34792 100644 --- a/apps/test/features/divination/divination_params_test.dart +++ b/apps/test/features/divination/divination_params_test.dart @@ -2,8 +2,15 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart'; void main() { - test('mock data contains valid defaults', () { - final params = DivinationMockData.initial(); + test('params contains valid fields', () { + final params = DivinationParams( + method: DivinationMethod.manual, + questionType: QuestionType.career, + question: '测试问题', + divinationTime: DateTime(2026, 4, 3, 10, 30), + coinBalance: 8, + userId: 'u_test', + ); expect(params.method, DivinationMethod.manual); expect(params.questionType, QuestionType.career); @@ -30,7 +37,14 @@ void main() { }); test('toBinary and toChangedBinary mappings are correct', () { - final params = DivinationMockData.initial(); + final params = DivinationParams( + method: DivinationMethod.manual, + questionType: QuestionType.career, + question: '测试问题', + divinationTime: DateTime(2026, 4, 3, 10, 30), + coinBalance: 8, + userId: 'u_test', + ); final states = [ YaoType.oldYin, YaoType.youngYang, diff --git a/apps/test/features/divination/divination_result_builder_test.dart b/apps/test/features/divination/divination_result_builder_test.dart deleted file mode 100644 index b942853..0000000 --- a/apps/test/features/divination/divination_result_builder_test.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart'; -import 'package:meeyao_qianwen/features/divination/data/services/divination_result_builder.dart'; - -void main() { - final builder = DivinationResultBuilder(); - - test('build returns result with hexagram names and section text', () { - final params = DivinationMockData.initial().copyWith( - method: DivinationMethod.auto, - question: '近期工作是否会有突破', - questionType: QuestionType.career, - ); - - final result = builder.build( - params: params, - yaoStates: const [ - YaoType.youngYang, - YaoType.youngYin, - YaoType.oldYang, - YaoType.youngYin, - YaoType.oldYin, - YaoType.youngYang, - ], - ); - - expect(result.guaName, isNotEmpty); - expect(result.targetGuaName, isNotEmpty); - expect(result.binaryCode, hasLength(6)); - expect(result.changedBinaryCode, hasLength(6)); - expect(result.keywords, contains('签')); - expect(result.conclusion, contains('这个卦象的结果为')); - expect(result.yaoLines.length, 6); - expect(result.targetYaoLines.length, 6); - }); -} diff --git a/apps/test/features/divination/divination_result_screen_test.dart b/apps/test/features/divination/divination_result_screen_test.dart index 812683b..021f089 100644 --- a/apps/test/features/divination/divination_result_screen_test.dart +++ b/apps/test/features/divination/divination_result_screen_test.dart @@ -1,43 +1,180 @@ import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meeyao_qianwen/app/app_theme.dart'; import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart'; -import 'package:meeyao_qianwen/features/divination/data/services/divination_result_builder.dart'; +import 'package:meeyao_qianwen/features/divination/data/models/divination_result.dart'; import 'package:meeyao_qianwen/features/divination/presentation/screens/divination_result_screen.dart'; +import 'package:meeyao_qianwen/l10n/app_localizations.dart'; void main() { testWidgets('result screen shows key sections', (tester) async { - final params = DivinationMockData.initial().copyWith( + final params = DivinationParams( method: DivinationMethod.auto, questionType: QuestionType.health, question: '近期状态是否平稳', + divinationTime: DateTime(2026, 4, 3, 20, 30), + coinBalance: 10, + userId: 'u_test', ); - final data = DivinationResultBuilder().build( + final data = DivinationResultData( params: params, - yaoStates: const [ - YaoType.oldYin, - YaoType.youngYang, - YaoType.youngYin, - YaoType.oldYang, - YaoType.youngYang, - YaoType.oldYin, + binaryCode: '101001', + changedBinaryCode: '100001', + guaName: '山火贲', + targetGuaName: '山雷颐', + upperName: '艮', + lowerName: '离', + signType: '中上签', + keywords: '稳中求进、审时度势、蓄势待发', + conclusion: '1. 方向可行\n2. 节奏宜稳', + analysis: '当前阶段需先稳住节奏,再做关键推进。', + suggestion: '1. 控节奏\n2. 重复盘', + ganzhi: GanzhiData( + yearGanZhi: '丙午', + monthGanZhi: '辛卯', + dayGanZhi: '丁未', + timeGanZhi: '庚戌', + yearKongWang: '子丑', + monthKongWang: '戌亥', + dayKongWang: '寅卯', + timeKongWang: '寅卯', + yueJian: '卯木', + riChen: '未土', + yuePo: '酉金', + riChong: '丑土', + ), + wuXingStatus: {'木': '旺', '火': '相', '土': '休', '金': '囚', '水': '死'}, + yaoLines: [ + YaoLineData( + index: 0, + spirit: '龙', + relation: '父母', + branch: '卯', + element: '木', + type: YaoType.oldYin, + mark: '世', + ), + YaoLineData( + index: 1, + spirit: '雀', + relation: '兄弟', + branch: '丑', + element: '土', + type: YaoType.youngYang, + mark: '', + ), + YaoLineData( + index: 2, + spirit: '勾', + relation: '妻财', + branch: '亥', + element: '水', + type: YaoType.youngYin, + mark: '', + ), + YaoLineData( + index: 3, + spirit: '蛇', + relation: '妻财', + branch: '酉', + element: '金', + type: YaoType.oldYang, + mark: '应', + ), + YaoLineData( + index: 4, + spirit: '虎', + relation: '子孙', + branch: '未', + element: '土', + type: YaoType.youngYang, + mark: '', + ), + YaoLineData( + index: 5, + spirit: '玄', + relation: '兄弟', + branch: '巳', + element: '火', + type: YaoType.oldYin, + mark: '', + ), + ], + targetYaoLines: [ + YaoLineData( + index: 0, + spirit: '龙', + relation: '父母', + branch: '子', + element: '水', + type: YaoType.youngYang, + mark: '世', + ), + YaoLineData( + index: 1, + spirit: '雀', + relation: '兄弟', + branch: '丑', + element: '土', + type: YaoType.youngYang, + mark: '', + ), + YaoLineData( + index: 2, + spirit: '勾', + relation: '妻财', + branch: '亥', + element: '水', + type: YaoType.youngYin, + mark: '', + ), + YaoLineData( + index: 3, + spirit: '蛇', + relation: '妻财', + branch: '申', + element: '金', + type: YaoType.youngYin, + mark: '应', + ), + YaoLineData( + index: 4, + spirit: '虎', + relation: '子孙', + branch: '未', + element: '土', + type: YaoType.youngYang, + mark: '', + ), + YaoLineData( + index: 5, + spirit: '玄', + relation: '兄弟', + branch: '午', + element: '火', + type: YaoType.youngYang, + mark: '', + ), ], ); await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), + locale: const Locale('zh'), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, home: DivinationResultScreen(data: data), ), ); - await tester.pump(); - expect(find.text('天机推演中'), findsOneWidget); - - await tester.pump(const Duration(milliseconds: 450)); - expect(find.text('正在解卦'), findsOneWidget); - - await tester.pump(const Duration(milliseconds: 850)); - expect(find.text('解卦完成\n点击查看'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1000)); + await tester.pumpAndSettle(); expect(find.text('解卦结果'), findsOneWidget); expect(find.text('AI解卦'), findsOneWidget); diff --git a/apps/test/features/divination/divination_screen_test.dart b/apps/test/features/divination/divination_screen_test.dart index 5a080fd..d6cb657 100644 --- a/apps/test/features/divination/divination_screen_test.dart +++ b/apps/test/features/divination/divination_screen_test.dart @@ -1,15 +1,42 @@ import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meeyao_qianwen/app/app_theme.dart'; +import 'package:meeyao_qianwen/core/auth/session_store.dart'; +import 'package:meeyao_qianwen/data/network/api_client.dart'; +import 'package:meeyao_qianwen/data/storage/local_kv_store.dart'; +import 'package:meeyao_qianwen/features/divination/data/apis/divination_api.dart'; import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart'; +import 'package:meeyao_qianwen/features/divination/data/services/divination_run_service.dart'; import 'package:meeyao_qianwen/features/divination/presentation/screens/auto_divination_screen.dart'; import 'package:meeyao_qianwen/features/divination/presentation/screens/divination_screen.dart'; import 'package:meeyao_qianwen/features/divination/presentation/screens/manual_divination_screen.dart'; +import 'package:meeyao_qianwen/l10n/app_localizations.dart'; void main() { + final runService = DivinationRunService( + api: DivinationApi(apiClient: ApiClient(baseUrl: 'http://localhost:5775')), + ); + final sessionStore = SessionStore(LocalKvStore()); + testWidgets('divination screen navigates to auto screen', (tester) async { await tester.pumpWidget( - MaterialApp(theme: AppTheme.light(), home: const DivinationScreen()), + MaterialApp( + theme: AppTheme.light(), + locale: const Locale('zh'), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: DivinationScreen( + sessionStore: sessionStore, + userId: 'user_test', + runServiceOverride: runService, + ), + ), ); await tester.tap(find.text('自动起卦')); @@ -25,15 +52,27 @@ void main() { testWidgets('auto screen keeps resolve button disabled initially', ( tester, ) async { - final params = DivinationMockData.initial().copyWith( + final params = DivinationParams( method: DivinationMethod.auto, + questionType: QuestionType.career, question: '测试问题', + divinationTime: DateTime(2026, 4, 3, 20, 30), + coinBalance: 9, + userId: 'user_test', ); await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), - home: AutoDivinationScreen(params: params), + locale: const Locale('zh'), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: AutoDivinationScreen(params: params, runService: runService), ), ); @@ -49,7 +88,22 @@ void main() { tester, ) async { await tester.pumpWidget( - MaterialApp(theme: AppTheme.light(), home: const DivinationScreen()), + MaterialApp( + theme: AppTheme.light(), + locale: const Locale('zh'), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: DivinationScreen( + sessionStore: sessionStore, + userId: 'user_test', + runServiceOverride: runService, + ), + ), ); await tester.enterText(find.byType(TextField), '近期感情是否稳定'); diff --git a/apps/test/features/divination/manual_divination_screen_test.dart b/apps/test/features/divination/manual_divination_screen_test.dart index 9bb2950..3bcfbf8 100644 --- a/apps/test/features/divination/manual_divination_screen_test.dart +++ b/apps/test/features/divination/manual_divination_screen_test.dart @@ -1,20 +1,41 @@ import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:meeyao_qianwen/app/app_theme.dart'; +import 'package:meeyao_qianwen/data/network/api_client.dart'; +import 'package:meeyao_qianwen/features/divination/data/apis/divination_api.dart'; import 'package:meeyao_qianwen/features/divination/data/models/divination_params.dart'; +import 'package:meeyao_qianwen/features/divination/data/services/divination_run_service.dart'; import 'package:meeyao_qianwen/features/divination/presentation/screens/manual_divination_screen.dart'; +import 'package:meeyao_qianwen/l10n/app_localizations.dart'; void main() { testWidgets('manual screen shows yao legend', (tester) async { - final params = DivinationMockData.initial().copyWith( + final params = DivinationParams( method: DivinationMethod.manual, + questionType: QuestionType.career, question: '测试问题', + divinationTime: DateTime(2026, 4, 3, 20, 30), + coinBalance: 9, + userId: 'user_test', + ); + final runService = DivinationRunService( + api: DivinationApi( + apiClient: ApiClient(baseUrl: 'http://localhost:5775'), + ), ); await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), - home: ManualDivinationScreen(params: params), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: ManualDivinationScreen(params: params, runService: runService), ), ); diff --git a/backend/alembic/versions/20260403_0004_remove_points_reason_code.py b/backend/alembic/versions/20260403_0004_remove_points_reason_code.py index a8727e1..384dc86 100644 --- a/backend/alembic/versions/20260403_0004_remove_points_reason_code.py +++ b/backend/alembic/versions/20260403_0004_remove_points_reason_code.py @@ -111,7 +111,7 @@ def upgrade() -> None: lifetime_spent, version ) - values (new.id, 100, 0, 100, 0, 0) + values (new.id, 60, 0, 60, 0, 0) on conflict (user_id) do nothing; insert into public.points_ledger ( @@ -131,8 +131,8 @@ def upgrade() -> None: v_ledger_id, new.id, 1, - 100, - 100, + 60, + 60, 'register', null, null, @@ -271,7 +271,7 @@ def downgrade() -> None: lifetime_spent, version ) - values (new.id, 100, 0, 100, 0, 0) + values (new.id, 60, 0, 60, 0, 0) on conflict (user_id) do nothing; insert into public.points_ledger ( @@ -291,8 +291,8 @@ def downgrade() -> None: v_ledger_id, new.id, 1, - 100, - 100, + 60, + 60, 'register', null, null, diff --git a/backend/src/core/agentscope/events/agui_codec.py b/backend/src/core/agentscope/events/agui_codec.py index a5c56b9..e85a29e 100644 --- a/backend/src/core/agentscope/events/agui_codec.py +++ b/backend/src/core/agentscope/events/agui_codec.py @@ -17,36 +17,38 @@ from schemas.agent.ui_hints import UiHintsPayload if TYPE_CHECKING: pass -_INTERNAL_TO_AGUI: dict[str, EventType] = { - "run.started": EventType.RUN_STARTED, - "run.finished": EventType.RUN_FINISHED, - "run.error": EventType.RUN_ERROR, - "step.start": EventType.STEP_STARTED, - "step.finish": EventType.STEP_FINISHED, - "text.end": EventType.TEXT_MESSAGE_END, - "tool.start": EventType.TOOL_CALL_START, - "tool.args": EventType.TOOL_CALL_ARGS, - "tool.end": EventType.TOOL_CALL_END, - "tool.result": EventType.TOOL_CALL_RESULT, - "state.snapshot": EventType.STATE_SNAPSHOT, - "messages.snapshot": EventType.MESSAGES_SNAPSHOT, +_INTERNAL_TO_AGUI: dict[str, str] = { + "run.started": EventType.RUN_STARTED.value, + "run.finished": EventType.RUN_FINISHED.value, + "run.error": EventType.RUN_ERROR.value, + "step.start": EventType.STEP_STARTED.value, + "step.finish": EventType.STEP_FINISHED.value, + "text.end": EventType.TEXT_MESSAGE_END.value, + "tool.start": EventType.TOOL_CALL_START.value, + "tool.args": EventType.TOOL_CALL_ARGS.value, + "tool.end": EventType.TOOL_CALL_END.value, + "tool.result": EventType.TOOL_CALL_RESULT.value, + "state.snapshot": EventType.STATE_SNAPSHOT.value, + "messages.snapshot": EventType.MESSAGES_SNAPSHOT.value, } +_ALLOWED_WIRE_TYPES: set[str] = {event_type.value for event_type in EventType} +_ALLOWED_WIRE_TYPES.add("DIVINATION_DERIVED") -def _convert_to_agui_type(internal_type: str) -> EventType: + +def _convert_to_agui_type(internal_type: str) -> str: mapped = _INTERNAL_TO_AGUI.get(internal_type) if mapped is not None: return mapped - return EventType(internal_type.upper().replace(".", "_")) + candidate = internal_type.upper().replace(".", "_") + if candidate in _ALLOWED_WIRE_TYPES: + return candidate + raise ValueError(f"unsupported ag-ui event type: {internal_type}") def _is_agui_event(event: dict[str, Any]) -> bool: - event_type = event.get("type", "") - try: - EventType(event_type) - return True - except ValueError: - return False + event_type = str(event.get("type", "")).strip().upper() + return event_type in _ALLOWED_WIRE_TYPES def _sanitize_agui_event(event: dict[str, Any]) -> dict[str, Any]: @@ -153,7 +155,7 @@ def to_agui_wire_event(event: dict[str, Any] | BaseEvent) -> dict[str, Any]: if internal_type == "text.end" and isinstance(data, dict): text_end_payload: dict[str, Any] = { - "type": _convert_to_agui_type(internal_type).value, + "type": _convert_to_agui_type(internal_type), } if isinstance(thread_id, str) and thread_id: text_end_payload["threadId"] = thread_id @@ -174,7 +176,7 @@ def to_agui_wire_event(event: dict[str, Any] | BaseEvent) -> dict[str, Any]: if internal_type == "tool.result" and isinstance(data, dict): tool_result_payload: dict[str, Any] = { - "type": _convert_to_agui_type(internal_type).value, + "type": _convert_to_agui_type(internal_type), } if isinstance(thread_id, str) and thread_id: tool_result_payload["threadId"] = thread_id @@ -203,7 +205,7 @@ def to_agui_wire_event(event: dict[str, Any] | BaseEvent) -> dict[str, Any]: wire_type = _convert_to_agui_type(internal_type) payload: dict[str, Any] = { - "type": wire_type.value, + "type": wire_type, } if isinstance(thread_id, str) and thread_id: payload["threadId"] = thread_id diff --git a/backend/src/core/agentscope/prompts/user_prompt.py b/backend/src/core/agentscope/prompts/user_prompt.py new file mode 100644 index 0000000..2b1773a --- /dev/null +++ b/backend/src/core/agentscope/prompts/user_prompt.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from schemas.domain.divination import DerivedDivinationData + + +def build_divination_user_prompt(*, derived: DerivedDivinationData) -> str: + structured_json = derived.model_dump_json( + by_alias=True, + exclude_none=True, + ensure_ascii=False, + ) + return ( + f"用户问题:{derived.question}\n" + f"问题类型:{derived.question_type}\n" + "以下是后端推导后的六爻结构化数据(JSON):\n" + f"{structured_json}\n" + "请仅基于以上六爻数据做专业解读。" + ) diff --git a/backend/src/core/agentscope/runtime/runner.py b/backend/src/core/agentscope/runtime/runner.py index 13c36c3..e96d887 100644 --- a/backend/src/core/agentscope/runtime/runner.py +++ b/backend/src/core/agentscope/runtime/runner.py @@ -13,11 +13,14 @@ from agentscope.message import Msg from agentscope.tool import Toolkit from agentscope.model import OpenAIChatModel from core.agentscope.prompts.system_prompt import build_system_prompt +from core.agentscope.prompts.user_prompt import build_divination_user_prompt from core.agentscope.schemas.agui_input import extract_latest_user_payload +from core.divination import derive_divination from core.agentscope.runtime.json_react_agent import JsonReActAgent from core.agentscope.runtime.model_tracking import TrackingChatModel from core.agentscope.runtime.stage_emitter import PipelineStageEmitter from core.agentscope.utils import patch_agentscope_json_repair_compat +from core.agentscope.utils.json_finalize import finalize_json_response from core.config.settings import config from core.db.session import AsyncSessionLocal from models.llm import Llm @@ -26,9 +29,11 @@ from models.system_agents import SystemAgents from schemas.agent.forwarded_props import ( ClientTimeContext, RuntimeMode, + parse_forwarded_props_divination_payload, parse_forwarded_props_client_time, parse_forwarded_props_runtime_mode, ) +from schemas.domain.divination import DerivedDivinationData from schemas.agent.runtime_models import ( WorkerAgentOutputLite, resolve_worker_output_model, @@ -97,6 +102,21 @@ class AgentScopeRunner: worker_toolkit = self._build_toolkit() if cancel_checker is not None and await cancel_checker(): raise asyncio.CancelledError("run canceled by user") + derived_divination = self._resolve_derived_divination( + run_input=run_input + ) + await self._emit_step_event( + pipeline=pipeline, + run_input=run_input, + step_name="divination", + event_type="DIVINATION_DERIVED", + runtime_mode=runtime_mode, + extra_event={ + "divination": derived_divination.model_dump( + mode="json", by_alias=True, exclude_none=True + ) + }, + ) worker_output = await self._execute_worker_step( pipeline=pipeline, run_input=run_input, @@ -106,6 +126,7 @@ class AgentScopeRunner: stage_config=worker_config, runtime_client_time=runtime_client_time, runtime_mode=runtime_mode, + derived_divination=derived_divination, ) return { "worker": worker_output.model_dump(mode="json", exclude_none=True), @@ -187,6 +208,7 @@ class AgentScopeRunner: stage_config: SystemAgentRuntimeConfig, runtime_client_time: ClientTimeContext | None, runtime_mode: RuntimeMode, + derived_divination: DerivedDivinationData, ) -> WorkerAgentOutputLite: worker_output_model = resolve_worker_output_model() await self._emit_step_event( @@ -201,6 +223,7 @@ class AgentScopeRunner: input_messages=self._build_worker_input_messages( context_messages=context_messages, run_input=run_input, + derived_divination=derived_divination, ), toolkit=toolkit, run_input=run_input, @@ -234,6 +257,7 @@ class AgentScopeRunner: runtime_mode: RuntimeMode, ) -> StageExecutionResult: tracking_model = self._build_model(stage_config=stage_config) + formatter = OpenAIChatFormatter() emitter = PipelineStageEmitter( pipeline=pipeline, session_id=run_input.thread_id, @@ -243,32 +267,24 @@ class AgentScopeRunner: emit_text_events=True, emit_tool_events=False, ) - agent = self._build_agent( - agent_name=stage_config.agent_type.value, - system_prompt=build_system_prompt( - agent_type=stage_config.agent_type, - llm_config=stage_config.llm_config, - user_context=user_context, - now_utc=datetime.now(timezone.utc), - runtime_client_time=runtime_client_time, - extra_context=stage_config.extra_context, - tools=None, - ), - toolkit=toolkit, - model=tracking_model, - emitter=emitter, + system_prompt = build_system_prompt( + agent_type=stage_config.agent_type, + llm_config=stage_config.llm_config, + user_context=user_context, + now_utc=datetime.now(timezone.utc), + runtime_client_time=runtime_client_time, + extra_context=stage_config.extra_context, + tools=None, ) - async with self._active_agent_lock: - self._active_agent = agent - try: - response_msg = await agent.reply_json( - input_messages, output_model=worker_output_model - ) - finally: - async with self._active_agent_lock: - if self._active_agent is agent: - self._active_agent = None - worker_payload = worker_output_model.model_validate(response_msg.metadata or {}) + + _, worker_payload_raw = await finalize_json_response( + model=tracking_model, + formatter=formatter, + base_messages=[Msg("system", system_prompt, "system"), *input_messages], + output_model=worker_output_model, + retries=2, + ) + worker_payload = worker_output_model.model_validate(worker_payload_raw) response_metadata = self._llm_pricing_service.build_usage_metadata( model=stage_config.model_code, usage_summary=tracking_model.usage_summary(), @@ -278,7 +294,12 @@ class AgentScopeRunner: response_metadata=response_metadata, ) return StageExecutionResult( - message=response_msg, + message=Msg( + name=stage_config.agent_type.value, + role="assistant", + content=worker_payload.answer, + metadata=worker_payload.model_dump(mode="json", exclude_none=True), + ), payload=worker_payload.model_dump(mode="json", exclude_none=True), response_metadata=response_metadata, ) @@ -288,13 +309,16 @@ class AgentScopeRunner: *, context_messages: list[Msg], run_input: RunAgentInput, + derived_divination: DerivedDivinationData, ) -> list[Msg]: if context_messages: last = context_messages[-1] if last.role == "user": return context_messages - user_text, user_blocks = extract_latest_user_payload(run_input) + _, _ = extract_latest_user_payload(run_input) + user_text = build_divination_user_prompt(derived=derived_divination) + user_blocks = [{"type": "text", "text": user_text}] if ( user_blocks and isinstance(user_blocks[0], dict) @@ -307,6 +331,17 @@ class AgentScopeRunner: user_msg = Msg(name="user", role="user", content=content) return [*context_messages, user_msg] + @staticmethod + def _resolve_derived_divination( + *, run_input: RunAgentInput + ) -> DerivedDivinationData: + payload = parse_forwarded_props_divination_payload( + getattr(run_input, "forwarded_props", None) + ) + if payload is None: + raise ValueError("forwardedProps.divinationPayload is required") + return derive_divination(payload) + def _build_model( self, *, stage_config: SystemAgentRuntimeConfig ) -> TrackingChatModel: diff --git a/backend/src/core/divination/__init__.py b/backend/src/core/divination/__init__.py new file mode 100644 index 0000000..54792a5 --- /dev/null +++ b/backend/src/core/divination/__init__.py @@ -0,0 +1,3 @@ +from core.divination.derivation import derive_divination + +__all__ = ["derive_divination"] diff --git a/backend/src/core/divination/derivation.py b/backend/src/core/divination/derivation.py new file mode 100644 index 0000000..fd1fc14 --- /dev/null +++ b/backend/src/core/divination/derivation.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +from datetime import datetime + +from lunar_python import Solar + +from core.divination.gua_catalog_loader import GuaCatalogItem, load_gua_catalog +from schemas.domain.divination import ( + DerivedDivinationData, + DivinationPayload, + FushenDetail, + GanzhiDetail, + SpecialMark, + YaoDetail, + YaoType, +) + +_DI_ZHI_ORDER = ("子", "丑", "寅", "卯", "辰", "巳", "午", "未", "申", "酉", "戌", "亥") +_TIAN_GAN_ORDER = ("甲", "乙", "丙", "丁", "戊", "己", "庚", "辛", "壬", "癸") + + +def _yao_to_bit(yao: YaoType) -> str: + if yao in (YaoType.SHAO_YANG, YaoType.LAO_YANG): + return "1" + return "0" + + +def _yao_to_changed_bit(yao: YaoType) -> str: + if yao == YaoType.SHAO_YANG: + return "1" + if yao == YaoType.SHAO_YIN: + return "0" + if yao == YaoType.LAO_YANG: + return "0" + return "1" + + +def _yao_is_yang(yao: YaoType) -> bool: + return yao in (YaoType.SHAO_YANG, YaoType.LAO_YANG) + + +def _yao_is_changing(yao: YaoType) -> bool: + return yao in (YaoType.LAO_YANG, YaoType.LAO_YIN) + + +def _resolve_liu_shou(day_gan: str) -> tuple[str, ...]: + start = { + "甲": 0, + "乙": 0, + "丙": 1, + "丁": 1, + "戊": 2, + "己": 3, + "庚": 4, + "辛": 4, + "壬": 5, + "癸": 5, + }.get(day_gan, 0) + base = ("龙", "雀", "勾", "蛇", "虎", "玄") + return tuple(base[(start + i) % 6] for i in range(6)) + + +def _di_zhi_wu_xing(di_zhi: str) -> str: + if di_zhi in ("寅", "卯"): + return "木" + if di_zhi in ("巳", "午"): + return "火" + if di_zhi in ("申", "酉"): + return "金" + if di_zhi in ("亥", "子"): + return "水" + return "土" + + +def _chong_di_zhi(di_zhi: str) -> str: + return { + "子": "午", + "午": "子", + "丑": "未", + "未": "丑", + "寅": "申", + "申": "寅", + "卯": "酉", + "酉": "卯", + "辰": "戌", + "戌": "辰", + "巳": "亥", + "亥": "巳", + }.get(di_zhi, "") + + +def _wu_xing_status(month_di_zhi: str, wu_xing: str) -> str: + table = { + "寅": {"木": "旺", "火": "相", "水": "休", "金": "囚", "土": "死"}, + "卯": {"木": "旺", "火": "相", "水": "休", "金": "囚", "土": "死"}, + "巳": {"火": "旺", "土": "相", "木": "休", "水": "囚", "金": "死"}, + "午": {"火": "旺", "土": "相", "木": "休", "水": "囚", "金": "死"}, + "申": {"金": "旺", "水": "相", "土": "休", "火": "囚", "木": "死"}, + "酉": {"金": "旺", "水": "相", "土": "休", "火": "囚", "木": "死"}, + "亥": {"水": "旺", "木": "相", "金": "休", "土": "囚", "火": "死"}, + "子": {"水": "旺", "木": "相", "金": "休", "土": "囚", "火": "死"}, + "辰": {"土": "旺", "金": "相", "火": "休", "木": "囚", "水": "死"}, + "未": {"土": "旺", "金": "相", "火": "休", "木": "囚", "水": "死"}, + "戌": {"土": "旺", "金": "相", "火": "休", "木": "囚", "水": "死"}, + "丑": {"土": "旺", "金": "相", "火": "休", "木": "囚", "水": "死"}, + } + return table.get(month_di_zhi, {}).get(wu_xing, "") + + +def _get_kong_wang(gan_zhi: str) -> str: + tian_gan = gan_zhi[0] + di_zhi = gan_zhi[1] + tian_gan_value = _TIAN_GAN_ORDER.index(tian_gan) + 1 + di_zhi_value = _DI_ZHI_ORDER.index(di_zhi) + 1 + start = di_zhi_value - tian_gan_value - 1 + if start < 0: + start += 12 + start %= 12 + if start == 0: + start = 12 + first = _DI_ZHI_ORDER[start - 1] + second = _DI_ZHI_ORDER[start % 12] + return f"{first}{second}" + + +def _get_all_wu_xing_status(month_gan_zhi: str) -> dict[str, str]: + month_di_zhi = month_gan_zhi[1] + return { + "木": _wu_xing_status(month_di_zhi, "木"), + "火": _wu_xing_status(month_di_zhi, "火"), + "土": _wu_xing_status(month_di_zhi, "土"), + "金": _wu_xing_status(month_di_zhi, "金"), + "水": _wu_xing_status(month_di_zhi, "水"), + } + + +def _resolve_special_mark( + *, index: int, world_position: int, response_position: int +) -> SpecialMark: + position = index + 1 + if position == world_position: + return SpecialMark.SHI + if position == response_position: + return SpecialMark.YING + return SpecialMark.NONE + + +def _build_target_relation_names( + base_item: GuaCatalogItem, target_item: GuaCatalogItem +) -> tuple[str, ...]: + gua_gong_wu_xing = { + "乾": "金", + "坤": "土", + "震": "木", + "巽": "木", + "坎": "水", + "离": "火", + "艮": "土", + "兑": "金", + }.get(base_item.upper_name, "金") + + def calculate(yao_wu_xing: str) -> str: + if gua_gong_wu_xing == yao_wu_xing: + return "兄弟" + table = { + "木": {"火": "子孙", "土": "妻财", "金": "官鬼", "水": "父母"}, + "火": {"土": "子孙", "金": "妻财", "水": "官鬼", "木": "父母"}, + "土": {"金": "子孙", "水": "妻财", "木": "官鬼", "火": "父母"}, + "金": {"水": "子孙", "木": "妻财", "火": "官鬼", "土": "父母"}, + "水": {"木": "子孙", "火": "妻财", "土": "官鬼", "金": "父母"}, + } + return table.get(gua_gong_wu_xing, {}).get(yao_wu_xing, "兄弟") + + return tuple(calculate(element) for element in target_item.yao_elements) + + +def derive_divination(payload: DivinationPayload) -> DerivedDivinationData: + catalog = load_gua_catalog() + binary_code = "".join(_yao_to_bit(yao) for yao in payload.yao_lines) + changed_binary_code = "".join(_yao_to_changed_bit(yao) for yao in payload.yao_lines) + base_item = catalog[binary_code] + target_item = catalog[changed_binary_code] + has_changing_yao = any(_yao_is_changing(yao) for yao in payload.yao_lines) + + dt = datetime.fromisoformat(payload.divination_time_iso.replace("Z", "+00:00")) + solar = Solar.fromYmdHms(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second) + lunar = solar.getLunar() + year_gan_zhi = lunar.getYearInGanZhi() + month_gan_zhi = lunar.getMonthInGanZhi() + day_gan_zhi = lunar.getDayInGanZhi() + time_gan_zhi = lunar.getTimeInGanZhi() + liu_shou = _resolve_liu_shou(day_gan_zhi[0]) + target_relations = _build_target_relation_names(base_item, target_item) + + yao_info_list = [ + YaoDetail( + position=index + 1, + spiritName=liu_shou[index], + relationName=base_item.yao_relations[index], + tiganName=base_item.yao_tigan[index], + elementName=base_item.yao_elements[index], + isYang=_yao_is_yang(payload.yao_lines[index]), + isChanging=_yao_is_changing(payload.yao_lines[index]), + specialMark=_resolve_special_mark( + index=index, + world_position=base_item.world_position, + response_position=base_item.response_position, + ), + ) + for index in range(6) + ] + + target_yao_info_list = [ + YaoDetail( + position=index + 1, + spiritName=liu_shou[index], + relationName=target_relations[index], + tiganName=target_item.yao_tigan[index], + elementName=target_item.yao_elements[index], + isYang=_yao_is_yang(payload.yao_lines[index]) + if not _yao_is_changing(payload.yao_lines[index]) + else not _yao_is_yang(payload.yao_lines[index]), + isChanging=False, + specialMark=_resolve_special_mark( + index=index, + world_position=target_item.world_position, + response_position=target_item.response_position, + ), + ) + for index in range(6) + ] + + month_di_zhi = month_gan_zhi[1] + month_chong = _chong_di_zhi(month_di_zhi) + day_di_zhi = day_gan_zhi[1] + day_chong = _chong_di_zhi(day_di_zhi) + + fushen_info_list = [ + FushenDetail( + position=base_item.fushen_positions[idx] + 1, + relationName=base_item.fushen_relations[idx], + tiganName=base_item.fushen_tigan[idx], + elementName=base_item.fushen_elements[idx], + ) + for idx in range(len(base_item.fushen_positions)) + ] + + return DerivedDivinationData( + question=payload.question, + questionType=payload.question_type, + divinationMethod=payload.divination_method, + divinationTime=dt.strftime("%Y年%m月%d日 %H:%M"), + binaryCode=binary_code, + changedBinaryCode=changed_binary_code, + guaName=base_item.name, + upperName=base_item.upper_name, + lowerName=base_item.lower_name, + targetGuaName=target_item.name, + worldPosition=base_item.world_position, + responsePosition=base_item.response_position, + hasChangingYao=has_changing_yao, + ganzhi=GanzhiDetail( + yearGanZhi=year_gan_zhi, + monthGanZhi=month_gan_zhi, + dayGanZhi=day_gan_zhi, + timeGanZhi=time_gan_zhi, + yearKongWang=_get_kong_wang(year_gan_zhi), + monthKongWang=_get_kong_wang(month_gan_zhi), + dayKongWang=_get_kong_wang(day_gan_zhi), + timeKongWang=_get_kong_wang(time_gan_zhi), + yueJian=f"{month_di_zhi}{_di_zhi_wu_xing(month_di_zhi)}", + riChen=f"{day_di_zhi}{_di_zhi_wu_xing(day_di_zhi)}", + yuePo=f"{month_chong}{_di_zhi_wu_xing(month_chong)}", + riChong=f"{day_chong}{_di_zhi_wu_xing(day_chong)}", + ), + wuXingStatuses=_get_all_wu_xing_status(month_gan_zhi), + yaoInfoList=yao_info_list, + targetYaoInfoList=target_yao_info_list if has_changing_yao else [], + fushenPositions=[item + 1 for item in base_item.fushen_positions], + fushenInfoList=fushen_info_list, + ) diff --git a/backend/src/core/divination/gua_catalog_loader.py b/backend/src/core/divination/gua_catalog_loader.py new file mode 100644 index 0000000..ce950f9 --- /dev/null +++ b/backend/src/core/divination/gua_catalog_loader.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +import re + + +@dataclass(frozen=True) +class GuaCatalogItem: + name: str + binary: str + upper_name: str + lower_name: str + yao_relations: tuple[str, ...] + yao_tigan: tuple[str, ...] + yao_elements: tuple[str, ...] + world_position: int + response_position: int + fushen_positions: tuple[int, ...] + fushen_relations: tuple[str, ...] + fushen_tigan: tuple[str, ...] + fushen_elements: tuple[str, ...] + + +_ENTRY_HEAD_RE = re.compile(r'put\("([01]{6})",\s*GuaInfo\(', re.MULTILINE) +_STRING_FIELD_RE = re.compile(r'\b%s\s*=\s*"([^"]*)"') +_INT_FIELD_RE = re.compile(r"\b%s\s*=\s*(\d+)") +_LIST_STRING_FIELD_RE = re.compile(r"\b%s\s*=\s*listOf\((.*?)\)", re.DOTALL) +_LIST_INT_FIELD_RE = re.compile(r"\b%s\s*=\s*listOf\((.*?)\)", re.DOTALL) + + +def _extract_gua_body(source: str, start_idx: int) -> tuple[str, int]: + depth = 1 + idx = start_idx + while idx < len(source): + ch = source[idx] + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + return source[start_idx:idx], idx + idx += 1 + raise ValueError("invalid Guaxiang.kt structure: unmatched parenthesis") + + +def _parse_string_field(body: str, field_name: str) -> str: + match = _STRING_FIELD_RE.pattern % re.escape(field_name) + found = re.search(match, body) + if found is None: + raise ValueError(f"missing field: {field_name}") + return found.group(1) + + +def _parse_int_field(body: str, field_name: str) -> int: + match = _INT_FIELD_RE.pattern % re.escape(field_name) + found = re.search(match, body) + if found is None: + raise ValueError(f"missing field: {field_name}") + return int(found.group(1)) + + +def _parse_list_of_strings( + body: str, field_name: str, *, optional: bool = False +) -> tuple[str, ...]: + if f"{field_name} = emptyList()" in body: + return () + match = _LIST_STRING_FIELD_RE.pattern % re.escape(field_name) + found = re.search(match, body) + if found is None: + if optional: + return () + raise ValueError(f"missing list field: {field_name}") + inner = found.group(1) + values = re.findall(r'"([^"]+)"', inner) + return tuple(values) + + +def _parse_list_of_ints( + body: str, field_name: str, *, optional: bool = False +) -> tuple[int, ...]: + if f"{field_name} = emptyList()" in body: + return () + match = _LIST_INT_FIELD_RE.pattern % re.escape(field_name) + found = re.search(match, body) + if found is None: + if optional: + return () + raise ValueError(f"missing list field: {field_name}") + inner = found.group(1) + values = [int(item.strip()) for item in inner.split(",") if item.strip()] + return tuple(values) + + +def _resolve_guaxiang_file() -> Path: + current = Path(__file__).resolve() + root = current.parents[4] + target = ( + root / "old/app/src/main/java/com/example/eryaoapp/screens/result/Guaxiang.kt" + ) + if not target.exists(): + raise FileNotFoundError(f"Guaxiang.kt not found: {target}") + return target + + +@lru_cache(maxsize=1) +def load_gua_catalog() -> dict[str, GuaCatalogItem]: + source = _resolve_guaxiang_file().read_text(encoding="utf-8") + result: dict[str, GuaCatalogItem] = {} + for head in _ENTRY_HEAD_RE.finditer(source): + binary = head.group(1) + body, _ = _extract_gua_body(source, head.end()) + item = GuaCatalogItem( + name=_parse_string_field(body, "name"), + binary=_parse_string_field(body, "binary"), + upper_name=_parse_string_field(body, "upperName"), + lower_name=_parse_string_field(body, "lowerName"), + yao_relations=_parse_list_of_strings(body, "yaoRelations"), + yao_tigan=_parse_list_of_strings(body, "yaoTiGan"), + yao_elements=_parse_list_of_strings(body, "yaoElements"), + world_position=_parse_int_field(body, "worldPosition"), + response_position=_parse_int_field(body, "responsePosition"), + fushen_positions=_parse_list_of_ints( + body, "fushenPositions", optional=True + ), + fushen_relations=_parse_list_of_strings( + body, "fushenRelations", optional=True + ), + fushen_tigan=_parse_list_of_strings(body, "fushenTiGan", optional=True), + fushen_elements=_parse_list_of_strings( + body, "fushenElements", optional=True + ), + ) + result[binary] = item + + if len(result) != 64: + raise ValueError(f"invalid gua catalog size: {len(result)}") + return result diff --git a/backend/src/schemas/agent/forwarded_props.py b/backend/src/schemas/agent/forwarded_props.py index 8d9e412..11db3b8 100644 --- a/backend/src/schemas/agent/forwarded_props.py +++ b/backend/src/schemas/agent/forwarded_props.py @@ -14,6 +14,8 @@ from pydantic import ( field_validator, ) +from ..domain.divination import DivinationPayload + _RFC3339_WITH_TZ_PATTERN = re.compile( r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$" ) @@ -69,6 +71,9 @@ class ForwardedPropsPayload(BaseModel): runtime_mode: RuntimeMode client_time: ClientTimeContext | None = None + divination_payload: DivinationPayload | None = Field( + default=None, alias="divinationPayload" + ) def parse_forwarded_props(forwarded_props: object) -> ForwardedPropsPayload: @@ -90,3 +95,10 @@ def parse_forwarded_props_client_time( def parse_forwarded_props_runtime_mode(forwarded_props: object) -> RuntimeMode: payload = parse_forwarded_props(forwarded_props) return payload.runtime_mode + + +def parse_forwarded_props_divination_payload( + forwarded_props: object, +) -> DivinationPayload | None: + payload = parse_forwarded_props(forwarded_props) + return payload.divination_payload diff --git a/backend/src/schemas/domain/divination.py b/backend/src/schemas/domain/divination.py new file mode 100644 index 0000000..d7a8a15 --- /dev/null +++ b/backend/src/schemas/domain/divination.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class DivinationMethod(str, Enum): + MANUAL = "手动起卦" + AUTO = "自动起卦" + + +class YaoType(str, Enum): + SHAO_YANG = "少阳" + SHAO_YIN = "少阴" + LAO_YANG = "老阳" + LAO_YIN = "老阴" + + +class SpecialMark(str, Enum): + SHI = "世" + YING = "应" + NONE = "" + + +class YaoDetail(BaseModel): + model_config = ConfigDict(extra="forbid") + + position: int = Field(ge=1, le=6) + spirit_name: str = Field(alias="spiritName", min_length=1) + relation_name: str = Field(alias="relationName", min_length=1) + tigan_name: str = Field(alias="tiganName", min_length=1) + element_name: str = Field(alias="elementName", min_length=1) + is_yang: bool = Field(alias="isYang") + is_changing: bool = Field(alias="isChanging") + special_mark: SpecialMark = Field(alias="specialMark", default=SpecialMark.NONE) + + +class FushenDetail(BaseModel): + model_config = ConfigDict(extra="forbid") + + position: int = Field(ge=1, le=6) + relation_name: str = Field(alias="relationName", min_length=1) + tigan_name: str = Field(alias="tiganName", min_length=1) + element_name: str = Field(alias="elementName", min_length=1) + + +class GanzhiDetail(BaseModel): + model_config = ConfigDict(extra="forbid") + + year_gan_zhi: str = Field(alias="yearGanZhi", min_length=2, max_length=2) + month_gan_zhi: str = Field(alias="monthGanZhi", min_length=2, max_length=2) + day_gan_zhi: str = Field(alias="dayGanZhi", min_length=2, max_length=2) + time_gan_zhi: str = Field(alias="timeGanZhi", min_length=2, max_length=2) + year_kong_wang: str = Field(alias="yearKongWang", min_length=2, max_length=2) + month_kong_wang: str = Field(alias="monthKongWang", min_length=2, max_length=2) + day_kong_wang: str = Field(alias="dayKongWang", min_length=2, max_length=2) + time_kong_wang: str = Field(alias="timeKongWang", min_length=2, max_length=2) + yue_jian: str = Field(alias="yueJian", min_length=2) + ri_chen: str = Field(alias="riChen", min_length=2) + yue_po: str = Field(alias="yuePo", min_length=2) + ri_chong: str = Field(alias="riChong", min_length=2) + + +class DivinationPayload(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + divination_method: DivinationMethod = Field(alias="divinationMethod") + question_type: str = Field(alias="questionType", min_length=1, max_length=32) + question: str = Field(min_length=1, max_length=300) + divination_time_iso: str = Field(alias="divinationTimeIso", min_length=20) + yao_lines: list[YaoType] = Field(alias="yaoLines", min_length=6, max_length=6) + + @field_validator("divination_time_iso") + @classmethod + def validate_divination_time_iso(cls, value: str) -> str: + normalized = value.replace("Z", "+00:00") + dt = datetime.fromisoformat(normalized) + if dt.tzinfo is None: + raise ValueError("divinationTimeIso must include timezone") + return value + + +class DerivedDivinationData(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + question: str = Field(min_length=1) + question_type: str = Field(alias="questionType", min_length=1) + divination_method: DivinationMethod = Field(alias="divinationMethod") + divination_time: str = Field(alias="divinationTime", min_length=1) + binary_code: str = Field(alias="binaryCode", min_length=6, max_length=6) + changed_binary_code: str = Field( + alias="changedBinaryCode", min_length=6, max_length=6 + ) + gua_name: str = Field(alias="guaName", min_length=2) + upper_name: str = Field(alias="upperName", min_length=1) + lower_name: str = Field(alias="lowerName", min_length=1) + target_gua_name: str = Field(alias="targetGuaName", min_length=2) + world_position: int = Field(alias="worldPosition", ge=1, le=6) + response_position: int = Field(alias="responsePosition", ge=1, le=6) + has_changing_yao: bool = Field(alias="hasChangingYao") + ganzhi: GanzhiDetail + wu_xing_statuses: dict[str, str] = Field(alias="wuXingStatuses") + yao_info_list: list[YaoDetail] = Field( + alias="yaoInfoList", min_length=6, max_length=6 + ) + target_yao_info_list: list[YaoDetail] = Field( + alias="targetYaoInfoList", default_factory=list + ) + fushen_positions: list[int] = Field(alias="fushenPositions", default_factory=list) + fushen_info_list: list[FushenDetail] = Field( + alias="fushenInfoList", default_factory=list + ) diff --git a/backend/src/v1/agent/service.py b/backend/src/v1/agent/service.py index 052811c..1aedc21 100644 --- a/backend/src/v1/agent/service.py +++ b/backend/src/v1/agent/service.py @@ -17,6 +17,7 @@ from core.agentscope.schemas.agui_input import extract_latest_user_payload from core.config.settings import config from core.logging import get_logger from schemas.agent.forwarded_props import ( + parse_forwarded_props_divination_payload, parse_forwarded_props_runtime_mode, RuntimeMode, ) @@ -93,11 +94,22 @@ class AgentService: forwarded_props = getattr(run_input, "forwarded_props", None) try: runtime_mode = parse_forwarded_props_runtime_mode(forwarded_props) + divination_payload = parse_forwarded_props_divination_payload( + forwarded_props + ) except ValueError as exc: raise ApiProblemError( status_code=422, detail=problem_payload(code="AGENT_PAYLOAD_INVALID", detail=str(exc)), ) from exc + if divination_payload is None: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AGENT_DIVINATION_PAYLOAD_REQUIRED", + detail="forwardedProps.divinationPayload is required", + ), + ) if runtime_config is None: from v1.agent.system_agents_config import ( diff --git a/backend/src/v1/points/dependencies.py b/backend/src/v1/points/dependencies.py new file mode 100644 index 0000000..bb9cf7a --- /dev/null +++ b/backend/src/v1/points/dependencies.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from core.db import get_db +from v1.points.repository import PointsRepository +from v1.points.service import PointsService + + +def get_points_service(session: AsyncSession = Depends(get_db)) -> PointsService: + return PointsService(repository=PointsRepository(session)) diff --git a/backend/src/v1/points/repository.py b/backend/src/v1/points/repository.py index bb1d77f..b48e672 100644 --- a/backend/src/v1/points/repository.py +++ b/backend/src/v1/points/repository.py @@ -56,3 +56,14 @@ class PointsRepository: ) self._session.add(entry) await self._session.flush() + + async def get_user_points(self, *, user_id: UUID) -> UserPoints: + insert_stmt = ( + insert(UserPoints) + .values(user_id=user_id) + .on_conflict_do_nothing(index_elements=[UserPoints.user_id]) + ) + await self._session.execute(insert_stmt) + + stmt = select(UserPoints).where(UserPoints.user_id == user_id) + return (await self._session.execute(stmt)).scalar_one() diff --git a/backend/src/v1/points/router.py b/backend/src/v1/points/router.py new file mode 100644 index 0000000..0140690 --- /dev/null +++ b/backend/src/v1/points/router.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends + +from core.auth.models import CurrentUser +from v1.points.dependencies import get_points_service +from v1.points.schemas import PointsBalanceResponse +from v1.points.service import PointsService +from v1.users.dependencies import get_current_user + +router = APIRouter(prefix="/points", tags=["points"]) + + +@router.get("/balance", response_model=PointsBalanceResponse) +async def get_points_balance( + service: Annotated[PointsService, Depends(get_points_service)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], +) -> PointsBalanceResponse: + result = await service.get_points_balance(user_id=current_user.id) + return PointsBalanceResponse( + balance=result.balance, + frozenBalance=result.frozen_balance, + availableBalance=result.available_balance, + runCost=result.run_cost, + canRun=result.can_run, + ) diff --git a/backend/src/v1/points/schemas.py b/backend/src/v1/points/schemas.py new file mode 100644 index 0000000..384478b --- /dev/null +++ b/backend/src/v1/points/schemas.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class PointsBalanceResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) + + balance: int = Field(ge=0) + frozen_balance: int = Field(alias="frozenBalance", ge=0) + available_balance: int = Field(alias="availableBalance", ge=0) + run_cost: int = Field(alias="runCost", gt=0) + can_run: bool = Field(alias="canRun") diff --git a/backend/src/v1/points/service.py b/backend/src/v1/points/service.py index 151ffa0..09f80b5 100644 --- a/backend/src/v1/points/service.py +++ b/backend/src/v1/points/service.py @@ -22,6 +22,15 @@ class RunChargeResult: event_id: str +@dataclass(frozen=True) +class PointsBalanceResult: + balance: int + frozen_balance: int + available_balance: int + run_cost: int + can_run: bool + + class PointsService: def __init__(self, repository: PointsRepository) -> None: self._repository = repository @@ -51,6 +60,23 @@ class PointsService: ) return available + async def get_points_balance( + self, + *, + user_id: UUID, + ) -> PointsBalanceResult: + account = await self._repository.get_user_points(user_id=user_id) + balance = int(account.balance) + frozen_balance = int(account.frozen_balance) + available = max(balance - frozen_balance, 0) + return PointsBalanceResult( + balance=balance, + frozen_balance=frozen_balance, + available_balance=available, + run_cost=RUN_POINTS_COST, + can_run=available >= RUN_POINTS_COST, + ) + async def consume_successful_run_points( self, *, diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py index e512b27..c0faffc 100644 --- a/backend/src/v1/router.py +++ b/backend/src/v1/router.py @@ -4,8 +4,10 @@ from fastapi import APIRouter from v1.agent.router import router as agent_router from v1.auth.router import router as auth_router +from v1.points.router import router as points_router router = APIRouter(prefix="/api/v1") router.include_router(auth_router) router.include_router(agent_router) +router.include_router(points_router) diff --git a/backend/tests/unit/test_agui_codec.py b/backend/tests/unit/test_agui_codec.py new file mode 100644 index 0000000..35cebab --- /dev/null +++ b/backend/tests/unit/test_agui_codec.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from core.agentscope.events.agui_codec import to_agui_wire_event + + +def test_to_agui_wire_event_supports_custom_internal_type() -> None: + event = { + "type": "DIVINATION_DERIVED", + "threadId": "t1", + "runId": "r1", + "divination": {"guaName": "山火贲"}, + } + + wire = to_agui_wire_event(event) + + assert wire["type"] == "DIVINATION_DERIVED" + assert wire["threadId"] == "t1" + assert wire["runId"] == "r1" + assert wire["divination"] == {"guaName": "山火贲"} + + +def test_to_agui_wire_event_rejects_unknown_type() -> None: + event = { + "type": "UNCONTROLLED_CUSTOM_EVENT", + "threadId": "t1", + "runId": "r1", + } + + try: + to_agui_wire_event(event) + except ValueError as exc: + assert "unsupported ag-ui event type" in str(exc) + return + raise AssertionError("expected ValueError for unsupported event type") + + +def test_to_agui_wire_event_maps_known_internal_type() -> None: + event = { + "type": "run.started", + "threadId": "t1", + "runId": "r1", + } + + wire = to_agui_wire_event(event) + + assert wire["type"] == "RUN_STARTED" + assert wire["threadId"] == "t1" + assert wire["runId"] == "r1" diff --git a/docs/plans/2026-04-03-datetime-picker-design.md b/docs/plans/2026-04-03-datetime-picker-design.md new file mode 100644 index 0000000..d83a144 --- /dev/null +++ b/docs/plans/2026-04-03-datetime-picker-design.md @@ -0,0 +1,76 @@ +# 摇卦页面日期时间选择器优化设计 + +## 1. 现状问题 + +1. **硬编码日期格式**:`DateFormat('yyyy年MM月dd日 HH:mm')` 在3处硬编码,未做 l10n + - `auto_divination_screen.dart:353` + - `manual_divination_screen.dart:271` + - `divination_result_screen.dart:455` + +2. **原生 picker 样式简陋**:使用 Material `showDatePicker` + `showTimePicker`,交互体验差 + +## 2. 优化方案 + +### 2.1 自定义底部弹层时间选择器 + +- 使用 `CupertinoDatePicker`(iOS 滚轮样式)替代原生 Material picker +- 底部弹层,带半透明遮罩和圆角动画 +- 日期/时间在同一个 picker 内通过 SegmentedControl 切换 + +### 2.2 Locale-aware 日期格式化 + +使用 `intl` 包实现: +- 中文 locale:`DateFormat.yMd('zh_CN').add_Hm()` → `2026年4月3日 14:30` +- 英文 locale:`DateFormat.yMd('en').add_Hm()` → `4/3/2026 14:30` + +### 2.3 新增 l10n 键值 + +已有键值: +- `autoSelectTime`: "选择起卦时间" / "Select time" +- `manualSelectTime`: "选择起卦时间" / "Select time" +- `divinationModify`: "修改" / "Modify" + +无需新增键值,日期格式完全由 `intl` 包根据 locale 自动处理。 + +## 3. 组件结构 + +``` +apps/lib/shared/widgets/ + └── date_time_picker/ + └── date_time_picker_bottom_sheet.dart # 弹层容器 +``` + +**DateTimePickerBottomSheet** 接口: +```dart +Future showDateTimePickerBottomSheet({ + required BuildContext context, + required DateTime initialDateTime, + DateTime? minDateTime, + DateTime? maxDateTime, +}); +``` + +## 4. 交互流程 + +1. 用户点击"修改"按钮 +2. 底部弹出 `DateTimePickerBottomSheet` +3. SegmentedControl 切换"日期"/"时间"tab +4. Cupertino 滚轮选择值 +5. 点击"确认"关闭弹层并更新状态 + +## 5. 涉及的改动文件 + +### 新建 +- `apps/lib/shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart` + +### 修改 +- `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart` +- `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart` +- `apps/lib/features/divination/presentation/screens/divination_result_screen.dart` + +## 6. 验收标准 + +1. 日期格式跟随系统语言:中文环境显示中文格式,英文环境显示英文格式 +2. 选择器使用 iOS 滚轮样式 +3. 底部弹层带遮罩动画 +4. 原硬编码格式完全移除 diff --git a/docs/plans/2026-04-03-datetime-picker-impl.md b/docs/plans/2026-04-03-datetime-picker-impl.md new file mode 100644 index 0000000..876f355 --- /dev/null +++ b/docs/plans/2026-04-03-datetime-picker-impl.md @@ -0,0 +1,341 @@ +# 日期时间选择器优化实现计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 将摇卦页面的日期时间选择器改为 iOS 滚轮样式,并实现 locale-aware 格式化 + +**Architecture:** 创建共享的 `DateTimePickerBottomSheet` 组件,封装 `CupertinoDatePicker` 和底部弹层交互,替换现有的 `showDatePicker` + `showTimePicker` 调用 + +**Tech Stack:** Flutter, Cupertino widgets, intl package + +--- + +## Task 1: 创建 DateTimePickerBottomSheet 组件 + +**Files:** +- Create: `apps/lib/shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart` + +**Step 1: 创建文件结构和基础代码** + +```dart +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:intl/intl.dart'; + +class DateTimePickerBottomSheet extends StatefulWidget { + const DateTimePickerBottomSheet({ + super.key, + required this.initialDateTime, + this.minDateTime, + this.maxDateTime, + }); + + final DateTime initialDateTime; + final DateTime? minDateTime; + final DateTime? maxDateTime; + + @override + State createState() => _DateTimePickerBottomSheetState(); +} + +class _DateTimePickerBottomSheetState extends State { + late DateTime _selectedDateTime; + int _selectedTab = 0; // 0=日期, 1=时间 + + @override + void initState() { + super.initState(); + _selectedDateTime = widget.initialDateTime; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final locale = Localizations.localeOf(context); + + return Container( + height: 400, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + children: [ + // 顶部栏 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + Text( + l10n.autoSelectTime, + style: Theme.of(context).textTheme.titleMedium, + ), + TextButton( + onPressed: () => Navigator.pop(context, _selectedDateTime), + child: Text(l10n.confirm), + ), + ], + ), + ), + // SegmentedControl 切换日期/时间 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: CupertinoSlidingSegmentedControl( + groupValue: _selectedTab, + children: { + 0: Text(l10n.dateTab), + 1: Text(l10n.timeTab), + }, + onValueChanged: (value) => setState(() => _selectedTab = value ?? 0), + ), + ), + const SizedBox(height: 16), + // CupertinoDatePicker + Expanded( + child: CupertinoDatePicker( + mode: _selectedTab == 0 + ? CupertinoDatePickerMode.date + : CupertinoDatePickerMode.time, + initialDateTime: _selectedDateTime, + minimumDate: widget.minDateTime, + maximumDate: widget.maxDateTime, + onDateTimeChanged: (DateTime newDateTime) { + setState(() => _selectedDateTime = newDateTime); + }, + ), + ), + ], + ), + ); + } +} + +Future showDateTimePickerBottomSheet({ + required BuildContext context, + required DateTime initialDateTime, + DateTime? minDateTime, + DateTime? maxDateTime, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => DateTimePickerBottomSheet( + initialDateTime: initialDateTime, + minDateTime: minDateTime, + maxDateTime: maxDateTime, + ), + ); +} +``` + +**Step 2: 添加 l10n 键值** + +在 `apps/lib/l10n/app_zh.arb` 添加: +```json +"dateTab": "日期", +"timeTab": "时间", +"confirm": "确认", +"cancel": "取消" +``` + +在 `apps/lib/l10n/app_en.arb` 添加: +```json +"dateTab": "Date", +"timeTab": "Time", +"confirm": "Confirm", +"cancel": "Cancel" +``` + +运行 `flutter gen-l10n` 生成代码 + +**Step 3: Commit** + +```bash +git add apps/lib/shared/widgets/date_time_picker/ apps/lib/l10n/ +git commit -m "feat(divination): add DateTimePickerBottomSheet with iOS wheel style" +``` + +--- + +## Task 2: 修改 auto_divination_screen.dart 使用新选择器 + +**Files:** +- Modify: `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart:208-230` +- Modify: `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart:353` + +**Step 1: 添加 import** + +在文件顶部添加: +```dart +import 'package:shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart'; +``` + +**Step 2: 修改 _pickTime 方法** + +将: +```dart +Future _pickTime() async { + final date = await showDatePicker( + context: context, + initialDate: _selectedTime, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (date == null || !mounted) return; + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_selectedTime), + ); + if (time == null || !mounted) return; + setState(() { + _selectedTime = DateTime( + date.year, + date.month, + date.day, + time.hour, + time.minute, + ); + }); +} +``` + +替换为: +```dart +Future _pickTime() async { + final result = await showDateTimePickerBottomSheet( + context: context, + initialDateTime: _selectedTime, + minDateTime: DateTime(2000), + maxDateTime: DateTime(2100), + ); + if (result == null || !mounted) return; + setState(() { + _selectedTime = result; + }); +} +``` + +**Step 3: 修改日期显示格式** + +将: +```dart +DateFormat('yyyy年MM月dd日 HH:mm').format(selectedTime) +``` + +替换为: +```dart +DateFormat.yMd(Localizations.localeOf(context).toString()).add_Hm().format(selectedTime) +``` + +需要添加 import: +```dart +import 'package:intl/intl.dart'; +``` + +**Step 4: Commit** + +```bash +git add apps/lib/features/divination/presentation/screens/auto_divination_screen.dart +git commit -m "feat(divination): use DateTimePickerBottomSheet in auto_divination_screen" +``` + +--- + +## Task 3: 修改 manual_divination_screen.dart 使用新选择器 + +**Files:** +- Modify: `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart:142-168` +- Modify: `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart:271` + +**Step 1: 添加 import** + +```dart +import 'package:shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart'; +import 'package:intl/intl.dart'; +``` + +**Step 2: 修改 _pickTime 方法和日期显示格式** + +同 Task 2 的修改方式 + +**Step 3: Commit** + +```bash +git add apps/lib/features/divination/presentation/screens/manual_divination_screen.dart +git commit -m "feat(divination): use DateTimePickerBottomSheet in manual_divination_screen" +``` + +--- + +## Task 4: 修改 divination_result_screen.dart 的日期格式 + +**Files:** +- Modify: `apps/lib/features/divination/presentation/screens/divination_result_screen.dart:455-457` + +**Step 1: 添加 import** + +```dart +import 'package:intl/intl.dart'; +``` + +**Step 2: 修改日期格式** + +将: +```dart +DateFormat( + 'yyyy年MM月dd日 HH:mm', +).format(data.params.divinationTime), +``` + +替换为: +```dart +DateFormat.yMd(Localizations.localeOf(context).toString()).add_Hm().format(data.params.divinationTime), +``` + +**Step 3: Commit** + +```bash +git add apps/lib/features/divination/presentation/screens/divination_result_screen.dart +git commit -m "refactor(divination): use locale-aware date format in divination_result_screen" +``` + +--- + +## Task 5: 运行验证 + +**Step 1: 生成 l10n** + +```bash +cd apps && flutter gen-l10n +``` + +**Step 2: 运行静态分析** + +```bash +cd apps && flutter analyze +``` + +预期: 无错误 + +**Step 3: 运行相关测试** + +```bash +cd apps && flutter test test/features/divination/ +``` + +--- + +**Plan complete.** Two execution options: + +**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration + +**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints + +**Which approach?** diff --git a/docs/protocols/common/http-error-codes.md b/docs/protocols/common/http-error-codes.md index 690b925..201a258 100644 --- a/docs/protocols/common/http-error-codes.md +++ b/docs/protocols/common/http-error-codes.md @@ -24,6 +24,7 @@ This document is the source of truth for backend RFC7807 `code` values consumed | code | status | meaning | frontend handling | |---|---:|---|---| | `AGENT_SESSION_RUN_LIMIT_EXCEEDED` | 409 | Session already reached max run count (start + 3 follow-ups) | Show run-limit message and require starting a new session | +| `AGENT_DIVINATION_PAYLOAD_REQUIRED` | 422 | Missing required `forwardedProps.divinationPayload` in run request | Prompt user to restart casting flow and resubmit | Compatibility strategy: diff --git a/docs/protocols/divination/divination-run-protocol.md b/docs/protocols/divination/divination-run-protocol.md new file mode 100644 index 0000000..cbca5ea --- /dev/null +++ b/docs/protocols/divination/divination-run-protocol.md @@ -0,0 +1,172 @@ +# Divination Run Protocol (Frontend <-> Backend) + +This document defines the structured contract for divination run input, backend hexagram derivation, and run event output. + +Protocol verification status: + +- Backend route source: `backend/src/v1/agent/router.py` +- Backend derivation source: `backend/src/core/divination/derivation.py` +- Runtime payload schema source: `backend/src/schemas/domain/divination.py` + +## Compatibility strategy + +- Current strategy: additive evolution only. +- Existing required fields cannot be removed or renamed without migration notes. +- Canonical divination terminology values must remain Chinese. + +## Route overview + +- Submit run: `POST /api/v1/agent/runs` +- Stream events: `GET /api/v1/agent/runs/{threadId}/events?runId=...` + +## Run request contract + +`RunAgentInput` uses AG-UI shape. This protocol constrains two sections: + +1) `messages[0].content` (question text) +2) `forwardedProps.divinationPayload` (structured divination input) + +### Required request shape + +```json +{ + "threadId": "uuid", + "runId": "run_20260403_xxx", + "state": {}, + "messages": [ + { + "id": "msg_run_20260403_xxx_user_0", + "role": "user", + "content": "我最近换工作是否合适?" + } + ], + "tools": [], + "context": [], + "forwardedProps": { + "runtime_mode": "chat", + "client_time": { + "device_timezone": "Asia/Shanghai", + "client_now_iso": "2026-04-03T20:30:00+08:00", + "client_epoch_ms": 1775219400000 + }, + "divinationPayload": { + "divinationMethod": "手动起卦", + "questionType": "事业", + "question": "我最近换工作是否合适?", + "divinationTimeIso": "2026-04-03T20:30:00+08:00", + "yaoLines": [ + "少阳", + "少阴", + "老阳", + "少阴", + "少阳", + "老阴" + ] + } + } +} +``` + +### AG-UI required base fields + +- `state`: required object, frontend sends `{}` by default. +- `messages[0].id`: required string id for user message. +- `tools`: required array, frontend sends empty array when no tools requested. +- `context`: required array, frontend sends empty array when no extra context. + +### `divinationPayload` strict rules + +- `divinationMethod`: enum, allowed values `手动起卦 | 自动起卦` +- `questionType`: non-empty string, recommended Chinese category labels +- `question`: non-empty string +- `divinationTimeIso`: RFC3339 datetime with timezone offset +- `yaoLines`: exactly 6 items, order is `初爻 -> 上爻` +- `yaoLines` item enum: `少阳 | 少阴 | 老阳 | 老阴` +- Additional fields are forbidden. + +## Event output contract + +During run streaming, backend emits standard AG-UI lifecycle events and two divination-relevant payload events: + +### 1) `DIVINATION_DERIVED` + +- Emitted once after backend derives hexagram data. +- Payload field: `divination` (strict object). + +`divination` object: + +```json +{ + "question": "我最近换工作是否合适?", + "questionType": "事业", + "divinationMethod": "手动起卦", + "divinationTime": "2026年04月03日 20:30", + "binaryCode": "101001", + "changedBinaryCode": "100001", + "guaName": "山火贲", + "upperName": "艮", + "lowerName": "离", + "targetGuaName": "山雷颐", + "worldPosition": 1, + "responsePosition": 4, + "hasChangingYao": true, + "ganzhi": { + "yearGanZhi": "丙午", + "monthGanZhi": "壬辰", + "dayGanZhi": "辛亥", + "timeGanZhi": "乙巳", + "yearKongWang": "子丑", + "monthKongWang": "午未", + "dayKongWang": "寅卯", + "timeKongWang": "戌亥", + "yueJian": "辰土", + "riChen": "亥水", + "yuePo": "戌土", + "riChong": "巳火" + }, + "wuXingStatuses": { + "木": "囚", + "火": "休", + "土": "旺", + "金": "相", + "水": "死" + }, + "yaoInfoList": [ + { + "position": 1, + "spiritName": "虎", + "relationName": "官鬼", + "tiganName": "卯", + "elementName": "木", + "isYang": true, + "isChanging": false, + "specialMark": "世" + } + ], + "targetYaoInfoList": [], + "fushenPositions": [2], + "fushenInfoList": [ + { + "position": 2, + "relationName": "父母", + "tiganName": "午", + "elementName": "火" + } + ] +} +``` + +### 2) `TEXT_MESSAGE_END` + +- Standard final answer event. +- Existing fields remain canonical: `sign_level`, `summary`, `conclusion`, `focus_points`, `advice`, `keywords`, `answer`. + +Frontend should combine: + +- structural divination data from `DIVINATION_DERIVED` +- interpretation text from `TEXT_MESSAGE_END` + +## Error contract linkage + +- All errors use RFC7807 with extension `code` and optional `params`. +- Error code registry source: `docs/protocols/common/http-error-codes.md`. diff --git a/docs/protocols/points/points-balance-protocol.md b/docs/protocols/points/points-balance-protocol.md new file mode 100644 index 0000000..4d07f5b --- /dev/null +++ b/docs/protocols/points/points-balance-protocol.md @@ -0,0 +1,48 @@ +# Points Balance Protocol (Frontend <-> Backend) + +This document defines the read-only points balance contract for authenticated users. + +Protocol verification status: + +- Backend route source: `backend/src/v1/points/router.py` +- Backend service source: `backend/src/v1/points/service.py` +- Response schema source: `backend/src/v1/points/schemas.py` + +## Compatibility strategy + +- Additive evolution only. +- Existing response fields are stable and must remain backward-compatible. + +## Route + +- `GET /api/v1/points/balance` + +## Authorization + +- Requires authenticated session. +- User identity is derived from verified backend auth context. + +## Response (200) + +```json +{ + "balance": 120, + "frozenBalance": 20, + "availableBalance": 100, + "runCost": 20, + "canRun": true +} +``` + +Field rules: + +- `balance`: integer `>= 0` +- `frozenBalance`: integer `>= 0` +- `availableBalance`: integer `>= 0`, computed as `max(balance - frozenBalance, 0)` +- `runCost`: integer `> 0`, current value `20` +- `canRun`: boolean, equivalent to `availableBalance >= runCost` + +## Error contract linkage + +- RFC7807 + extension `code`, optional `params`. +- Shared registry: `docs/protocols/common/http-error-codes.md`. diff --git a/pyproject.toml b/pyproject.toml index b44efbb..6502dc7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "dashscope>=1.25.15", "email-validator==2.3.0", "fastapi==0.135.1", + "lunar-python>=1.4.8", "pydantic==2.12.5", "pydantic-settings==2.13.1", "pyjwt==2.11.0", @@ -49,3 +50,6 @@ dev = [ "basedpyright==1.38.2", "pre-commit==4.5.1", ] + +[tool.basedpyright] +reportImplicitRelativeImport = "warning"