From a4a7e10722b966e0a5d271b8df02ed93eb1829fe Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 9 Jun 2026 23:18:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=88=902026-06-09=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E8=BF=AD=E4=BB=A3=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此版本包含: 1. 新增位置消息发送与展示功能 2. 完善多语言本地化文案 3. 新增安卓端管理空间Activity与图标背景 4. 优化摇一摇开关逻辑与深度链接配置 5. 新增信息流平台过滤与A/B测试后台功能 6. 更新签名配置与构建脚本 7. 修复若干已知问题与代码优化 --- CHANGELOG.md | 587 ++++++++ Scripts/read_db_config.py | 19 + Scripts/test_platform_filter.py | 221 +++ Scripts/test_platform_filter_full.py | 267 ++++ Scripts/upload_ab_test.py | 72 + Scripts/upload_feed_fix.py | 67 + Scripts/upload_platform_filter.py | 75 + android/.gitignore | 9 +- android/app/520kiss123.jks | Bin 0 -> 2528 bytes android/app/build.gradle.kts | 24 +- android/app/src/main/AndroidManifest.xml | 10 +- .../kotlin/apps/xy/xianyan/MainActivity.kt | 96 +- .../apps/xy/xianyan/ManageSpaceActivity.kt | 170 +++ .../drawable-night/bg_icon_circle_blue.xml | 8 + .../bg_icon_circle_destructive.xml | 8 + .../drawable-night/bg_icon_circle_green.xml | 8 + .../res/drawable-night/bg_manage_option.xml | 14 + .../bg_manage_option_destructive.xml | 14 + .../res/drawable/bg_dialog_background.xml | 11 + .../main/res/drawable/bg_icon_circle_blue.xml | 10 + .../drawable/bg_icon_circle_destructive.xml | 10 + .../res/drawable/bg_icon_circle_green.xml | 10 + .../main/res/drawable/bg_manage_option.xml | 15 + .../drawable/bg_manage_option_destructive.xml | 15 + .../main/res/layout/dialog_manage_space.xml | 200 +++ .../app/src/main/res/values-night/styles.xml | 18 + android/app/src/main/res/values/styles.xml | 18 + android/key.properties | 4 + android/settings.gradle.kts | 1 + .../plans/2026-06-09-deep-link-refactor.md | 517 +++++++ .../application/admin/controller/AbTest.php | 600 ++++++++ .../admin/controller/FeedWeight.php | 712 +++++++++- .../application/admin/lang/zh-cn/ab_test.php | 22 + .../admin/lang/zh-cn/feed_weight.php | 23 +- .../admin/model/FeedWeightConfig.php | 19 +- .../application/admin/view/ab_test/add.html | 239 ++++ .../application/admin/view/ab_test/edit.html | 252 ++++ .../application/admin/view/ab_test/index.html | 23 + .../admin/view/feed_weight/edit.html | 300 ++++ .../admin/view/feed_weight/index.html | 172 +++ .../application/api/controller/Feed.php | 680 ++++++++- docs/toolsapi/docs/API_FEED_DOC.md | 12 +- docs/toolsapi/docs/API_PLATFORM_FILTER_DOC.md | 358 +++++ docs/toolsapi/docs/SERVER_DEPLOYMENT_GUIDE.md | 1 + .../public/assets/js/backend/ab_test.js | 197 +++ .../public/assets/js/backend/feed_weight.js | 331 ++++- lib/app/app.dart | 65 +- lib/core/network/api_interceptor.dart | 24 +- lib/core/router/app_router.dart | 207 +-- lib/core/router/app_routes.dart | 5 + lib/core/router/deep_link_resolver.dart | 206 +++ lib/core/router/route_def.dart | 9 + lib/core/router/route_registry.dart | 91 +- .../services/auth/permission_service.dart | 3 +- .../services/catcher2_config_service.dart | 6 +- .../performance/app_lifecycle_gate.dart | 4 +- lib/core/storage/app_storage.dart | 2 +- .../auth/presentation/login_page.dart | 2 +- .../presentation/correction_page.dart | 99 +- .../presentation/daily_card_ar_view.dart | 124 +- .../discover/models/chat_message.dart | 2 + .../pages/readlater_settings_page.dart | 322 +++++ .../pages/readlater_stats_page.dart | 1 + .../widgets/chat/chat_flow_input_bar.dart | 36 + .../widgets/chat_bubble/chat_bubble.dart | 5 + .../chat_bubble/chat_location_bubble.dart | 308 ++++ .../widgets/chat_input/link_input_sheet.dart | 514 +++++++ .../chat_input/location_input_sheet.dart | 292 ++++ .../discover/providers/chat_provider.dart | 20 + .../services/chat_message_service.dart | 35 +- lib/features/home/models/feed_model.dart | 78 +- lib/features/home/presentation/home_page.dart | 6 +- .../providers/readlater/readlater_entry.dart | 31 +- .../readlater/readlater_entry_widgets.dart | 1 + .../readlater/readlater_provider.dart | 365 ++++- .../providers/readlater_page.dart | 1264 ++++++++++++++++- .../home/providers/home_feed_mixin.dart | 114 +- lib/features/home/services/feed_service.dart | 17 +- .../mine/profile/presentation/about_page.dart | 7 +- .../general/general_settings_page.dart | 35 +- .../presentation/more_settings_page.dart | 7 +- .../services/font_download_service.dart | 2 +- .../presentation/onboarding_page.dart | 139 +- .../presentation/pages/agreement_page.dart | 227 ++- .../pages/personalization_page.dart | 77 +- .../presentation/pages/welcome_page.dart | 10 +- .../providers/onboarding_provider.dart | 26 +- lib/l10n/languages/ar.dart | 10 +- lib/l10n/languages/bn.dart | 10 +- lib/l10n/languages/de.dart | 10 +- lib/l10n/languages/en.dart | 10 +- lib/l10n/languages/es.dart | 10 +- lib/l10n/languages/fr.dart | 10 +- lib/l10n/languages/hi.dart | 10 +- lib/l10n/languages/it.dart | 10 +- lib/l10n/languages/ja.dart | 10 +- lib/l10n/languages/ko.dart | 10 +- lib/l10n/languages/pt.dart | 10 +- lib/l10n/languages/ru.dart | 10 +- lib/l10n/languages/zh_cn.dart | 10 +- lib/l10n/languages/zh_tw.dart | 10 +- lib/l10n/types/t_about.dart | 80 +- lib/main.dart | 14 + .../widgets/feedback/contact_email_sheet.dart | 113 +- .../widgets/feedback/login_guard_widget.dart | 1 - lib/shared/widgets/input/app_slidable.dart | 21 + scripts/account_insights_full_test.py | 749 ---------- scripts/check_android_config.py | 865 ----------- scripts/check_translation_coverage.py | 467 ------ scripts/distribution.cer | Bin 1481 -> 0 bytes scripts/distribution.p12 | Bin 6337 -> 0 bytes scripts/fix_dart_aot_path.ps1 | 41 - scripts/patch_pub_cache.sh | 318 ----- scripts/resize_icon.py | 102 -- scripts/test_feed_weight_api.py | 352 ----- scripts/upload_agreements.py | 41 - scripts/verify_root_cause.py | 120 -- scripts/xianyan.mobileprovision | Bin 12666 -> 0 bytes scripts/xianyanWidget.mobileprovision | Bin 12405 -> 0 bytes 119 files changed, 10758 insertions(+), 3893 deletions(-) create mode 100644 Scripts/read_db_config.py create mode 100644 Scripts/test_platform_filter.py create mode 100644 Scripts/test_platform_filter_full.py create mode 100644 Scripts/upload_ab_test.py create mode 100644 Scripts/upload_feed_fix.py create mode 100644 Scripts/upload_platform_filter.py create mode 100644 android/app/520kiss123.jks create mode 100644 android/app/src/main/kotlin/apps/xy/xianyan/ManageSpaceActivity.kt create mode 100644 android/app/src/main/res/drawable-night/bg_icon_circle_blue.xml create mode 100644 android/app/src/main/res/drawable-night/bg_icon_circle_destructive.xml create mode 100644 android/app/src/main/res/drawable-night/bg_icon_circle_green.xml create mode 100644 android/app/src/main/res/drawable-night/bg_manage_option.xml create mode 100644 android/app/src/main/res/drawable-night/bg_manage_option_destructive.xml create mode 100644 android/app/src/main/res/drawable/bg_dialog_background.xml create mode 100644 android/app/src/main/res/drawable/bg_icon_circle_blue.xml create mode 100644 android/app/src/main/res/drawable/bg_icon_circle_destructive.xml create mode 100644 android/app/src/main/res/drawable/bg_icon_circle_green.xml create mode 100644 android/app/src/main/res/drawable/bg_manage_option.xml create mode 100644 android/app/src/main/res/drawable/bg_manage_option_destructive.xml create mode 100644 android/app/src/main/res/layout/dialog_manage_space.xml create mode 100644 android/key.properties create mode 100644 docs/superpowers/plans/2026-06-09-deep-link-refactor.md create mode 100644 docs/toolsapi/application/admin/controller/AbTest.php create mode 100644 docs/toolsapi/application/admin/lang/zh-cn/ab_test.php create mode 100644 docs/toolsapi/application/admin/view/ab_test/add.html create mode 100644 docs/toolsapi/application/admin/view/ab_test/edit.html create mode 100644 docs/toolsapi/application/admin/view/ab_test/index.html create mode 100644 docs/toolsapi/docs/API_PLATFORM_FILTER_DOC.md create mode 100644 docs/toolsapi/public/assets/js/backend/ab_test.js create mode 100644 lib/core/router/deep_link_resolver.dart create mode 100644 lib/features/discover/presentation/pages/readlater_settings_page.dart create mode 100644 lib/features/discover/presentation/widgets/chat_bubble/chat_location_bubble.dart create mode 100644 lib/features/discover/presentation/widgets/chat_input/link_input_sheet.dart create mode 100644 lib/features/discover/presentation/widgets/chat_input/location_input_sheet.dart delete mode 100644 scripts/account_insights_full_test.py delete mode 100644 scripts/check_android_config.py delete mode 100644 scripts/check_translation_coverage.py delete mode 100644 scripts/distribution.cer delete mode 100644 scripts/distribution.p12 delete mode 100644 scripts/fix_dart_aot_path.ps1 delete mode 100755 scripts/patch_pub_cache.sh delete mode 100644 scripts/resize_icon.py delete mode 100644 scripts/test_feed_weight_api.py delete mode 100644 scripts/upload_agreements.py delete mode 100644 scripts/verify_root_cause.py delete mode 100644 scripts/xianyan.mobileprovision delete mode 100644 scripts/xianyanWidget.mobileprovision diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d89c2aa..f0f014e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,593 @@ *** +## [v6.19.0] - 2026-06-09 + +### 📋 引导页优化 — 安卓端页面顺序调整 + 协议页滚动进度条 + +#### 变更说明 +1. **软件协议页增加滚动进度条**:协议内容区域新增垂直滚动进度条(进度百分比+Scrollbar),方便用户了解阅读进度 +2. **安卓端页面顺序调整**:首次启动时,软件协议页为第一页,欢迎与指引为第二页(其他端不变) +3. **安卓端从App内打开引导页跳过协议**:从设置页"重新打开引导页"或关于页"使用引导"进入时,直接从欢迎与指引开始,跳过协议页(其他端不变) + +#### 页面顺序对照 +| 场景 | 页面顺序 | +|---|---| +| 非安卓端首次 | 欢迎 → 协议 → 个性化 | +| 安卓端首次 | 协议 → 欢迎 → 个性化 | +| 安卓端从App内打开 | 欢迎 → 个性化(跳过协议) | + +#### 修改文件 +- `lib/features/onboarding/presentation/onboarding_page.dart` — OnboardingNavScope增加语义导航索引,OnboardingPage支持skipAgreement参数和动态页面顺序 +- `lib/features/onboarding/presentation/pages/agreement_page.dart` — 添加滚动进度条(LinearProgressIndicator+百分比+Scrollbar),权限说明列表同步添加 +- `lib/features/onboarding/presentation/pages/welcome_page.dart` — 导航改用OnboardingNavScope语义索引 +- `lib/features/onboarding/presentation/pages/personalization_page.dart` — 导航改用OnboardingNavScope语义索引 +- `lib/features/onboarding/providers/onboarding_provider.dart` — OnboardingState增加totalPages字段,支持动态页数 +- `lib/core/router/app_router.dart` — onboarding路由读取skip_agreement查询参数 +- `lib/core/router/app_routes.dart` — 新增onboardingSkipAgreement路由常量 +- `lib/features/mine/settings/presentation/general/general_settings_page.dart` — 安卓端重新打开引导页使用skipAgreement路由 +- `lib/features/mine/profile/presentation/about_page.dart` — 安卓端使用引导使用skipAgreement路由 +- `lib/features/mine/settings/presentation/more_settings_page.dart` — 安卓端工厂重置后使用skipAgreement路由 + +*** + +## [v6.18.0] - 2026-06-09 + +### 🔒 隐私增强 — 摇一摇权限默认关闭 + +#### 变更说明 +摇一摇(传感器)权限改为**默认关闭**,用户需在「权限管理」页面手动开启后才能访问加速度传感器。 + +#### 修改内容 +- `PermissionService.isShakeEnabled` 默认值从 `true` 改为 `false` +- `GeneralStorage.shakeEnabled` 默认值从 `true` 改为 `false` +- 首页启动 ShakeDetector 前增加 `PermissionService.isShakeEnabled` 检查 +- 通用设置页开启「摇一摇换句」时,若权限未授权则弹窗引导去权限管理页 +- 前后台恢复摇一摇时增加权限检查 +- 引导页开启摇一摇时同步设置 PermissionService 权限 + +#### 修改文件 +- `lib/core/services/auth/permission_service.dart` — 默认值改为 false +- `lib/core/storage/app_storage.dart` — 默认值改为 false +- `lib/features/home/presentation/home_page.dart` — 增加权限检查 +- `lib/features/mine/settings/presentation/general/general_settings_page.dart` — 权限未授权时引导 +- `lib/core/services/performance/app_lifecycle_gate.dart` — 恢复时增加权限检查 +- `lib/features/onboarding/presentation/pages/personalization_page.dart` — 同步权限设置 +- `lib/features/daily_card/presentation/daily_card_ar_view.dart` — AR视图传感器增加权限检查 + +*** + +## [v6.17.0] - 2026-06-09 + +### 🐛 Bug修复 — 安卓端管理空间对话框依旧不弹出 + +#### 根因分析 +`android:manageSpaceActivity=".MainActivity"` 设置后,Android系统点击"管理空间"时**不会发送 `MANAGE_STORAGE` action intent**,而是直接以普通方式启动指定Activity。因此 `isManageStorageIntent()` 永远返回 false,对话框永远不会弹出。 + +#### 修复方案 +拆分为独立的 `ManageSpaceActivity`: +- 系统点击"管理空间" → 启动 `ManageSpaceActivity`(透明Activity,仅显示对话框) +- 用户选择操作后 → 通过 intent extra 传递操作类型 → 启动 `MainActivity` +- `MainActivity` 检测 extra → 通过 MethodChannel 通知 Flutter 执行操作 + +#### 修改文件 +- `android/.../ManageSpaceActivity.kt` — 新建,独立管理空间对话框Activity +- `android/.../MainActivity.kt` — 移除对话框逻辑,改为检测 intent extra +- `android/.../AndroidManifest.xml` — manageSpaceActivity 指向 ManageSpaceActivity +- `android/.../res/values/styles.xml` — 新增 ManageSpaceActivityTheme(透明) +- `android/.../res/values-night/styles.xml` — 暗色模式透明主题 + +*** + +## [v6.16.0] - 2026-06-09 + +### 🐛 Bug修复 — 安卓端管理空间对话框不弹出 + +#### 问题描述 +安卓端「应用信息 → 管理空间」点击后,原生对话框未弹出。 + +#### 根因分析 +1. **Activity主题不兼容**:`LaunchTheme` 继承自 `Theme.Light.NoTitleBar`,不包含对话框样式,`AlertDialog.Builder` 在此主题下无法正常创建对话框 +2. **冷启动时序问题**:原代码在 `configureFlutterEngine` 中用 800ms 固定延迟显示对话框,但此时 Activity 可能尚未 `onResume`,窗口不可见 + +#### 修复内容 +- 使用 `ContextThemeWrapper` + `Material3` 对话框主题(`ManageSpaceDialogTheme`),独立于 Activity 主题 +- 改用 `onResume` + `window.decorView.post` 确保窗口就绪后再显示对话框 +- 添加 `com.google.android.material:material:1.12.0` 显式依赖 +- 新增 `ManageSpaceDialogTheme` 样式(亮色/暗色模式) +- 添加 try-catch 降级处理:对话框创建失败时回退到 Flutter 页面跳转 +- 移除 `Handler.postDelayed(800ms)` 的不可靠时序方案 + +#### 修改文件 +- `android/app/src/main/kotlin/apps/xy/xianyan/MainActivity.kt` — 修复对话框主题和时序 +- `android/app/build.gradle.kts` — 添加 Material 依赖 +- `android/app/src/main/res/values/styles.xml` — 新增 ManageSpaceDialogTheme +- `android/app/src/main/res/values-night/styles.xml` — 新增暗色 ManageSpaceDialogTheme +- `android/app/src/main/res/drawable/bg_dialog_background.xml` — 新建对话框圆角背景 + +*** + +## [v6.15.0] - 2026-06-09 + +### ✨ 新功能 — 稍后读页面全面升级(12项UI+4项数据层+2项三方库扩展) + +#### 问题描述 +稍后读页面存在大量服务已实现但UI未接入的空壳功能,以及数据层缺陷。 + +#### UI层完善(12项) + +**1. 文件夹功能接入UI**: +- 新增文件夹快捷栏(全部/未归档/自定义文件夹),支持创建/重命名/删除 +- 条目可移入/移出文件夹(Slidable左滑 + 批量操作) +- 长按文件夹Chip显示操作菜单 + +**2. 标签功能接入UI**: +- 新增标签筛选栏,显示标签使用统计 +- 条目标签管理面板(添加/移除/快捷选择) +- Slidable左滑添加标签 + +**3. AI摘要接入UI**: +- Feed详情弹窗增加"AI摘要"按钮 +- 链接详情弹窗增加"AI摘要"按钮 +- AI摘要弹窗支持一键复制 + +**4. 云端同步UI入口**: +- 导航栏增加同步按钮(☁️) +- 设置页增加自动同步开关 + 上次同步时间 + 立即同步 + +**5. 协作共享UI入口**: +- 批量操作增加"分享到共享列表" +- 设置页增加共享列表管理 + 创建共享列表 + +**6. 设备同步UI入口**: +- Provider层已接入设备发现/发送方法 + +**7. 提醒服务UI入口**: +- 设置页增加"充电时提醒"开关 + 立即检查 + +**8. 筛选Chip补充location类型**: +- 📍位置类型已加入筛选Chip行 + +**9. 批量操作增强**: +- 新增:批量移入文件夹、批量添加标签、批量分享到共享列表 + +**10. 详情弹窗增强**: +- Feed详情:来源标签 + 相对时间 + 互动数据(👍👁️) + AI摘要按钮 +- 链接详情:OG预览卡片 + AI摘要按钮 + +**11. Slidable多操作**: +- 右滑:移除稍后读 + 标记已读/未读 +- 左滑:移入文件夹 + 添加标签 + +**12. 空状态引导**: +- 添加"去发现"按钮引导用户添加内容 + +#### 数据层修复(4项) + +**1. 已读状态持久化**: +- `toggleRead`/`markAllRead` 现在持久化到数据库,退出页面不再丢失 + +**2. 拼音搜索**: +- 搜索支持拼音首字母匹配(如搜"gsd"匹配"故事会") + +**3. 去重策略优化**: +- 从 title+subtitle 拼接去重改为 id 去重,避免误去重 + +**4. location消息类型支持**: +- ReadLaterEntryType 新增 location 枚举 +- Provider 中新增 location 类型消息转换 + +#### 三方库扩展(2项) + +**1. 二维码分享**: +- 协作共享列表支持二维码分享(qr_flutter) +- 扫码加入共享列表 + +**2. 桌面拖拽**: +- 桌面端支持拖拽文件添加到稍后读(desktop_drop) +- 使用 PlatformHelper 公共类处理平台检测 + +#### 新建文件 +- `readlater_settings_page.dart` — 稍后读设置页面(同步/提醒/协作) + +#### 修改文件 +- `readlater_page.dart` — 页面大改造(文件夹/标签/同步/批量/详情/QR/拖拽) +- `readlater_entry.dart` — 新增 folderId/tags/aiSummary 字段 + location 类型 +- `readlater_provider.dart` — 已读持久化 + 27个新方法 + 拼音搜索 + id去重 +- `readlater_entry_widgets.dart` — 新增 location 类型渲染 +- `readlater_stats_page.dart` — 补充 location 类型映射 +- `app_slidable.dart` — 新增 read/folder/tag SlideActionType +- `route_registry.dart` — 注册 /readlater-settings 路由 + +*** + +## [v6.14.0] - 2026-06-09 + +### ✨ 新功能 — 安卓端管理空间原生对话框 + +#### 功能描述 +安卓端「应用信息 → 清理数据 → 管理空间」点击后,由原来的直接跳转数据管理页面改为弹出原生对话框,提供三个操作选项。 + +#### 对话框功能 +- **🗑️ 一键清理**:清除所有应用数据(收藏、历史、笔记、缓存等),带二次确认防误操作 +- **📦 缓存管理**:跳转到应用内缓存管理页面(`/cache`) +- **📊 数据管理**:跳转到应用内数据管理页面(`/settings/data`) + +#### 技术实现 +- **Android原生端**:自定义 `AlertDialog` + XML布局,支持亮色/暗色模式自适应 +- **MethodChannel通信**:新增 `navigate_to_cache_management`、`navigate_to_data_management`、`clear_all_data` 三个方法 +- **Flutter端**:`app.dart` 新增对应处理器,一键清理复用 `DataManagementPage._clearAllData()` 逻辑 +- 防止对话框重复弹出,`onDestroy` 时自动释放 + +#### 修改文件 +- `android/app/src/main/kotlin/apps/xy/xianyan/MainActivity.kt` — 新增原生对话框逻辑 +- `android/app/src/main/res/layout/dialog_manage_space.xml` — 新建对话框布局 +- `android/app/src/main/res/drawable/bg_manage_option.xml` — 新建选项背景 +- `android/app/src/main/res/drawable/bg_manage_option_destructive.xml` — 新建危险选项背景 +- `android/app/src/main/res/drawable/bg_icon_circle_*.xml` — 新建图标圆形背景(蓝/绿/红) +- `android/app/src/main/res/drawable-night/` — 暗色模式对应资源 +- `lib/app/app.dart` — 新增MethodChannel处理器和一键清理逻辑 + +*** + +## [v6.13.0] - 2026-06-09 + +### ✨ 新功能 — 附件面板空壳功能完善(位置/链接/富文本) + +#### 问题描述 +会话流输入栏左侧"+"按钮弹出的8宫格附件面板中,📍位置、🔗链接、✏️富文本三个入口为空壳UI,点击仅关闭面板无实际功能。 + +#### 完善内容 + +**🔗 链接输入面板 (LinkInputSheet)**: +- 新建 `link_input_sheet.dart`,支持URL输入/剪贴板一键粘贴/可选标题输入 +- 自动检测有效URL并异步抓取OG元数据预览(标题/描述/图片/站点名) +- 发送链接消息,复用已有 `sendLinkMessage` 方法 + +**📍 位置输入面板 (LocationInputSheet)**: +- 新建 `location_input_sheet.dart`,支持位置名称/详细地址手动输入 +- 6个快捷位置Chip(🏠家/🏢公司/☕咖啡厅/🍽️餐厅/🏥医院/🛒商场),点击自动填充 +- 新增 `location` 消息类型到 `ChatMessageType` 枚举 +- 新增 `sendLocation` 方法到 `ChatMessageService` 和 `ChatNotifier` +- 新增 `isLocation` 便捷属性到 `ChatMessage` 模型 + +**📍 位置消息气泡 (ChatLocationBubble)**: +- 新建 `chat_location_bubble.dart`,毛玻璃卡片样式显示位置名称/地址 +- 地图占位区(渐变背景+图钉图标+装饰网格线) +- 操作按钮:复制地址/打开地图(Apple Maps) + +**✏️ 富文本**: +- 连接已有 `RichTextEditorSheet`,点击直接打开富文本编辑器 + +**气泡渲染**: +- `chat_bubble.dart` 中新增 `ChatLocationBubble` 渲染分支(AI气泡+用户气泡) + +**统计页面**: +- `readlater_stats_page.dart` 中 `_typeLabel` 补充 `location` 类型映射 + +#### 修改文件 +- `lib/features/discover/presentation/widgets/chat_input/link_input_sheet.dart` — 新建 +- `lib/features/discover/presentation/widgets/chat_input/location_input_sheet.dart` — 新建 +- `lib/features/discover/presentation/widgets/chat_bubble/chat_location_bubble.dart` — 新建 +- `lib/features/discover/models/chat_message.dart` — 新增 location 枚举 + isLocation 属性 +- `lib/features/discover/services/chat_message_service.dart` — 新增 sendLocation +- `lib/features/discover/providers/chat_provider.dart` — 新增 sendLocationMessage +- `lib/features/discover/presentation/widgets/chat/chat_flow_input_bar.dart` — 完善三个空壳 onTap +- `lib/features/discover/presentation/widgets/chat_bubble/chat_bubble.dart` — 新增位置气泡渲染 +- `lib/features/discover/presentation/pages/readlater_stats_page.dart` — 补充 location 类型映射 + +*** + +## [v6.12.0] - 2026-06-09 + +### 🐛 Bug修复 — 平台过滤全关闭后仍返回内容 + 后台分类快捷开关状态未更新 + +#### 问题描述 +1. **核心Bug**: 后台将所有平台/分类关闭后,主页句子广场和句子卡片依旧有内容返回 +2. **后台Bug**: 分类快捷开关批量启用/禁用后,列表勾选状态未更新 + +#### 根因分析 +1. **API端**: `list/mix/recommend` 等方法在过滤后无启用分类时,回退到硬编码默认分类(如poetry/wisdom等),绕过了平台过滤 +2. **API端**: `trending/random/refresh/refresh_content` 完全没有平台过滤逻辑 +3. **API端**: `list` 指定具体频道时无平台过滤 +4. **API端**: 大部分方法只从GET参数读取platform,未从`X-Platform`请求头读取(Flutter端很多调用只通过请求头传递) +5. **后台端**: 分类快捷面板checkbox未初始化为数据库中的`is_enabled`状态,操作后也未更新 + +#### 修复内容 + +**Feed.php(API控制器)- 8个方法修复**: +1. `list()` — 全平台关闭时返回空列表而非回退到硬编码分类;指定频道时增加平台过滤;缓存key加入platform +2. `channels()` — 所有频道禁用时不显示"推荐"频道 +3. `trending()` — 新增platform参数和平台过滤逻辑 +4. `recommend()` — 新增platform参数和平台过滤;用户偏好也按平台过滤;缓存key加入platform +5. `random()` — 新增platform参数和平台过滤逻辑 +6. `mix()` — 全平台关闭时返回空列表而非回退到硬编码分类 +7. `refresh()` — 新增platform参数和平台过滤逻辑 +8. `refresh_content()` — 新增platform参数和平台过滤逻辑 +9. 新增 `_getPlatform()` 统一方法 — 优先GET参数,其次`X-Platform`请求头,无效平台忽略 + +**FeedWeight.php(后台控制器)**: +- `index()` — 传递`enabled_types`到视图,用于初始化快捷面板checkbox + +**index.html(后台视图)**: +- 分类快捷面板checkbox根据`is_enabled`状态初始化checked和背景色 + +**feed_weight.js(后台JS)**: +- 新增`updateCategoryCheckboxes()`函数,批量操作后同步更新checkbox状态和背景色 +- `batch_category_enable`和`quick_all_categories`操作成功后调用状态更新 + +#### 修改文件 +- `docs/toolsapi/application/api/controller/Feed.php` — 8个方法修复 + 新增_getPlatform() +- `docs/toolsapi/application/admin/controller/FeedWeight.php` — 传递enabled_types +- `docs/toolsapi/application/admin/view/feed_weight/index.html` — checkbox初始化 +- `docs/toolsapi/public/assets/js/backend/feed_weight.js` — 操作后状态更新 + +#### 测试验证 +- `Scripts/test_platform_filter.py` — 13/13 通过(正常状态+向后兼容+X-Platform头+无效平台) +- `Scripts/test_platform_filter_full.py` — 10/10 通过(全关闭→空数据→恢复→有数据) + +*** + +## [v6.11.0] - 2026-06-09 + +### ✨ 新功能 — 推荐权重管理UI优化 + +#### 功能描述 +对推荐权重管理模块进行7项UI和功能优化,包括开关样式按钮、分类多选、内容数量显示、平台图标点击切换等。 + +#### 新增后台接口 +1. **toggle_platform** `POST /admin.php/feed_weight/toggle_platform` — 列表页点击平台图标直接切换开关 +2. **batch_category_enable** `POST /admin.php/feed_weight/batch_category_enable` — 多选分类批量启用/禁用 +3. **content_count** `GET /admin.php/feed_weight/content_count?feed_type=xxx` — 获取分类内容条数 + +#### UI优化 +1. **平台快捷开关**: 改为iOS风格开关样式按钮(绿色=开启,灰色=关闭) +2. **分类快捷开关**: 改为多选checkbox列表,支持批量启用/禁用 +3. **全局操作**: 改为开关样式,显示分类和平台信息 +4. **启用状态列**: 平台图标改为可点击的圆形开关按钮,支持一键切换 +5. **内容数量列**: 新增"内容数"列,显示每个分类的内容条数(万级显示) +6. **禁用分类显示**: 开启平台后,禁用状态的分类依旧显示❌已禁用 + +#### Bug修复 +1. **random_content**: 修复表名映射(chengyu→cy, cidian→zc)和`$this->success()`参数顺序错误 +2. **content_count**: 修复`strpos()`错误,改用原生SQL查询 +3. **quick_platform**: 未指定ids时只操作is_enabled=1的分类,禁用分类保持不变 + +#### 修改文件 +- `docs/toolsapi/application/admin/controller/FeedWeight.php` — 新增3个接口,修复success参数,原生SQL替代Db::name +- `docs/toolsapi/public/assets/js/backend/feed_weight.js` — 开关样式按钮,平台图标点击,内容数列 +- `docs/toolsapi/application/admin/view/feed_weight/index.html` — 开关样式快捷面板,分类多选 +- `docs/toolsapi/docs/API_PLATFORM_FILTER_DOC.md` — 新增3个接口文档 + +*** + +## [v6.10.0] - 2026-06-09 + +### ✨ 新功能 — A/B测试实验管理 + +#### 功能描述 +新增A/B测试功能,允许管理员对不同平台+用户分组展示不同的内容权重配置。例如:安卓用户A组看到笑话权重50,B组看到笑话权重30。 + +#### 新增数据表 +- `tool_ab_test` — A/B测试实验表(实验名称、标识、平台、状态、流量占比、时间范围) +- `tool_ab_test_variant` — A/B测试变体表(变体名称、标识、权重覆盖JSON、流量占比、是否对照组) + +#### 新增后台功能 +1. **实验列表页**: 显示实验名称、目标平台、状态标签(草稿/运行中/已暂停/已结束)、流量占比、变体数量、操作按钮 +2. **新建实验**: 实验名称、标识、描述、目标平台(下拉选择)、流量占比、计划时间、变体配置(动态添加,含权重覆盖弹窗) +3. **编辑实验**: 预填已有数据,运行中实验仅允许修改描述和结束时间 +4. **实验状态流转**: 草稿→启动→暂停→恢复→结束,含校验(至少2个变体、流量占比总和100%、必须有对照组) +5. **变体权重配置**: 弹窗选择要覆盖的分类,设置权重值和启用状态,JSON格式存储 +6. **删除实验**: 仅草稿状态可删除 + +#### 新增API接口 +- `GET /api/feed/ab_test_config?platform=android&user_id=xxx` — 获取A/B测试配置 + - 根据平台匹配运行中的实验 + - 根据user_id的crc32 hash稳定分配变体 + - 返回test_key、variant、weight_overrides + +#### 新增文件 +- `Scripts/add_ab_test_table.py` — 数据库迁移脚本(SSH+PHP CLI建表) +- `Scripts/add_ab_test_menu.py` — 权限菜单添加脚本 +- `docs/toolsapi/application/admin/controller/AbTest.php` — 后台控制器 +- `docs/toolsapi/application/admin/view/ab_test/index.html` — 列表视图 +- `docs/toolsapi/application/admin/view/ab_test/add.html` — 新建视图 +- `docs/toolsapi/application/admin/view/ab_test/edit.html` — 编辑视图 +- `docs/toolsapi/public/assets/js/backend/ab_test.js` — 后台JS +- `docs/toolsapi/application/admin/lang/zh-cn/ab_test.php` — 语言包 + +#### 修改文件 +- `docs/toolsapi/application/api/controller/Feed.php` — 新增ab_test_config接口和_getAbTestConfig方法 + +#### weight_config JSON格式示例 +```json +{ + "joke": {"weight": 30, "is_enabled": true}, + "poetry": {"weight": 70, "is_enabled": true} +} +``` + +*** + +## [v6.9.2] - 2026-06-09 + +### 🛡️ 修复 — platform_enabled 格式异常容错处理 + +#### 变更描述 +为 `platform_enabled` 字段添加全面容错逻辑,防止服务端返回异常格式数据时导致客户端解析崩溃或频道显示异常。 + +#### 容错策略 +- `platform_enabled` 为 null → 所有平台默认启用 +- `platform_enabled` 不是 Map 类型 → 所有平台默认启用 +- 某个平台 key 不存在 → 该平台默认启用 +- 某个平台值不是 bool 类型 → 该平台默认启用 +- 平台检测异常 → 兜底返回 'other' + +#### 修改文件 +- `lib/features/home/models/feed_model.dart` — FeedChannel 新增 `platformEnabled` 字段和 `_parsePlatformEnabled` 容错解析函数,新增 `isPlatformEnabled()` 方法 +- `lib/core/network/api_interceptor.dart` — `currentPlatform` getter 增加 try-catch 兜底 + +#### 向后兼容 +- `FeedChannel` 新增字段均有默认值,不影响现有使用 +- `is_enabled` 字段解析逻辑不变 +- 服务端 `platform_enabled` 为空时行为与之前一致(所有平台启用) + +*** + +## [v6.9.1] - 2026-06-09 + +### ♻️ 重构 — 深度链接解析逻辑配置驱动化 + +#### 变更描述 +将 `app_router.dart` 中5个硬编码 switch 方法的深度链接解析逻辑,重构为基于 `route_registry` 的配置驱动架构。新增路由只需在 `RouteDef.deepLinkAliases` 中声明别名即可,无需手动同步多个 switch 语句。 + +#### 新增文件 +- `lib/core/router/deep_link_resolver.dart` — 配置驱动的深度链接解析器 + +#### 修改文件 +- `lib/core/router/route_def.dart` — RouteDef 新增 `deepLinkAliases` 字段 +- `lib/core/router/app_router.dart` — 删除5个硬编码 switch 方法,改用 DeepLinkResolver +- `lib/core/router/route_registry.dart` — 为46个路由添加 deepLinkAliases 映射 +- `lib/main.dart` — 添加 debug 模式下的深度链接配置验证 + +#### 向后兼容 +- 深度链接解析结果与重构前完全一致 +- DeepLinkService 调用方式不变 +- 所有 xianyan:// 和 https://s2ss.com 链接行为不变 + +*** + +## [v6.10.0] - 2026-06-09 + +### ✨ 新功能 — A/B测试系统 + 后台优化 + +#### A/B测试系统 +- 新增 `tool_ab_test` 和 `tool_ab_test_variant` 数据表 +- 后台新增「A/B测试」管理菜单(信息流管理下) +- 支持创建实验:实验名称、标识、目标平台、流量占比 +- 支持多变体配置:A组(对照组)/B组/C组,每组可设置权重覆盖 +- 实验状态管理:草稿→运行中→已暂停→已结束 +- 启动校验:至少2个变体、流量占比总和100%、必须有对照组 +- 用户分配算法:基于user_id的crc32哈希,确保同一用户始终分配到同一变体 +- API接口:`GET /api/feed/ab_test_config?platform=android&user_id=xxx` +- 运行中实验缓存30秒,状态变更时主动清除 + +#### 后台优化(8项) +1. **修复页面报错**:index.html模板`{:foreach}`语法错误改为`{foreach}` +2. **random_content性能优化**:`ORDER BY RAND() LIMIT 1`替代`COUNT+OFFSET` +3. **事务包裹**:`quick_all_platforms`/`quick_all_categories`/`batch_platform`/`reset_defaults`/`sync`均加事务 +4. **SQL注入防护**:`random_content`/`sub_categories`加白名单校验(`_isValidTable()`) +5. **缓存清理完善**:清除所有平台维度的缓存key + 模板编译缓存 +6. **platform_count排序优化**:MySQL JSON_EXTRACT函数替代PHP内存排序 +7. **快捷操作批量选择**:选中分类后快捷平台操作只影响选中的分类 +8. **APP端容错**:`platform_enabled`格式异常时默认所有平台启用 + +#### 修改文件 +- `docs/toolsapi/application/admin/controller/FeedWeight.php` — 8项优化 +- `docs/toolsapi/application/admin/controller/AbTest.php` — 新增A/B测试控制器 +- `docs/toolsapi/application/admin/view/feed_weight/index.html` — 修复模板语法 +- `docs/toolsapi/application/admin/view/ab_test/` — 新增3个视图 +- `docs/toolsapi/application/api/controller/Feed.php` — 新增ab_test_config接口 +- `docs/toolsapi/public/assets/js/backend/ab_test.js` — 新增 +- `docs/toolsapi/public/assets/js/backend/feed_weight.js` — 快捷操作支持批量选择 +- `lib/features/home/models/feed_model.dart` — platform_enabled容错 +- `lib/core/network/api_interceptor.dart` — 平台检测容错 + +*** + +## [v6.9.0] - 2026-06-09 + +### ✨ 新功能 — 推荐权重管理后台全面升级 + +#### 功能描述 +后台推荐权重管理页面全面升级:启用状态列显示平台图标、平台开关交互修复、快捷操作面板、内容类型中文显示、表头排序、随机内容预览、子类查询等。 + +#### 变更清单 +1. **启用状态列改为显示平台图标**: 列表页不再显示"启用/禁用"文字,改为显示7个平台emoji图标,开启的图标正常显示,关闭的图标半透明+分隔线 +2. **修复平台开关点击无反应**: 根因是ThinkPHP模板引擎无法用`$row.platform_data[$key]`访问变量key的数组元素,改为JavaScript动态生成平台开关按钮 +3. **平台数量排序**: 新增"平台数"列,显示"开启数/7",支持点击排序 +4. **禁用状态下不可编辑平台开关**: 编辑视图中is_enabled=0时,平台开关变灰不可点击,切换启用状态时自动联动 +5. **快捷操作面板**: 列表页新增"快捷操作"按钮,展开面板包含: + - 一键开启/关闭某平台(7个平台×开/关=14个按钮) + - 一键启用/禁用某分类(下拉选择+启用/禁用) + - 全局操作:全部开启/关闭平台、全部启用/禁用分类 +6. **内容类型显示中文名**: nameMap从17种扩展到44种,所有内容类型显示emoji+中文名 +7. **表头排序**: ID/内容类型/推荐权重/展示权重/推送上限/启用状态/平台数 均支持点击排序 +8. **随机内容预览**: 编辑视图新增"随机一条"按钮,AJAX获取该分类的随机一条数据 +9. **子类查询**: 编辑视图新增"查看子类"按钮,显示该分类的总条数、字段列表、子类分组统计 + +#### 新增API接口 +- `POST /admin.php/feed_weight/quick_platform` — 一键开启/关闭所有分类的某个平台 +- `POST /admin.php/feed_weight/quick_category` — 一键启用/禁用某个分类 +- `POST /admin.php/feed_weight/quick_all_platforms` — 一键开启/关闭所有分类的所有平台 +- `POST /admin.php/feed_weight/quick_all_categories` — 一键启用/禁用所有分类 +- `GET /admin.php/feed_weight/random_content` — 获取某个内容类型的随机一条数据 +- `GET /admin.php/feed_weight/sub_categories` — 获取某个内容类型的子类信息和统计 + +#### 修改文件 +- `docs/toolsapi/application/admin/controller/FeedWeight.php` — 新增6个API方法 +- `docs/toolsapi/application/admin/view/feed_weight/edit.html` — 重写平台开关为JS动态生成 +- `docs/toolsapi/application/admin/view/feed_weight/index.html` — 新增快捷操作面板 +- `docs/toolsapi/public/assets/js/backend/feed_weight.js` — 重写列定义和格式化器 +- `Scripts/upload_platform_filter.py` — 新增JS文件上传支持 + +*** + +## [v6.8.0] - 2026-06-09 + +### ✨ 新功能 — 平台过滤:按平台控制内容分类开关 + +#### 功能描述 +后台推荐权重管理模块的"启用状态"从单一开关改为多选平台开关,支持按平台(android/iOS/鸿蒙/macOS/Windows/Web/其他)独立控制每个内容分类的推送状态。APP端根据当前运行平台自动过滤,只展示该平台启用的内容。 + +#### 后台管理变更 +- **FeedWeight控制器**: 新增 `$platforms` 平台列表常量,新增 `batch_platform()` 批量平台操作方法 +- **编辑接口**: 支持 `platform_enabled` JSON参数,设置每个平台的开关状态 +- **批量操作**: 支持全选/全不选/反选/按平台切换 +- **视图**: 新增"平台操作"下拉菜单,支持一键操作;编辑页面新增平台开关按钮组(全选/反选/按平台切换) +- **恢复默认**: 重置时所有平台开关恢复为开启 + +#### API变更 +- **新增接口**: `GET /api/feed/platform_config` — 获取当前平台的内容分类配置 +- **扩展接口**: `channels/list/mix/weight_config` 新增 `platform` 参数,支持按平台过滤 +- **平台传递**: 支持 `X-Platform` 请求头 + `platform` URL参数双重传递 +- **install接口**: 新增分类时自动设置 `platform_enabled` 默认值(所有平台开启) +- **数据库**: `fa_feed_weight_config` 表新增 `platform_enabled` VARCHAR(255) 字段(表前缀tool_) + +#### 端到端测试结果 +- 关闭joke的iOS平台 → iOS频道不包含joke ✅ +- 安卓频道仍包含joke ✅ +- platform_config接口正确返回禁用列表 ✅ +- 恢复后iOS频道重新包含joke ✅ + +#### Flutter APP变更 +- **ApiInterceptor**: `X-Platform` 请求头从固定值`flutter`改为实际平台标识(android/ios/harmony等) +- **FeedService**: `fetchChannels/fetchRefreshContent` 新增 `platform` 参数 +- **FeedListParams**: 新增 `platform` 字段,`toQueryParameters()` 自动附带 +- **FeedMixConfig**: 新增 `platform` 字段,`toQueryParameters()` 自动附带 +- **HomeFeedMixin**: 所有Feed数据拉取调用自动传入当前平台标识 + +#### 向后兼容 +- `platform_enabled` 为空时所有平台默认启用 +- 不传 `platform` 参数时不按平台过滤(等同旧版行为) +- 旧版APP不传 `X-Platform` 头时不受影响 + +#### 修改文件 +- `docs/toolsapi/application/admin/controller/FeedWeight.php` +- `docs/toolsapi/application/admin/model/FeedWeightConfig.php` +- `docs/toolsapi/application/admin/view/feed_weight/index.html` +- `docs/toolsapi/application/admin/lang/zh-cn/feed_weight.php` +- `docs/toolsapi/application/api/controller/Feed.php` +- `lib/core/network/api_interceptor.dart` +- `lib/features/home/services/feed_service.dart` +- `lib/features/home/models/feed_model.dart` +- `lib/features/home/providers/home_feed_mixin.dart` +- `docs/toolsapi/docs/API_PLATFORM_FILTER_DOC.md` (新增) +- `Scripts/upload_platform_filter.py` (新增) +- `Scripts/test_platform_filter.py` (新增) + +*** + ## [v6.7.5] - 2026-06-08 ### 🐛 修复 — 登录页/忘记密码页布局崩溃 diff --git a/Scripts/read_db_config.py b/Scripts/read_db_config.py new file mode 100644 index 00000000..34cb0b47 --- /dev/null +++ b/Scripts/read_db_config.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""读取服务器数据库配置""" +import paramiko + +HOST = '123.207.67.197' +PORT = 22 +USER = 'root' +PASS = '520Kiss123' + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect(HOST, port=PORT, username=USER, password=PASS, timeout=15) + +# 读取数据库配置 +stdin, stdout, stderr = ssh.exec_command('cat /www/wwwroot/tools.wktyl.com/application/database.php') +print(stdout.read().decode()) + +ssh.close() diff --git a/Scripts/test_platform_filter.py b/Scripts/test_platform_filter.py new file mode 100644 index 00000000..4cb39e5d --- /dev/null +++ b/Scripts/test_platform_filter.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@name 平台过滤接口测试脚本 +@desc 测试后台全平台/全分类关闭后,API接口是否正确返回空数据 +@created 2026-06-09 +@updated 2026-06-09 +""" + +import requests +import json +import sys +import time + +BASE_URL = "https://tools.wktyl.com" +PLATFORM = "android" + +results = {"passed": 0, "failed": 0} + + +def record_pass(): + results["passed"] += 1 + +def record_fail(): + results["failed"] += 1 + + +def test_api(name, url, expect_empty_list=False, expect_empty_channels=False, check_platform_field=False, headers=None): + """测试API接口""" + try: + h = headers or {} + resp = requests.get(url, headers=h, timeout=20) + data = resp.json() + code = data.get("code", 0) + + if code != 1: + print(f" ✗ {name}: 接口返回错误 code={code}, msg={data.get('msg', '')}") + record_fail() + return data + + result = data.get("data", {}) + + if expect_empty_list: + items = result.get("list", []) + if len(items) > 0: + print(f" ✗ {name}: 期望空列表,但返回了 {len(items)} 条数据") + record_fail() + else: + print(f" ✓ {name}: 正确返回空列表") + record_pass() + + if expect_empty_channels: + channels = result.get("channels", []) + if len(channels) > 0: + print(f" ✗ {name}: 期望空频道列表,但返回了 {len(channels)} 个频道") + record_fail() + else: + print(f" ✓ {name}: 正确返回空频道列表") + record_pass() + + if check_platform_field: + plat = result.get("platform", "") + if plat == PLATFORM: + print(f" ✓ {name}: platform字段正确 ({plat})") + record_pass() + else: + print(f" ✗ {name}: platform字段不正确 (期望={PLATFORM}, 实际={plat})") + record_fail() + + return result + + except Exception as e: + print(f" ✗ {name}: 请求异常 - {e}") + record_fail() + return None + + +def test_data_count(name, url, headers=None): + """测试接口返回的数据条数""" + try: + h = headers or {} + resp = requests.get(url, headers=h, timeout=20) + data = resp.json() + if data.get("code") != 1: + print(f" ✗ {name}: 接口返回错误 code={data.get('code')}") + record_fail() + return -1 + result = data.get("data", {}) + items = result.get("list", []) + count = len(items) + print(f" ✓ {name}: 返回 {count} 条数据") + record_pass() + return count + except Exception as e: + print(f" ✗ {name}: 请求异常 - {e}") + record_fail() + return -1 + + +def main(): + print("=" * 60) + print("平台过滤接口测试") + print(f"测试平台: {PLATFORM}") + print(f"基础URL: {BASE_URL}") + print("=" * 60) + + headers_with_platform = {"X-Platform": PLATFORM} + + # ============================================================ + # 第一步:测试正常状态(有启用分类时) + # ============================================================ + print("\n--- 第一步:测试正常状态(有启用分类) ---") + + print("\n[1] channels接口 - platform参数") + test_api("channels(platform)", + f"{BASE_URL}/api/feed/channels?platform={PLATFORM}", + check_platform_field=True) + + print("\n[2] list接口 - channel=all + platform") + test_data_count("list(all+platform)", + f"{BASE_URL}/api/feed/list?channel=all&platform={PLATFORM}&limit=5") + + print("\n[3] mix接口 - platform参数") + test_data_count("mix(+platform)", + f"{BASE_URL}/api/feed/mix?platform={PLATFORM}&limit=5") + + print("\n[4] trending接口 - platform参数") + test_data_count("trending(+platform)", + f"{BASE_URL}/api/feed/trending?platform={PLATFORM}&limit=5") + + print("\n[5] recommend接口 - platform参数") + test_data_count("recommend(+platform)", + f"{BASE_URL}/api/feed/recommend?platform={PLATFORM}&limit=5") + + print("\n[6] random接口 - platform参数") + test_data_count("random(+platform)", + f"{BASE_URL}/api/feed/random?platform={PLATFORM}&limit=5") + + print("\n[7] list接口 - 指定频道poetry + platform") + test_data_count("list(poetry+platform)", + f"{BASE_URL}/api/feed/list?channel=poetry&platform={PLATFORM}&limit=5") + + # ============================================================ + # 第二步:测试向后兼容性(不传platform参数) + # ============================================================ + print("\n--- 第二步:测试向后兼容性(不传platform) ---") + + print("\n[8] channels接口 - 无platform参数") + test_data_count("channels(无platform)", + f"{BASE_URL}/api/feed/channels") + + print("\n[9] list接口 - 无platform参数") + test_data_count("list(无platform)", + f"{BASE_URL}/api/feed/list?channel=all&limit=5") + + # ============================================================ + # 第三步:测试X-Platform请求头 + # ============================================================ + print("\n--- 第三步:测试X-Platform请求头 ---") + + test_data_count("channels(X-Platform头)", + f"{BASE_URL}/api/feed/channels", + headers=headers_with_platform) + + test_data_count("list(X-Platform头)", + f"{BASE_URL}/api/feed/list?channel=all&limit=5", + headers=headers_with_platform) + + # ============================================================ + # 第四步:测试platform_config接口 + # ============================================================ + print("\n--- 第四步:测试platform_config接口 ---") + + try: + resp = requests.get(f"{BASE_URL}/api/feed/platform_config?platform={PLATFORM}", timeout=15) + data = resp.json() + if data.get("code") == 1: + result = data.get("data", {}) + enabled = result.get("enabled_count", 0) + disabled = result.get("disabled_count", 0) + print(f" ✓ platform_config: 启用={enabled}, 禁用={disabled}") + record_pass() + else: + print(f" ✗ platform_config: 接口返回错误") + record_fail() + except Exception as e: + print(f" ✗ platform_config: 请求异常 - {e}") + record_fail() + + # ============================================================ + # 第五步:测试无效平台参数(应忽略,正常返回数据) + # ============================================================ + print("\n--- 第五步:测试无效平台参数 ---") + + try: + resp = requests.get(f"{BASE_URL}/api/feed/list?channel=all&platform=invalid_platform&limit=5", timeout=15) + data = resp.json() + items = data.get("data", {}).get("list", []) + print(f" ✓ 无效platform参数: 返回 {len(items)} 条数据(应忽略无效平台)") + record_pass() + except Exception as e: + print(f" ✗ 无效platform参数: 请求异常 - {e}") + record_fail() + + # ============================================================ + # 结果汇总 + # ============================================================ + print("\n" + "=" * 60) + print(f"测试结果: ✓ 通过={results['passed']}, ✗ 失败={results['failed']}") + print("=" * 60) + + if results["failed"] > 0: + print("\n⚠ 有测试失败,请检查!") + sys.exit(1) + else: + print("\n✓ 全部测试通过!") + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/Scripts/test_platform_filter_full.py b/Scripts/test_platform_filter_full.py new file mode 100644 index 00000000..cc5bfd70 --- /dev/null +++ b/Scripts/test_platform_filter_full.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@name 平台过滤核心场景测试(SSH版) +@desc 通过SSH操作数据库,关闭所有平台后验证API返回空数据,然后恢复 +@created 2026-06-09 +@updated 2026-06-09 +""" + +import requests +import json +import sys +import time +import paramiko + +BASE_URL = "https://tools.wktyl.com" +PLATFORM = "android" + +# 服务器配置 +HOST = '123.207.67.197' +PORT = 22 +USER = 'root' +PASS = '520Kiss123' + +results = {"passed": 0, "failed": 0} + + +def record_pass(): + results["passed"] += 1 + +def record_fail(): + results["failed"] += 1 + + +def ssh_exec(cmd): + """执行SSH命令""" + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(HOST, port=PORT, username=USER, password=PASS, timeout=15) + stdin, stdout, stderr = ssh.exec_command(cmd) + out = stdout.read().decode() + err = stderr.read().decode() + ssh.close() + return out, err + + +def clear_cache(): + """清除服务器运行时缓存""" + ssh_exec('rm -rf /www/wwwroot/tools.wktyl.com/runtime/cache/*') + + +def backup_platform_enabled(): + """备份当前platform_enabled字段""" + cmd = """mysql -u tools -p'tools' tools -N -e "SELECT GROUP_CONCAT(CONCAT(id,':',platform_enabled) SEPARATOR '|||') FROM tool_feed_weight_config" """ + out, err = ssh_exec(cmd) + return out.strip() + + +def close_all_platforms(): + """关闭所有平台 - 将所有记录的platform_enabled设为全false""" + cmd = """mysql -u tools -p'tools' tools -e "UPDATE tool_feed_weight_config SET platform_enabled='{\\\"android\\\":false,\\\"ios\\\":false,\\\"harmony\\\":false,\\\"macos\\\":false,\\\"win\\\":false,\\\"web\\\":false,\\\"other\\\":false}'" """ + out, err = ssh_exec(cmd) + if err and "ERROR" in err: + print(f" ✗ 关闭所有平台失败: {err[:200]}") + return False + print(" ✓ 所有平台已关闭") + return True + + +def restore_platform_enabled(backup): + """恢复platform_enabled字段""" + if not backup or backup == "NULL" or backup == "": + print(" ⚠ 无备份数据,使用全开启恢复") + cmd = """mysql -u tools -p'tools' tools -e "UPDATE tool_feed_weight_config SET platform_enabled='{\\\"android\\\":true,\\\"ios\\\":true,\\\"harmony\\\":true,\\\"macos\\\":true,\\\"win\\\":true,\\\"web\\\":true,\\\"other\\\":true}'" """ + out, err = ssh_exec(cmd) + return + + # 逐条恢复 + pairs = backup.split("|||") + for pair in pairs: + if ":" not in pair: + continue + parts = pair.split(":", 1) + id_val = parts[0].strip() + pe_val = parts[1].strip() if len(parts) > 1 else "" + # 转义单引号 + pe_escaped = pe_val.replace("'", "\\'") + if not pe_escaped or pe_escaped == "NULL": + pe_escaped = "" + cmd = f"""mysql -u tools -p'tools' tools -e "UPDATE tool_feed_weight_config SET platform_enabled='{pe_escaped}' WHERE id={id_val}" """ + ssh_exec(cmd) + print(" ✓ 平台设置已恢复") + + +def test_empty_result(name, url): + """测试接口是否返回空列表""" + try: + resp = requests.get(url, timeout=20) + data = resp.json() + if data.get("code") != 1: + print(f" ✗ {name}: 接口返回错误 code={data.get('code')}, msg={data.get('msg', '')}") + record_fail() + return + + result = data.get("data", {}) + items = result.get("list", result.get("channels", [])) + count = len(items) + + if count == 0: + print(f" ✓ {name}: 正确返回空数据 (0条)") + record_pass() + else: + print(f" ✗ {name}: 期望空数据,但返回了 {count} 条") + # 打印前2条数据的feed_type帮助调试 + for i, item in enumerate(items[:2]): + print(f" 样本[{i}]: feed_type={item.get('feed_type', '?')}, title={item.get('title', '?')[:30]}") + record_fail() + + except Exception as e: + print(f" ✗ {name}: 请求异常 - {e}") + record_fail() + + +def test_has_data(name, url): + """测试接口是否返回数据""" + try: + resp = requests.get(url, timeout=20) + data = resp.json() + if data.get("code") != 1: + print(f" ✗ {name}: 接口返回错误 code={data.get('code')}") + record_fail() + return + + result = data.get("data", {}) + items = result.get("list", result.get("channels", [])) + count = len(items) + + if count > 0: + print(f" ✓ {name}: 正确返回数据 ({count}条)") + record_pass() + else: + print(f" ✗ {name}: 期望有数据,但返回了0条") + record_fail() + + except Exception as e: + print(f" ✗ {name}: 请求异常 - {e}") + record_fail() + + +def main(): + print("=" * 60) + print("平台过滤核心场景测试(SSH版)") + print(f"测试场景: 后台全平台关闭 → API应返回空数据") + print(f"测试平台: {PLATFORM}") + print("=" * 60) + + # 备份当前设置 + print("\n--- 备份当前平台设置 ---") + backup = backup_platform_enabled() + print(f" 备份完成 ({len(backup)} 字符)") + + try: + # ============================================================ + # 核心测试:全平台关闭 + # ============================================================ + print("\n--- 核心测试:关闭所有平台 ---") + if not close_all_platforms(): + print("关闭平台失败,终止测试") + sys.exit(1) + + # 清除缓存 + print("\n 清除服务器缓存...") + clear_cache() + time.sleep(2) + + print("\n--- 验证:全平台关闭后API应返回空数据 ---") + + print("\n[1] channels接口 - platform=android") + test_empty_result("channels(全关闭)", + f"{BASE_URL}/api/feed/channels?platform={PLATFORM}") + + print("\n[2] list接口 - channel=all + platform=android") + test_empty_result("list(全关闭)", + f"{BASE_URL}/api/feed/list?channel=all&platform={PLATFORM}&limit=5") + + print("\n[3] mix接口 - platform=android") + test_empty_result("mix(全关闭)", + f"{BASE_URL}/api/feed/mix?platform={PLATFORM}&limit=5") + + print("\n[4] trending接口 - platform=android") + test_empty_result("trending(全关闭)", + f"{BASE_URL}/api/feed/trending?platform={PLATFORM}&limit=5") + + print("\n[5] recommend接口 - platform=android") + test_empty_result("recommend(全关闭)", + f"{BASE_URL}/api/feed/recommend?platform={PLATFORM}&limit=5") + + print("\n[6] random接口 - platform=android") + test_empty_result("random(全关闭)", + f"{BASE_URL}/api/feed/random?platform={PLATFORM}&limit=5") + + print("\n[7] list接口 - 指定频道poetry + platform=android") + test_empty_result("list(poetry+全关闭)", + f"{BASE_URL}/api/feed/list?channel=poetry&platform={PLATFORM}&limit=5") + + print("\n[8] platform_config接口 - platform=android") + try: + resp = requests.get(f"{BASE_URL}/api/feed/platform_config?platform={PLATFORM}", timeout=15) + data = resp.json() + if data.get("code") == 1: + result = data.get("data", {}) + enabled = result.get("enabled_count", 0) + if enabled == 0: + print(f" ✓ platform_config: 启用分类=0 (正确)") + record_pass() + else: + print(f" ✗ platform_config: 启用分类={enabled} (期望0)") + record_fail() + except Exception as e: + print(f" ✗ platform_config: 请求异常 - {e}") + record_fail() + + # ============================================================ + # 恢复:恢复平台设置 + # ============================================================ + print("\n--- 恢复:恢复平台设置 ---") + restore_platform_enabled(backup) + + # 清除缓存 + print("\n 清除服务器缓存...") + clear_cache() + time.sleep(2) + + print("\n--- 验证:恢复后API应返回数据 ---") + + print("\n[9] channels接口 - 恢复后") + test_has_data("channels(恢复后)", + f"{BASE_URL}/api/feed/channels?platform={PLATFORM}") + + print("\n[10] list接口 - 恢复后") + test_has_data("list(恢复后)", + f"{BASE_URL}/api/feed/list?channel=all&platform={PLATFORM}&limit=5") + + except Exception as e: + print(f"\n⚠ 测试过程异常: {e}") + # 尝试恢复 + print("尝试恢复平台设置...") + restore_platform_enabled(backup) + clear_cache() + + # ============================================================ + # 结果汇总 + # ============================================================ + print("\n" + "=" * 60) + print(f"测试结果: ✓ 通过={results['passed']}, ✗ 失败={results['failed']}") + print("=" * 60) + + if results["failed"] > 0: + print("\n⚠ 有测试失败,请检查!") + sys.exit(1) + else: + print("\n✓ 全部测试通过!") + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/Scripts/upload_ab_test.py b/Scripts/upload_ab_test.py new file mode 100644 index 00000000..7c214830 --- /dev/null +++ b/Scripts/upload_ab_test.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""上传A/B测试相关文件到服务器""" +import paramiko +import os + +HOST = '123.207.67.197' +USER = 'root' +PASS = '520Kiss123' + +LOCAL_BASE = r'e:\project\flutter\f\xianyan\docs\toolsapi\application' +REMOTE_BASE = '/www/wwwroot/tools.wktyl.com/application/' +JS_LOCAL = r'e:\project\flutter\f\xianyan\docs\toolsapi\public\assets\js\backend' +JS_REMOTE = '/www/wwwroot/tools.wktyl.com/public/assets/js/backend/' + +FILES = [ + ('admin/controller/AbTest.php', 'admin/controller/AbTest.php'), + ('admin/view/ab_test/index.html', 'admin/view/ab_test/index.html'), + ('admin/view/ab_test/add.html', 'admin/view/ab_test/add.html'), + ('admin/view/ab_test/edit.html', 'admin/view/ab_test/edit.html'), + ('admin/lang/zh-cn/ab_test.php', 'admin/lang/zh-cn/ab_test.php'), + ('api/controller/Feed.php', 'api/controller/Feed.php'), +] + +JS_FILES = [ + ('ab_test.js', 'ab_test.js'), + ('feed_weight.js', 'feed_weight.js'), +] + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect(HOST, port=22, username=USER, password=PASS, timeout=15) +sftp = ssh.open_sftp() + +success = 0 +for local_name, remote_name in FILES: + local_path = os.path.join(LOCAL_BASE, local_name) + remote_path = REMOTE_BASE + remote_name + if not os.path.exists(local_path): + print(f" SKIP: {local_name} (not found)") + continue + # 创建远程目录 + remote_dir = os.path.dirname(remote_path).replace('\\', '/') + try: + sftp.stat(remote_dir) + except: + ssh.exec_command(f'mkdir -p {remote_dir}') + import time; time.sleep(0.5) + try: + sftp.put(local_path, remote_path) + print(f" OK: {local_name}") + success += 1 + except Exception as e: + print(f" FAIL: {local_name}: {e}") + +for local_name, remote_name in JS_FILES: + local_path = os.path.join(JS_LOCAL, local_name) + remote_path = JS_REMOTE + remote_name + if not os.path.exists(local_path): + print(f" SKIP: {local_name} (not found)") + continue + try: + sftp.put(local_path, remote_path) + print(f" OK: JS/{local_name}") + success += 1 + except Exception as e: + print(f" FAIL: JS/{local_name}: {e}") + +# 清除缓存 +ssh.exec_command('rm -rf /www/wwwroot/tools.wktyl.com/runtime/cache/* /www/wwwroot/tools.wktyl.com/runtime/temp/*') +sftp.close() +ssh.close() +print(f"\nDone: {success}/{len(FILES)+len(JS_FILES)}") diff --git a/Scripts/upload_feed_fix.py b/Scripts/upload_feed_fix.py new file mode 100644 index 00000000..2e977829 --- /dev/null +++ b/Scripts/upload_feed_fix.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@name 上传Feed控制器到服务器 +@desc SFTP上传修复后的Feed.php到服务器 +@created 2026-06-09 +@updated 2026-06-09 +""" + +import paramiko +import os + +# 服务器配置 +HOST = '123.207.67.197' +PORT = 22 +USER = 'root' +PASS = '520Kiss123' + +# 本地文件 -> 远程路径映射 +UPLOAD_MAP = { + r'docs\toolsapi\application\api\controller\Feed.php': '/www/wwwroot/tools.wktyl.com/application/api/controller/Feed.php', + r'docs\toolsapi\application\admin\controller\FeedWeight.php': '/www/wwwroot/tools.wktyl.com/application/admin/controller/FeedWeight.php', + r'docs\toolsapi\application\admin\view\feed_weight\index.html': '/www/wwwroot/tools.wktyl.com/application/admin/view/feed_weight/index.html', + r'docs\toolsapi\public\assets\js\backend\feed_weight.js': '/www/wwwroot/tools.wktyl.com/public/assets/js/backend/feed_weight.js', +} + +def upload(): + local_base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + print(f"本地项目根目录: {local_base}") + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + print(f"连接服务器 {HOST}:{PORT}...") + ssh.connect(HOST, port=PORT, username=USER, password=PASS, timeout=15) + sftp = ssh.open_sftp() + print("连接成功!") + + for local_rel, remote_path in UPLOAD_MAP.items(): + local_path = os.path.join(local_base, local_rel) + if not os.path.exists(local_path): + print(f"[跳过] 本地文件不存在: {local_path}") + continue + + # 确保远程目录存在 + remote_dir = os.path.dirname(remote_path) + try: + sftp.stat(remote_dir) + except FileNotFoundError: + print(f" 创建远程目录: {remote_dir}") + + print(f"上传: {local_rel} -> {remote_path}") + sftp.put(local_path, remote_path) + print(f" ✓ 上传成功") + + sftp.close() + + # 清除服务器缓存 + print("\n清除服务器运行时缓存...") + stdin, stdout, stderr = ssh.exec_command('rm -rf /www/wwwroot/tools.wktyl.com/runtime/cache/*') + print(f" 缓存清除: {stdout.read().decode()}") + + ssh.close() + print("\n✓ 全部上传完成!") + +if __name__ == '__main__': + upload() diff --git a/Scripts/upload_platform_filter.py b/Scripts/upload_platform_filter.py new file mode 100644 index 00000000..7a2f206a --- /dev/null +++ b/Scripts/upload_platform_filter.py @@ -0,0 +1,75 @@ + +#!/usr/bin/env python3 +"""上传平台过滤相关PHP代码到服务器""" +import paramiko +import os + +HOST = '123.207.67.197' +PORT = 22 +USER = 'root' +PASS = '520Kiss123' +REMOTE_BASE = '/www/wwwroot/tools.wktyl.com/application/' +LOCAL_BASE = os.path.join(os.path.dirname(__file__), '..', 'docs', 'toolsapi', 'application') + +UPLOAD_FILES = [ + ('admin/controller/FeedWeight.php', 'admin/controller/FeedWeight.php'), + ('admin/model/FeedWeightConfig.php', 'admin/model/FeedWeightConfig.php'), + ('admin/view/feed_weight/index.html', 'admin/view/feed_weight/index.html'), + ('admin/view/feed_weight/edit.html', 'admin/view/feed_weight/edit.html'), + ('admin/lang/zh-cn/feed_weight.php', 'admin/lang/zh-cn/feed_weight.php'), + ('api/controller/Feed.php', 'api/controller/Feed.php'), +] + +# JS文件在不同目录 +JS_LOCAL_BASE = os.path.join(os.path.dirname(__file__), '..', 'docs', 'toolsapi', 'public', 'assets', 'js', 'backend') +JS_REMOTE_BASE = '/www/wwwroot/tools.wktyl.com/public/assets/js/backend/' +JS_FILES = [ + ('feed_weight.js', 'feed_weight.js'), +] + +def main(): + local_base = os.path.abspath(LOCAL_BASE) + print(f"本地代码目录: {local_base}") + print(f"待上传文件: {len(UPLOAD_FILES)} 个\n") + + ssh = paramiko.SSHClient() + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + ssh.connect(HOST, port=PORT, username=USER, password=PASS, timeout=15) + sftp = ssh.open_sftp() + + success_count = 0 + for local_rel, remote_rel in UPLOAD_FILES: + local_path = os.path.join(local_base, local_rel.replace('/', os.sep)) + remote_path = REMOTE_BASE + remote_rel + if not os.path.exists(local_path): + print(f" ⚠️ 本地文件不存在: {local_path}") + continue + try: + sftp.put(local_path, remote_path) + print(f" ✅ {local_rel}") + success_count += 1 + except Exception as e: + print(f" ❌ {local_rel}: {e}") + + # 上传JS文件 + js_local_base = os.path.abspath(JS_LOCAL_BASE) + for local_name, remote_name in JS_FILES: + local_path = os.path.join(js_local_base, local_name) + remote_path = JS_REMOTE_BASE + remote_name + if not os.path.exists(local_path): + print(f" ⚠️ 本地JS文件不存在: {local_path}") + continue + try: + sftp.put(local_path, remote_path) + print(f" ✅ JS: {local_name}") + success_count += 1 + except Exception as e: + print(f" ❌ JS: {local_name}: {e}") + + sftp.close() + ssh.exec_command('rm -rf /www/wwwroot/tools.wktyl.com/runtime/cache/*') + ssh.close() + print(f"\n✅ 上传完成: {success_count}/{len(UPLOAD_FILES) + len(JS_FILES)}") + +if __name__ == '__main__': + main() diff --git a/android/.gitignore b/android/.gitignore index be3943c9..d881d284 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -7,8 +7,7 @@ gradle-wrapper.jar GeneratedPluginRegistrant.java .cxx/ -# Remember to never publicly share your keystore. -# See https://flutter.dev/to/reference-keystore -key.properties -**/*.keystore -**/*.jks +# Keystore 和签名配置已纳入版本管理,确保换电脑签名一致 +# key.properties +# **/*.keystore +# **/*.jks diff --git a/android/app/520kiss123.jks b/android/app/520kiss123.jks new file mode 100644 index 0000000000000000000000000000000000000000..6dfd370f739a237f0932d11dc2e8184dba572394 GIT binary patch literal 2528 zcma)8c{CJ`7M>MjYcRb+gQ!L$%#5GiAWL?#B{F0kdqs9JhV&Dao$Qn)WX&2O@iW$} zBgT?lb~1$Yyz}1acivy`oqO)N=YHRJ?|=6@7ej{8KtM2t42xiZp(8XS_St}pz-%(i z7ea=4ox=ApWN6KQQP2Vi8T#rJ&N|IB2A2O!u`&Ze*<`5lDX4&P{5=6>!ZGmF@=5M4@r?&qC&5&do7QN9)gLcu{m0Sqex)4w-@U{C-C$pE_>p$T*VLx5-q z+^|UtH+=wZ4W#$W5YO@BAY{nr2A2Z8F1T*U#(DrPOWDYv(G`-oef+6O5e!la58v2pVv=`QRUDOD37{&4vL&**t`9{*o&7fgB6&#oF3n3BTM) zA6|&Mgh}fY(o-D^NKkVoeSynwJc6cvCI27-Oods?CuN5BZ8OjdQ_0TfUa)Ykn47UV zZPXf%PVvJ+YHP;xEbR?ENaY!$@tuKo->=7cs86o96mH;z+=hhSsJWgI3N95@ds?0d$F* zk(He{Nvi(;d?(k^i=s{R5{L+tjic z9@`Pwr}~aX)wqJt;AQRx?EB!awLTL@t3C@yd9&Sl7Rg@^Qp11n!&^s1jfldYJNlXR zyAo4wP8k9nu1afgn%aur3|$CfwhfOg*odGN_f@l= zyMo>+l(I46#b+m(fABqgz^xmu=*rtA&zoLz+07}f$80gc!uXNKPOpR8phnivQFB#| zzRaf0x->y-O_*dcyls*F{eedkOOM@kIv>ASPInnRO8)g6;3CsndkQo=_lv&dK~+u{ zlnb4%Wb^EdT%2GV?Jd9PUT_N_c9Qr-&atWu#T-4Va#CFz_*BQnS@mBphr%%g;S06KuURPP6NPw0=|R z!`sedR0z?3S}hrGdsEf-Kxdeqh&@>`-=Pwoj>9%$NU%xgyh2ljFwdCf`t~LHXf`ty zkpe-}k2q zNT!NZd1d@;3_?7Mhw_<$w+*l}W!9aJj;y;CRNF#SSW>%m*;AN0YCTTrPS$((4dK+z zd!xuOsa$*N#jg(JSE)&(0+xJv;v%)y#=NIrwM^7K^?h&5gp?P-L_Q)=8|c z&INS=y9%E6(Q^s~wpDXohLkms92ObR=h15{=-l=`IOT`%_p!kR113Lcz>LclOORJ$ zm$Kz@05~B&W{6=sG0ipcwvlVcW>dH=%oEM=sK(o?6SW1UN2`yJ7QSNASDJP0 z#Rn&FmP`6BYf3+RDWP8$7}VZV@_5jjrl^(hdeut7A=^PJ?Yv`8Su9mHF|T+^idejL zh-+^n#@$X;Inz&->TVx54O3YUwSS5aL6#+a?tCzB>rLJHwBC^%Pzjf-3HlbAW@QfJ zaQ7qB$oN^1EJZqsVoI*PP8dE`@Am(4n`ZxU*0p5V_^Ma#W5*~lQr4C)bl&aZ! zbbZO%D(WC3zEJ8wCd0jg|6}ZPlbjzn^il|tI^(Pw`2limE05QL$Pao+A4MkuE&SRf zP80?BFFWdLj`0%?sn7(4Gp(d!hVzL6%gGgjBaME_8%H6e8Ev(J^RF~~RGl(e2THoG zoxKyaFOqPf=R)4aCeeND3b3*+V8%XyiFa0`XEo%Zc68_495Ofe-0O4szwFP+K?RK1 z;>IWnTf9X-tG3_4B#{dP9t5lG5XqU_?71$L&*XxJnlhD8SUV^SM1kf@28eE z-tn)L^o#WT~FKCz)|mMhD4-L8Ajl1!|q(=}AuVMz`gBSD?T{|VO zi;1^|@2Z{EFc&q+S z-Spi%(PL+YwLe*!8>OmA2EH0{Xm;+M=5l*P(Q+Q<5^=XaY0Ow%&VTs}qldv_*nWRK zARrU~5h#;D8p_f!1=#N|Dwli&AvG1Gvd!zIjzRg62A!FMd=SJ-zeZ;9N=)=S#*~FX Os + android:manageSpaceActivity=".ManageSpaceActivity"> + + diff --git a/android/app/src/main/kotlin/apps/xy/xianyan/MainActivity.kt b/android/app/src/main/kotlin/apps/xy/xianyan/MainActivity.kt index 68586457..2f0a01a8 100644 --- a/android/app/src/main/kotlin/apps/xy/xianyan/MainActivity.kt +++ b/android/app/src/main/kotlin/apps/xy/xianyan/MainActivity.kt @@ -1,17 +1,15 @@ // ============================================================ // 闲言APP — Android主Activity // 创建时间: 2026-04-20 -// 更新时间: 2026-06-01 -// 作用: Flutter主入口,处理BLE广播 + 系统管理空间拦截 -// 上次更新: shortcuts.xml已对齐quick_actions_android插件的extra key和action,无需额外处理 +// 更新时间: 2026-06-09 +// 作用: Flutter主入口,处理BLE广播 + 管理空间跳转 +// 上次更新: 拆分管理空间对话框到ManageSpaceActivity,MainActivity仅处理intent extras跳转 // ============================================================ package apps.xy.xianyan import android.content.Intent import android.os.Bundle -import android.os.Handler -import android.os.Looper import android.util.Log import apps.xy.xianyan.ble.BleAdvertiserPlugin import io.flutter.embedding.android.FlutterActivity @@ -27,26 +25,38 @@ class MainActivity : FlutterActivity() { private const val ACTION_MANAGE_STORAGE = "android.app.action.MANAGE_STORAGE" } + /** 待执行的ManageSpace操作(从ManageSpaceActivity传递过来) */ + private var pendingManageSpaceAction: String? = null private var pendingManageStorage = false private var methodChannel: MethodChannel? = null + // ---- 生命周期 ---- + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) bleAdvertiser.setupChannel(flutterEngine!!, this) - if (isManageStorageIntent(intent)) { - pendingManageStorage = true - Log.i(TAG, "onCreate: MANAGE_STORAGE intent detected, pending navigation") - } + // 检查是否从ManageSpaceActivity跳转过来 + handleManageSpaceIntent(intent) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - if (isManageStorageIntent(intent)) { - Log.i(TAG, "onNewIntent: MANAGE_STORAGE intent, notifying Flutter") - notifyFlutterOpenDataManagement() + // 检查是否从ManageSpaceActivity跳转过来 + handleManageSpaceIntent(intent) + } + + override fun onResume() { + super.onResume() + + // 执行待处理的管理空间操作 + pendingManageSpaceAction?.let { action -> + pendingManageSpaceAction = null + window.decorView.post { + invokeFlutterMethod(action) + } } } @@ -67,27 +77,6 @@ class MainActivity : FlutterActivity() { else -> result.notImplemented() } } - - if (pendingManageStorage) { - pendingManageStorage = false - Handler(Looper.getMainLooper()).postDelayed({ - notifyFlutterOpenDataManagement() - }, 800) - } - } - - private fun notifyFlutterOpenDataManagement() { - if (methodChannel == null) { - pendingManageStorage = true - Log.w(TAG, "MethodChannel not ready, will retry after configureFlutterEngine") - return - } - methodChannel?.invokeMethod("open_data_management", null) - Log.i(TAG, "Invoked open_data_management via MethodChannel") - } - - private fun isManageStorageIntent(intent: Intent?): Boolean { - return intent?.action == ACTION_MANAGE_STORAGE } override fun onDestroy() { @@ -95,4 +84,45 @@ class MainActivity : FlutterActivity() { methodChannel = null super.onDestroy() } + + // ---- 管理空间处理 ---- + + /** + * 检查intent中是否包含ManageSpaceActivity传递的操作 + * 支持两种来源: + * 1. ManageSpaceActivity通过extra传递的操作 + * 2. 系统MANAGE_STORAGE intent(兼容旧逻辑) + */ + private fun handleManageSpaceIntent(intent: Intent?) { + if (intent == null) return + + // 优先检查ManageSpaceActivity传递的extra + val action = intent.getStringExtra(ManageSpaceActivity.EXTRA_MANAGE_SPACE_ACTION) + if (action != null) { + Log.i(TAG, "handleManageSpaceIntent: received action from ManageSpaceActivity: $action") + pendingManageSpaceAction = action + return + } + + // 兼容:检查系统MANAGE_STORAGE intent + if (intent.action == ACTION_MANAGE_STORAGE) { + Log.i(TAG, "handleManageSpaceIntent: MANAGE_STORAGE intent detected") + pendingManageStorage = true + pendingManageSpaceAction = ManageSpaceActivity.ACTION_DATA_MANAGEMENT + } + } + + /** + * 通过MethodChannel调用Flutter端方法 + */ + private fun invokeFlutterMethod(method: String) { + if (methodChannel == null) { + Log.e(TAG, "invokeFlutterMethod: MethodChannel not ready, cannot invoke $method") + // 保存操作,等MethodChannel就绪后重试 + pendingManageSpaceAction = method + return + } + methodChannel?.invokeMethod(method, null) + Log.i(TAG, "invokeFlutterMethod: invoked $method via MethodChannel") + } } diff --git a/android/app/src/main/kotlin/apps/xy/xianyan/ManageSpaceActivity.kt b/android/app/src/main/kotlin/apps/xy/xianyan/ManageSpaceActivity.kt new file mode 100644 index 00000000..2f06fea5 --- /dev/null +++ b/android/app/src/main/kotlin/apps/xy/xianyan/ManageSpaceActivity.kt @@ -0,0 +1,170 @@ +// ============================================================ +// 闲言APP — 管理空间Activity +// 创建时间: 2026-06-09 +// 更新时间: 2026-06-09 +// 作用: 安卓端"应用信息→管理空间"入口,弹出原生对话框 +// 上次更新: 初始创建,从MainActivity拆分独立管理空间逻辑 +// ============================================================ +// 设计说明: +// AndroidManifest中 android:manageSpaceActivity 指向此Activity。 +// 当用户在系统"应用信息"页点击"管理空间"时,系统启动此Activity。 +// 注意:系统不会发送 MANAGE_STORAGE action intent,而是直接启动此Activity, +// 因此不能在MainActivity中通过intent action检测。 +// 此Activity使用透明主题,仅显示对话框,用户操作后跳转MainActivity。 +// ============================================================ + +package apps.xy.xianyan + +import android.content.Intent +import android.os.Bundle +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.widget.LinearLayout +import androidx.appcompat.app.AppCompatActivity +import com.google.android.material.dialog.MaterialAlertDialogBuilder + +class ManageSpaceActivity : AppCompatActivity() { + + companion object { + /** Intent extra key:管理空间操作类型 */ + const val EXTRA_MANAGE_SPACE_ACTION = "manage_space_action" + /** 操作值:一键清理 */ + const val ACTION_CLEAR_ALL = "clear_all_data" + /** 操作值:跳转缓存管理 */ + const val ACTION_CACHE_MANAGEMENT = "navigate_to_cache_management" + /** 操作值:跳转数据管理 */ + const val ACTION_DATA_MANAGEMENT = "navigate_to_data_management" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // 直接显示对话框,Activity本身透明无UI + showManageSpaceDialog() + } + + /** + * 显示管理空间原生对话框 + * 三个选项:一键清理、缓存管理、数据管理 + */ + private fun showManageSpaceDialog() { + val dialogContext = ContextThemeWrapper(this, R.style.ManageSpaceDialogTheme) + val dialogView = LayoutInflater.from(dialogContext) + .inflate(R.layout.dialog_manage_space, null) + + val dialog = MaterialAlertDialogBuilder(dialogContext) + .setTitle("管理空间") + .setView(dialogView) + .setNegativeButton("取消") { _, _ -> finish() } + .create() + + // 一键清理 + dialogView.findViewById(R.id.option_clear_all)?.setOnClickListener { + dialog.dismiss() + showClearAllConfirmDialog() + } + + // 缓存管理 + dialogView.findViewById(R.id.option_cache_management)?.setOnClickListener { + dialog.dismiss() + startMainActivity(ACTION_CACHE_MANAGEMENT) + } + + // 数据管理 + dialogView.findViewById(R.id.option_data_management)?.setOnClickListener { + dialog.dismiss() + startMainActivity(ACTION_DATA_MANAGEMENT) + } + + // 暗色模式适配 + adaptDarkMode(dialogView) + + // 点击对话框外部或按返回键关闭 + dialog.setOnCancelListener { finish() } + + dialog.show() + } + + /** + * 一键清理二次确认对话框 + */ + private fun showClearAllConfirmDialog() { + val dialogContext = ContextThemeWrapper(this, R.style.ManageSpaceDialogTheme) + MaterialAlertDialogBuilder(dialogContext) + .setTitle("⚠️ 确认清理") + .setMessage("此操作将清除所有本地数据,包括收藏、笔记、缓存等。此操作不可撤销!") + .setPositiveButton("清理") { _, _ -> + startMainActivity(ACTION_CLEAR_ALL) + } + .setNegativeButton("取消") { _, _ -> + finish() + } + .setOnCancelListener { finish() } + .show() + } + + /** + * 启动MainActivity并传递操作类型 + * 使用 CLEAR_TOP + SINGLE_TOP 确保复用已有实例 + */ + private fun startMainActivity(action: String) { + val intent = Intent(this, MainActivity::class.java) + intent.putExtra(EXTRA_MANAGE_SPACE_ACTION, action) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + startActivity(intent) + finish() + } + + /** + * 暗色模式适配:调整自定义布局中的文字和分隔线颜色 + */ + private fun adaptDarkMode(root: android.view.View) { + val isDark = resources.configuration.uiMode and + android.content.res.Configuration.UI_MODE_NIGHT_MASK == + android.content.res.Configuration.UI_MODE_NIGHT_YES + + if (!isDark) return + + val titleColor = android.graphics.Color.parseColor("#E0E0E0") + val descColor = android.graphics.Color.parseColor("#8A8A8E") + val dividerColor = android.graphics.Color.parseColor("#3C3C3E") + + adjustTextViewColors(root, titleColor, descColor) + adjustDividerColors(root, dividerColor) + } + + private fun adjustTextViewColors( + view: android.view.View, + titleColor: Int, + descColor: Int, + ) { + if (view is android.widget.TextView) { + val currentColor = view.currentTextColor + if (currentColor == android.graphics.Color.parseColor("#212121")) { + view.setTextColor(titleColor) + } else if (currentColor == android.graphics.Color.parseColor("#9E9E9E")) { + view.setTextColor(descColor) + } else if (currentColor == android.graphics.Color.parseColor("#BDBDBD")) { + view.setTextColor(android.graphics.Color.parseColor("#666666")) + } + } else if (view is android.view.ViewGroup) { + for (i in 0 until view.childCount) { + adjustTextViewColors(view.getChildAt(i), titleColor, descColor) + } + } + } + + private fun adjustDividerColors(view: android.view.View, color: Int) { + if (view is android.view.ViewGroup) { + for (i in 0 until view.childCount) { + val child = view.getChildAt(i) + if (child !is android.widget.TextView && child !is LinearLayout) { + try { + child.setBackgroundColor(color) + } catch (_: Exception) {} + } else if (child is android.view.ViewGroup) { + adjustDividerColors(child, color) + } + } + } + } +} diff --git a/android/app/src/main/res/drawable-night/bg_icon_circle_blue.xml b/android/app/src/main/res/drawable-night/bg_icon_circle_blue.xml new file mode 100644 index 00000000..137beb4d --- /dev/null +++ b/android/app/src/main/res/drawable-night/bg_icon_circle_blue.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable-night/bg_icon_circle_destructive.xml b/android/app/src/main/res/drawable-night/bg_icon_circle_destructive.xml new file mode 100644 index 00000000..6ba3d249 --- /dev/null +++ b/android/app/src/main/res/drawable-night/bg_icon_circle_destructive.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable-night/bg_icon_circle_green.xml b/android/app/src/main/res/drawable-night/bg_icon_circle_green.xml new file mode 100644 index 00000000..e81bb89d --- /dev/null +++ b/android/app/src/main/res/drawable-night/bg_icon_circle_green.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable-night/bg_manage_option.xml b/android/app/src/main/res/drawable-night/bg_manage_option.xml new file mode 100644 index 00000000..bd1facda --- /dev/null +++ b/android/app/src/main/res/drawable-night/bg_manage_option.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable-night/bg_manage_option_destructive.xml b/android/app/src/main/res/drawable-night/bg_manage_option_destructive.xml new file mode 100644 index 00000000..f0573e57 --- /dev/null +++ b/android/app/src/main/res/drawable-night/bg_manage_option_destructive.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_dialog_background.xml b/android/app/src/main/res/drawable/bg_dialog_background.xml new file mode 100644 index 00000000..f45b777f --- /dev/null +++ b/android/app/src/main/res/drawable/bg_dialog_background.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_icon_circle_blue.xml b/android/app/src/main/res/drawable/bg_icon_circle_blue.xml new file mode 100644 index 00000000..e07febc6 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_icon_circle_blue.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_icon_circle_destructive.xml b/android/app/src/main/res/drawable/bg_icon_circle_destructive.xml new file mode 100644 index 00000000..dea0ccb7 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_icon_circle_destructive.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_icon_circle_green.xml b/android/app/src/main/res/drawable/bg_icon_circle_green.xml new file mode 100644 index 00000000..24689043 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_icon_circle_green.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_manage_option.xml b/android/app/src/main/res/drawable/bg_manage_option.xml new file mode 100644 index 00000000..a35a9cf8 --- /dev/null +++ b/android/app/src/main/res/drawable/bg_manage_option.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/bg_manage_option_destructive.xml b/android/app/src/main/res/drawable/bg_manage_option_destructive.xml new file mode 100644 index 00000000..611b164c --- /dev/null +++ b/android/app/src/main/res/drawable/bg_manage_option_destructive.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/dialog_manage_space.xml b/android/app/src/main/res/layout/dialog_manage_space.xml new file mode 100644 index 00000000..6148f847 --- /dev/null +++ b/android/app/src/main/res/layout/dialog_manage_space.xml @@ -0,0 +1,200 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 06952be7..5253e7ca 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -15,4 +15,22 @@ + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index cb1ef880..5c970081 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -15,4 +15,22 @@ + + + + + + diff --git a/android/key.properties b/android/key.properties new file mode 100644 index 00000000..7a59c603 --- /dev/null +++ b/android/key.properties @@ -0,0 +1,4 @@ +storePassword=520kiss123 +keyPassword=520kiss123 +keyAlias=520kiss123 +storeFile=520kiss123.jks diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index fb605bc8..48fdabc8 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -21,6 +21,7 @@ plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.9.1" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" } include(":app") diff --git a/docs/superpowers/plans/2026-06-09-deep-link-refactor.md b/docs/superpowers/plans/2026-06-09-deep-link-refactor.md new file mode 100644 index 00000000..72d2daf5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-deep-link-refactor.md @@ -0,0 +1,517 @@ +# 深度链接解析逻辑配置驱动重构 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将 app_router.dart 中硬编码的深度链接 switch 语句重构为配置驱动,利用现有 route_registry 统一管理路由映射,消除重复定义。 + +**Architecture:** 在 RouteDef 中新增深度链接元数据字段(deepLinkAliases),创建 DeepLinkResolver 类统一解析逻辑,从 route_registry 自动构建映射表,替代原有的5个硬编码 switch 方法。 + +**Tech Stack:** Dart, go_router, 现有 route_def / route_registry 体系 + +--- + +## 文件变更清单 + +| 操作 | 文件路径 | 职责 | +|------|----------|------| +| 修改 | `lib/core/router/route_def.dart` | RouteDef 新增 deepLinkAliases 字段 | +| 新增 | `lib/core/router/deep_link_resolver.dart` | 配置驱动的深度链接解析器 | +| 修改 | `lib/core/router/app_router.dart` | 删除硬编码 switch,改用 DeepLinkResolver | +| 修改 | `lib/core/router/route_registry.dart` | 为各路由添加 deepLinkAliases | +| 修改 | `CHANGELOG.md` | 记录重构变更 | + +--- + +### Task 1: 扩展 RouteDef 增加深度链接元数据字段 + +**Files:** +- Modify: `lib/core/router/route_def.dart` + +- [ ] **Step 1: 在 RouteDef 类中新增 deepLinkAliases 字段** + +在 `RouteDef` 构造函数和类中新增 `deepLinkAliases` 字段,用于定义该路由支持的深度链接别名列表: + +```dart +class RouteDef { + const RouteDef({ + required this.path, + required this.name, + required this.module, + this.page, + this.builder, + this.ohosBuilder, + this.transition = RouteTransition.iosSlide, + this.middlewares = const [], + this.redirectTo, + this.children = const [], + this.deepLinkAliases = const [], // 新增 + }); + + // ... 现有字段 ... + + /// 深度链接别名列表 + /// 格式: ['xianyan://host', 'https://s2ss.com/segment', '/tool/subpath'] + /// 解析器会自动从这些别名构建映射表 + final List deepLinkAliases; + + // ... 现有 getter ... +} +``` + +--- + +### Task 2: 创建 DeepLinkResolver 配置驱动解析器 + +**Files:** +- Create: `lib/core/router/deep_link_resolver.dart` + +- [ ] **Step 1: 创建 DeepLinkResolver 类** + +创建 `lib/core/router/deep_link_resolver.dart`,实现从 route_registry 自动构建映射表并解析深度链接: + +```dart +// ============================================================ +// 闲言APP — 深度链接配置驱动解析器 +// 创建时间: 2026-06-09 +// 作用: 从 route_registry 自动构建深度链接映射表,替代硬编码 switch +// ============================================================ + +import 'package:xianyan/core/router/route_def.dart'; +import 'package:xianyan/core/router/route_registry.dart'; +import 'package:xianyan/core/router/app_routes.dart'; +import 'package:xianyan/core/utils/logger.dart' show Log, LogCategory; + +/// 深度链接映射条目 +class _DeepLinkEntry { + const _DeepLinkEntry({ + required this.alias, + required this.targetRoute, + this.hasSubPath = false, + }); + + /// 别名模式(如 'xianyan://home', 'https://s2ss.com/fortune', '/tool/hanzi') + final String alias; + + /// 目标内部路由路径 + final String targetRoute; + + /// 是否支持子路径(如 xianyan://article/xxx 中的 xxx) + final bool hasSubPath; +} + +/// 配置驱动的深度链接解析器 +/// 从 route_registry 中的 deepLinkAliases 自动构建映射表 +class DeepLinkResolver { + DeepLinkResolver._(); + + static List<_DeepLinkEntry>? _entries; + + /// 获取所有映射条目(懒加载) + static List<_DeepLinkEntry> get entries { + if (_entries != null) return _entries!; + _entries = _buildEntries(); + return _entries!; + } + + /// 从 route_registry 构建映射表 + static List<_DeepLinkEntry> _buildEntries() { + final result = <_DeepLinkEntry>[]; + + for (final route in routeRegistry) { + for (final alias in route.deepLinkAliases) { + // 检测是否支持子路径(别名以 /* 结尾) + final hasSubPath = alias.endsWith('/*'); + final cleanAlias = hasSubPath ? alias.substring(0, alias.length - 2) : alias; + + result.add(_DeepLinkEntry( + alias: cleanAlias, + targetRoute: route.path, + hasSubPath: hasSubPath, + )); + } + } + + Log.i('🔗 [DeepLink] 映射表构建完成: ${result.length} 条规则', null, null, LogCategory.router); + return result; + } + + /// 清除缓存(测试用或路由热更新时调用) + static void invalidateCache() { + _entries = null; + } + + /// 解析 xianyan:// scheme 链接 + /// 格式: xianyan://[/] + static String? resolveCustomScheme(Uri uri) { + final host = uri.host; + final path = uri.path; + final prefix = 'xianyan://$host'; + + // 1. 精确匹配: xianyan://host + for (final entry in entries) { + if (!entry.alias.startsWith('xianyan://')) continue; + + if (entry.hasSubPath) { + // 子路径匹配: xianyan://article/* → 匹配 xianyan://article/xxx + if (entry.alias == prefix) { + return path.isNotEmpty ? path : entry.targetRoute; + } + } else { + // 精确匹配: xianyan://home + if (entry.alias == prefix && path.isEmpty) { + return entry.targetRoute; + } + // 带路径匹配: xianyan://tool/hanzi + if (path.isNotEmpty && entry.alias == '$prefix$path') { + return entry.targetRoute; + } + } + } + + // 2. 兜底: 尝试路径匹配 + return _resolvePathFallback(path); + } + + /// 解析 https://s2ss.com 通用链接 + /// 格式: https://s2ss.com/[/] + static String? resolveHttps(Uri uri) { + final segments = uri.pathSegments; + if (segments.isEmpty) return AppRoutes.home; + + final first = segments.first; + final prefix = 'https://s2ss.com/$first'; + + // 1. 精确匹配 + for (final entry in entries) { + if (!entry.alias.startsWith('https://s2ss.com/')) continue; + + if (entry.hasSubPath) { + // 子路径匹配: https://s2ss.com/article/* + if (entry.alias == prefix) { + return '/${segments.join('/')}'; + } + } else if (segments.length == 1) { + // 精确匹配: https://s2ss.com/fortune + if (entry.alias == prefix) { + return entry.targetRoute; + } + } + } + + // 2. 兜底: 尝试路径匹配 + return _resolvePathFallback('/${segments.join('/')}'); + } + + /// 路径兜底解析:将 URI path 段直接映射为内部路由 + static String? _resolvePathFallback(String path) { + if (path.isEmpty || path == '/') return AppRoutes.home; + + // 从映射表中查找 /tool/xxx 格式的路径 + for (final entry in entries) { + if (!entry.alias.startsWith('/')) continue; + + if (entry.alias == path) { + return entry.targetRoute; + } + } + + // 最终兜底:直接返回路径(如果是内部路由格式) + if (path.startsWith('/')) { + return path; + } + + return null; + } + + /// 验证映射配置完整性 + /// 检查所有 deepLinkAliases 的目标路由是否在 routeRegistry 中存在 + static List validate() { + final errors = []; + final validPaths = routeRegistry.map((r) => r.path).toSet(); + + for (final entry in entries) { + if (!validPaths.contains(entry.targetRoute)) { + errors.add('深度链接映射目标路由不存在: ${entry.alias} → ${entry.targetRoute}'); + } + } + + // 检查重复别名 + final seenAliases = {}; + for (final entry in entries) { + if (seenAliases.contains(entry.alias)) { + errors.add('重复的深度链接别名: ${entry.alias}'); + } + seenAliases.add(entry.alias); + } + + return errors; + } +} +``` + +--- + +### Task 3: 重构 AppRouter 深度链接解析逻辑 + +**Files:** +- Modify: `lib/core/router/app_router.dart` + +- [ ] **Step 1: 替换 AppRouter 中的硬编码 switch 方法** + +将 `AppRouter` 类中的 `_resolveCustomScheme`、`_resolveHttps`、`_resolveToolPath`、`_resolveSettingsPath`、`_resolvePathFallback` 全部删除,替换为调用 `DeepLinkResolver`: + +修改后的 `AppRouter` 类: + +```dart +/// 统一深度链接URI解析入口 +/// 支持 xianyan:// scheme 和 https://s2ss.com 通用链接 +/// 供 GoRouter redirect 和 DeepLinkService 共用 +class AppRouter { + AppRouter._(); + + /// 解析 URI 为内部路由路径 + /// 返回 null 表示不是深度链接,不需要重定向 + static String? resolveDeepLinkUri(Uri uri) { + final scheme = uri.scheme.toLowerCase(); + + if (scheme == 'xianyan') { + return DeepLinkResolver.resolveCustomScheme(uri); + } + + if (scheme == 'https' || scheme == 'http') { + final host = uri.host.toLowerCase(); + if (host == 's2ss.com' || host == 'www.s2ss.com') { + return DeepLinkResolver.resolveHttps(uri); + } + } + + return null; + } +} +``` + +同时添加 import: +```dart +import 'deep_link_resolver.dart'; +``` + +--- + +### Task 4: 在 route_registry 中为各路由添加深度链接映射 + +**Files:** +- Modify: `lib/core/router/route_registry.dart` + +- [ ] **Step 1: 为所有需要深度链接支持的路由添加 deepLinkAliases** + +根据原有硬编码 switch 中的映射关系,为 route_registry 中的每个路由添加对应的 deepLinkAliases。 + +**核心路由映射(来自 _resolveCustomScheme + _resolveHttps):** + +```dart +// home +deepLinkAliases: ['xianyan://home', 'https://s2ss.com/home'], + +// discover +deepLinkAliases: ['xianyan://discover', 'https://s2ss.com/discover'], + +// profile +deepLinkAliases: ['xianyan://profile', 'https://s2ss.com/profile'], + +// search +deepLinkAliases: ['xianyan://search', 'https://s2ss.com/search'], + +// dailyFortune +deepLinkAliases: ['xianyan://fortune', 'https://s2ss.com/fortune'], + +// article (支持子路径) +deepLinkAliases: ['xianyan://article/*', 'https://s2ss.com/article/*'], + +// noteList +deepLinkAliases: ['xianyan://notes', 'https://s2ss.com/notes'], + +// inspiration +deepLinkAliases: ['xianyan://inspiration', 'https://s2ss.com/inspiration'], + +// favorites +deepLinkAliases: ['xianyan://favorites', 'https://s2ss.com/favorites'], + +// history +deepLinkAliases: ['xianyan://history', 'https://s2ss.com/history'], + +// editor +deepLinkAliases: ['xianyan://editor', 'https://s2ss.com/editor'], + +// signin +deepLinkAliases: ['xianyan://signin'], + +// weather +deepLinkAliases: ['xianyan://weather', 'https://s2ss.com/weather'], + +// weatherSettings +deepLinkAliases: ['xianyan://weather/settings', 'https://s2ss.com/weather/settings'], + +// poetry +deepLinkAliases: ['xianyan://poetry', 'https://s2ss.com/poetry'], + +// poetrySettings +deepLinkAliases: ['xianyan://poetry/settings', 'https://s2ss.com/poetry/settings'], + +// pomodoro +deepLinkAliases: ['xianyan://pomodoro'], + +// countdown +deepLinkAliases: ['xianyan://countdown'], + +// solarTerm +deepLinkAliases: ['xianyan://solar-term'], + +// knowledgeGraph +deepLinkAliases: ['xianyan://knowledge-graph'], + +// studyPlan +deepLinkAliases: ['xianyan://study-plan'], + +// notificationSettings +deepLinkAliases: ['xianyan://notification-settings'], + +// achievement +deepLinkAliases: ['xianyan://achievement', 'xianyan://checkin', 'https://s2ss.com/achievement', 'https://s2ss.com/checkin'], + +// rank +deepLinkAliases: ['xianyan://rank', 'https://s2ss.com/rank'], + +// learning +deepLinkAliases: ['xianyan://learning', 'https://s2ss.com/learning'], +``` + +**工具路由映射(来自 _resolveToolPath):** + +```dart +// hanziTool +deepLinkAliases: ['/tool/hanzi'], + +// ocr +deepLinkAliases: ['/tool/ocr'], + +// china_colors +deepLinkAliases: ['/tool/colors', '/tool/china_colors'], + +// tool list (hot) +deepLinkAliases: ['/tool/hot', '/tool/list'], + +// calc +deepLinkAliases: ['/tool/calc'], + +// offline +deepLinkAliases: ['/tool/offline'], + +// cacheManagement +deepLinkAliases: ['/tool/cache'], + +// readLater +deepLinkAliases: ['/tool/readlater'], + +// nickTool +deepLinkAliases: ['/tool/nick'], + +// ... 其他工具路由类似 +``` + +**设置路由映射(来自 _resolveSettingsPath):** + +```dart +// themeSettings +deepLinkAliases: ['xianyan://settings/theme'], + +// generalSettings +deepLinkAliases: ['xianyan://settings/general', 'xianyan://settings'], + +// accountSettings +deepLinkAliases: ['xianyan://settings/account'], + +// dataManagement +deepLinkAliases: ['xianyan://settings/data'], + +// notificationSettings +deepLinkAliases: ['xianyan://settings/notifications'], + +// languageSettings +deepLinkAliases: ['xianyan://settings/language'], + +// fontManagement +deepLinkAliases: ['xianyan://settings/fonts'], + +// appLockSettings +deepLinkAliases: ['xianyan://settings/app-lock'], +``` + +--- + +### Task 5: 添加配置验证 + 更新 CHANGELOG + +**Files:** +- Modify: `lib/main.dart` (添加启动时验证) +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: 在 main.dart 中添加深度链接配置验证** + +在应用启动时调用 `DeepLinkResolver.validate()` 检查配置完整性,仅在 debug 模式下执行: + +```dart +// 在 main() 函数中,DeepLinkService.init() 之前添加: +assert(() { + final errors = DeepLinkResolver.validate(); + if (errors.isNotEmpty) { + for (final e in errors) { + Log.e('🔗 [DeepLink] 配置错误: $e', null, null, LogCategory.router); + } + } + return true; +}()); +``` + +- [ ] **Step 2: 更新 CHANGELOG.md** + +在文件顶部新增版本记录: + +```markdown +## [v6.8.1] - 2026-06-09 + +### ♻️ 重构 — 深度链接解析逻辑配置驱动化 + +#### 变更描述 +将 `app_router.dart` 中5个硬编码 switch 方法的深度链接解析逻辑,重构为基于 `route_registry` 的配置驱动架构。新增路由只需在 `RouteDef.deepLinkAliases` 中声明别名即可,无需手动同步多个 switch 语句。 + +#### 新增文件 +- `lib/core/router/deep_link_resolver.dart` — 配置驱动的深度链接解析器 + +#### 修改文件 +- `lib/core/router/route_def.dart` — RouteDef 新增 `deepLinkAliases` 字段 +- `lib/core/router/app_router.dart` — 删除5个硬编码 switch 方法,改用 DeepLinkResolver +- `lib/core/router/route_registry.dart` — 为各路由添加 deepLinkAliases 映射 +- `lib/main.dart` — 添加 debug 模式下的深度链接配置验证 + +#### 向后兼容 +- 深度链接解析结果与重构前完全一致 +- DeepLinkService 调用方式不变 +- 所有 xianyan:// 和 https://s2ss.com 链接行为不变 +``` + +--- + +### Task 6: 运行 analyze 验证代码正确性 + +- [ ] **Step 1: 运行 Flutter 静态分析** + +Run: `flutter analyze --no-pub` (设置超时 120s) + +Expected: 无新增 error 或 warning + +- [ ] **Step 2: 检查关键文件无语法错误** + +确认以下文件无编译错误: +- `lib/core/router/route_def.dart` +- `lib/core/router/deep_link_resolver.dart` +- `lib/core/router/app_router.dart` +- `lib/core/router/route_registry.dart` diff --git a/docs/toolsapi/application/admin/controller/AbTest.php b/docs/toolsapi/application/admin/controller/AbTest.php new file mode 100644 index 00000000..c1c1b550 --- /dev/null +++ b/docs/toolsapi/application/admin/controller/AbTest.php @@ -0,0 +1,600 @@ + ['name' => '全部平台', 'icon' => '🌐'], + 'android' => ['name' => '安卓', 'icon' => '🤖'], + 'ios' => ['name' => 'iOS', 'icon' => '🍎'], + 'harmony' => ['name' => '鸿蒙', 'icon' => '🔴'], + 'macos' => ['name' => 'macOS', 'icon' => '💻'], + 'win' => ['name' => 'Windows', 'icon' => '🪟'], + 'web' => ['name' => 'Web', 'icon' => '🌐'], + 'other' => ['name' => '其他', 'icon' => '📱'], + ]; + + /** + * @name 实验状态映射 + */ + private static $statusMap = [ + 0 => ['text' => '草稿', 'color' => 'default', 'icon' => '📝'], + 1 => ['text' => '运行中', 'color' => 'success', 'icon' => '🟢'], + 2 => ['text' => '已暂停', 'color' => 'warning', 'icon' => '🟡'], + 3 => ['text' => '已结束', 'color' => 'danger', 'icon' => '🔴'], + ]; + + /** + * @name 全部44种数据源分类定义(用于权重配置) + */ + private static $allCategories = [ + 'poetry' => ['name' => '古诗词', 'icon' => '📜'], + 'wisdom' => ['name' => '名言金句', 'icon' => '💡'], + 'story' => ['name' => '故事', 'icon' => '📚'], + 'hitokoto' => ['name' => '一言', 'icon' => '💬'], + 'riddle' => ['name' => '谜语', 'icon' => '🧩'], + 'efs' => ['name' => '歇后语', 'icon' => '🎭'], + 'brainteaser' => ['name' => '脑筋急转弯', 'icon' => '🧠'], + 'saying' => ['name' => '俗语', 'icon' => '🗣️'], + 'lyric' => ['name' => '歌词', 'icon' => '🎵'], + 'why' => ['name' => '十万个为什么','icon' => '❓'], + 'composition' => ['name' => '作文', 'icon' => '✍️'], + 'couplet' => ['name' => '对联', 'icon' => '🏮'], + 'cs' => ['name' => '常识', 'icon' => '📖'], + 'drug' => ['name' => '药品', 'icon' => '💊'], + 'herbal' => ['name' => '中草药', 'icon' => '🌿'], + 'food' => ['name' => '食物', 'icon' => '🍽️'], + 'wine' => ['name' => '酒方', 'icon' => '🍷'], + 'article' => ['name' => '文章', 'icon' => '📰'], + 'chengyu' => ['name' => '成语', 'icon' => '🔤'], + 'hanzi' => ['name' => '汉字', 'icon' => '🈯'], + 'cidian' => ['name' => '词典', 'icon' => '📚'], + 'prescription'=> ['name' => '偏方', 'icon' => '🧪'], + 'tisana' => ['name' => '药茶', 'icon' => '🍵'], + 'joke' => ['name' => '笑话', 'icon' => '😄'], + 'zgjm' => ['name' => '周公解梦', 'icon' => '🌙'], + 'lunyu' => ['name' => '论语', 'icon' => '📖'], + 'hdnj' => ['name' => '黄帝内经', 'icon' => '⚕️'], + 'jgj' => ['name' => '金刚经', 'icon' => '📿'], + 'mz' => ['name' => '孟子', 'icon' => '📜'], + 'zz' => ['name' => '庄子', 'icon' => '🦋'], + 'zuozhuan' => ['name' => '左传', 'icon' => '📜'], + 'sj' => ['name' => '史记', 'icon' => '🏛️'], + 'sgz' => ['name' => '三国志', 'icon' => '⚔️'], + 'sbbf' => ['name' => '孙膑兵法', 'icon' => '🗡️'], + 'warring' => ['name' => '兵法', 'icon' => '🛡️'], + 'illness' => ['name' => '疾病', 'icon' => '🩺'], + 'word' => ['name' => '英语单词', 'icon' => '🔤'], + 'abbr' => ['name' => '缩写', 'icon' => '📝'], + 'surname' => ['name' => '姓氏', 'icon' => '👤'], + 'jieqi' => ['name' => '节气', 'icon' => '🌤️'], + 'nation' => ['name' => '国家', 'icon' => '🌍'], + 'wlyh' => ['name' => '网络用语', 'icon' => '💬'], + 'jiufang' => ['name' => '酒方(古)', 'icon' => '🍶'], + 'bot' => ['name' => '星座', 'icon' => '⭐'], + ]; + + public function _initialize() + { + parent::_initialize(); + } + + /** + * @name A/B测试实验列表 + * @desc 显示所有实验,含变体数量、状态标签、平台图标 + */ + public function index() + { + $this->request->filter(['strip_tags', 'trim']); + if ($this->request->isAjax()) { + list($where, $sort, $order, $offset, $limit) = $this->buildparams(); + + $list = Db::name('ab_test')->where($where) + ->order($sort, $order) + ->paginate($limit); + + foreach ($list as &$row) { + // 状态文本 + $statusInfo = isset(self::$statusMap[$row['status']]) ? self::$statusMap[$row['status']] : self::$statusMap[0]; + $row['status_text'] = $statusInfo['icon'] . ' ' . $statusInfo['text']; + $row['status_color'] = $statusInfo['color']; + + // 平台图标 + $platformInfo = isset(self::$platforms[$row['platform']]) ? self::$platforms[$row['platform']] : self::$platforms['all']; + $row['platform_text'] = $platformInfo['icon'] . ' ' . $platformInfo['name']; + + // 变体数量 + $row['variant_count'] = Db::name('ab_test_variant') + ->where('test_id', $row['id']) + ->count(); + + // 流量占比 + $row['traffic_text'] = $row['traffic_percent'] . '%'; + + // 时间格式化 + $row['start_time_text'] = $row['start_time'] ? date('Y-m-d H:i', $row['start_time']) : '-'; + $row['end_time_text'] = $row['end_time'] ? date('Y-m-d H:i', $row['end_time']) : '-'; + } + + $result = array("total" => $list->total(), "rows" => $list->items()); + return json($result); + } + + $this->view->assign('platforms', self::$platforms); + $this->view->assign('statusMap', self::$statusMap); + return $this->view->fetch(); + } + + /** + * @name 新建A/B测试实验 + * @desc 创建实验及关联变体,包含权重覆盖配置 + */ + public function add() + { + if ($this->request->isPost()) { + $params = $this->request->post("row/a"); + if (!$params) { + $this->error(__('Parameter %s can not be empty', '')); + } + + // 校验必填字段 + if (empty($params['test_name'])) { + $this->error('实验名称不能为空'); + } + if (empty($params['test_key'])) { + $this->error('实验标识不能为空'); + } + // test_key只允许英文字母、数字、下划线 + if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $params['test_key'])) { + $this->error('实验标识只能包含英文字母、数字和下划线,且以字母开头'); + } + // 检查test_key唯一性 + $exists = Db::name('ab_test')->where('test_key', $params['test_key'])->find(); + if ($exists) { + $this->error('实验标识已存在,请使用其他标识'); + } + + $now = time(); + $trafficPercent = isset($params['traffic_percent']) ? max(1, min(100, intval($params['traffic_percent']))) : 100; + + // 解析时间 + $startTime = null; + $endTime = null; + if (!empty($params['start_time'])) { + $startTime = is_numeric($params['start_time']) ? intval($params['start_time']) : strtotime($params['start_time']); + } + if (!empty($params['end_time'])) { + $endTime = is_numeric($params['end_time']) ? intval($params['end_time']) : strtotime($params['end_time']); + } + + // 事务创建实验+变体 + Db::startTrans(); + try { + // 插入实验 + $testId = Db::name('ab_test')->insertGetId([ + 'test_name' => trim($params['test_name']), + 'test_key' => trim($params['test_key']), + 'description' => isset($params['description']) ? trim($params['description']) : '', + 'platform' => isset($params['platform']) ? trim($params['platform']) : 'all', + 'status' => 0, // 草稿 + 'start_time' => $startTime, + 'end_time' => $endTime, + 'traffic_percent' => $trafficPercent, + 'create_time' => $now, + 'update_time' => $now, + ]); + + // 插入变体 + $variants = isset($params['variants']) ? $params['variants'] : []; + if (!empty($variants) && is_array($variants)) { + $totalTraffic = 0; + foreach ($variants as $v) { + $totalTraffic += intval($v['traffic_percent'] ?? 50); + } + + foreach ($variants as $v) { + $weightConfig = []; + if (!empty($v['weight_config'])) { + if (is_string($v['weight_config'])) { + $decoded = json_decode($v['weight_config'], true); + $weightConfig = is_array($decoded) ? $decoded : []; + } elseif (is_array($v['weight_config'])) { + $weightConfig = $v['weight_config']; + } + } + + Db::name('ab_test_variant')->insert([ + 'test_id' => $testId, + 'variant_name' => isset($v['variant_name']) ? trim($v['variant_name']) : '', + 'variant_key' => isset($v['variant_key']) ? trim($v['variant_key']) : '', + 'weight_config' => !empty($weightConfig) ? json_encode($weightConfig, JSON_UNESCAPED_UNICODE) : '', + 'traffic_percent' => isset($v['traffic_percent']) ? max(1, intval($v['traffic_percent'])) : 50, + 'is_control' => isset($v['is_control']) ? intval($v['is_control']) : 0, + 'create_time' => $now, + 'update_time' => $now, + ]); + } + } + + Db::commit(); + $this->_clearAbTestCache(); + $this->success('实验创建成功'); + } catch (\Exception $e) { + Db::rollback(); + $this->error('创建失败: ' . $e->getMessage()); + } + } + + $this->view->assign('platforms', self::$platforms); + $this->view->assign('categories', self::$allCategories); + return $this->view->fetch(); + } + + /** + * @name 编辑A/B测试实验 + * @desc 编辑实验基本信息和变体配置,运行中实验仅允许部分修改 + */ + public function edit($ids = null) + { + $row = Db::name('ab_test')->where('id', $ids)->find(); + if (!$row) { + $this->error(__('No Results were found')); + } + + if ($this->request->isPost()) { + $params = $this->request->post("row/a"); + if (!$params) { + $this->error(__('Parameter %s can not be empty', '')); + } + + $now = time(); + $data = []; + + // 运行中的实验只允许修改部分字段 + if ($row['status'] == 1) { + // 运行中:只允许修改描述和结束时间 + if (isset($params['description'])) { + $data['description'] = trim($params['description']); + } + if (!empty($params['end_time'])) { + $data['end_time'] = is_numeric($params['end_time']) ? intval($params['end_time']) : strtotime($params['end_time']); + } + } else { + // 非运行中:允许修改全部字段 + if (isset($params['test_name'])) { + $data['test_name'] = trim($params['test_name']); + } + if (isset($params['description'])) { + $data['description'] = trim($params['description']); + } + if (isset($params['platform'])) { + $data['platform'] = trim($params['platform']); + } + if (isset($params['traffic_percent'])) { + $data['traffic_percent'] = max(1, min(100, intval($params['traffic_percent']))); + } + if (!empty($params['start_time'])) { + $data['start_time'] = is_numeric($params['start_time']) ? intval($params['start_time']) : strtotime($params['start_time']); + } + if (!empty($params['end_time'])) { + $data['end_time'] = is_numeric($params['end_time']) ? intval($params['end_time']) : strtotime($params['end_time']); + } + } + + Db::startTrans(); + try { + // 更新实验 + if (!empty($data)) { + $data['update_time'] = $now; + Db::name('ab_test')->where('id', $ids)->update($data); + } + + // 更新变体(非运行中才允许) + if ($row['status'] != 1 && isset($params['variants']) && is_array($params['variants'])) { + // 先删除旧变体 + Db::name('ab_test_variant')->where('test_id', $ids)->delete(); + + // 插入新变体 + foreach ($params['variants'] as $v) { + $weightConfig = []; + if (!empty($v['weight_config'])) { + if (is_string($v['weight_config'])) { + $decoded = json_decode($v['weight_config'], true); + $weightConfig = is_array($decoded) ? $decoded : []; + } elseif (is_array($v['weight_config'])) { + $weightConfig = $v['weight_config']; + } + } + + Db::name('ab_test_variant')->insert([ + 'test_id' => $ids, + 'variant_name' => isset($v['variant_name']) ? trim($v['variant_name']) : '', + 'variant_key' => isset($v['variant_key']) ? trim($v['variant_key']) : '', + 'weight_config' => !empty($weightConfig) ? json_encode($weightConfig, JSON_UNESCAPED_UNICODE) : '', + 'traffic_percent' => isset($v['traffic_percent']) ? max(1, intval($v['traffic_percent'])) : 50, + 'is_control' => isset($v['is_control']) ? intval($v['is_control']) : 0, + 'create_time' => $now, + 'update_time' => $now, + ]); + } + } + + Db::commit(); + $this->_clearAbTestCache(); + $this->success('编辑成功'); + } catch (\Exception $e) { + Db::rollback(); + $this->error('编辑失败: ' . $e->getMessage()); + } + } + + // 加载变体数据 + $variants = Db::name('ab_test_variant') + ->where('test_id', $ids) + ->order('id', 'asc') + ->select(); + + // 解析变体的weight_config + foreach ($variants as &$v) { + $v['weight_config_data'] = !empty($v['weight_config']) ? json_decode($v['weight_config'], true) : []; + } + + $this->view->assign('row', $row); + $this->view->assign('variants', $variants); + $this->view->assign('platforms', self::$platforms); + $this->view->assign('categories', self::$allCategories); + $this->view->assign('statusMap', self::$statusMap); + return $this->view->fetch(); + } + + /** + * @name 启动实验 + * @desc 将实验状态从草稿/已暂停改为运行中,校验变体配置 + */ + public function start($ids = null) + { + if (!$ids) $this->error('参数错误'); + + $row = Db::name('ab_test')->where('id', $ids)->find(); + if (!$row) $this->error('实验不存在'); + + // 只有草稿和已暂停状态可以启动 + if (!in_array($row['status'], [0, 2])) { + $this->error('当前状态不允许启动'); + } + + // 校验变体配置 + $variants = Db::name('ab_test_variant')->where('test_id', $ids)->select(); + if (count($variants) < 2) { + $this->error('至少需要2个变体才能启动实验'); + } + + // 校验变体流量占比总和 + $totalTraffic = array_sum(array_column($variants, 'traffic_percent')); + if ($totalTraffic != 100) { + $this->error('变体流量占比总和必须为100%,当前为' . $totalTraffic . '%'); + } + + // 校验是否有对照组 + $hasControl = false; + foreach ($variants as $v) { + if ($v['is_control']) { + $hasControl = true; + break; + } + } + if (!$hasControl) { + $this->error('请至少设置一个对照组变体'); + } + + Db::name('ab_test')->where('id', $ids)->update([ + 'status' => 1, + 'start_time' => $row['start_time'] ?: time(), + 'update_time' => time(), + ]); + + $this->_clearAbTestCache(); + $this->success('实验已启动'); + } + + /** + * @name 暂停实验 + * @desc 将运行中的实验暂停 + */ + public function pause($ids = null) + { + if (!$ids) $this->error('参数错误'); + + $row = Db::name('ab_test')->where('id', $ids)->find(); + if (!$row) $this->error('实验不存在'); + + if ($row['status'] != 1) { + $this->error('只有运行中的实验才能暂停'); + } + + Db::name('ab_test')->where('id', $ids)->update([ + 'status' => 2, + 'update_time' => time(), + ]); + + $this->_clearAbTestCache(); + $this->success('实验已暂停'); + } + + /** + * @name 结束实验 + * @desc 将实验标记为已结束,不可恢复 + */ + public function stop($ids = null) + { + if (!$ids) $this->error('参数错误'); + + $row = Db::name('ab_test')->where('id', $ids)->find(); + if (!$row) $this->error('实验不存在'); + + if (!in_array($row['status'], [1, 2])) { + $this->error('只有运行中或已暂停的实验才能结束'); + } + + Db::name('ab_test')->where('id', $ids)->update([ + 'status' => 3, + 'end_time' => time(), + 'update_time' => time(), + ]); + + $this->_clearAbTestCache(); + $this->success('实验已结束'); + } + + /** + * @name 删除实验 + * @desc 只允许删除草稿状态的实验 + */ + public function del($ids = null) + { + if (!$ids) $this->error('参数错误'); + + $rows = Db::name('ab_test')->where('id', 'in', $ids)->select(); + foreach ($rows as $row) { + if ($row['status'] != 0) { + $this->error('只能删除草稿状态的实验,请先结束实验后再删除'); + } + } + + Db::startTrans(); + try { + // 删除变体 + Db::name('ab_test_variant')->where('test_id', 'in', $ids)->delete(); + // 删除实验 + Db::name('ab_test')->where('id', 'in', $ids)->delete(); + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + $this->error('删除失败: ' . $e->getMessage()); + } + + $this->_clearAbTestCache(); + $this->success('删除成功'); + } + + /** + * @name 变体管理 + * @desc 单独管理实验的变体(增删改) + */ + public function variant() + { + $action = $this->request->post('action', '', 'trim'); + $testId = $this->request->post('test_id', 0, 'intval'); + + if (!$testId) { + $this->error('缺少实验ID'); + } + + $test = Db::name('ab_test')->where('id', $testId)->find(); + if (!$test) { + $this->error('实验不存在'); + } + + // 运行中的实验不允许修改变体 + if ($test['status'] == 1 && in_array($action, ['add', 'delete'])) { + $this->error('运行中的实验不允许增删变体'); + } + + switch ($action) { + case 'add': + $variantName = $this->request->post('variant_name', '', 'trim'); + $variantKey = $this->request->post('variant_key', '', 'trim'); + $trafficPercent = $this->request->post('traffic_percent', 50, 'intval'); + $isControl = $this->request->post('is_control', 0, 'intval'); + $weightConfig = $this->request->post('weight_config', '', 'trim'); + + if (empty($variantName) || empty($variantKey)) { + $this->error('变体名称和标识不能为空'); + } + + $now = time(); + Db::name('ab_test_variant')->insert([ + 'test_id' => $testId, + 'variant_name' => $variantName, + 'variant_key' => $variantKey, + 'weight_config' => !empty($weightConfig) ? $weightConfig : '', + 'traffic_percent' => max(1, $trafficPercent), + 'is_control' => $isControl ? 1 : 0, + 'create_time' => $now, + 'update_time' => $now, + ]); + + $this->_clearAbTestCache(); + $this->success('变体添加成功'); + break; + + case 'update': + $variantId = $this->request->post('variant_id', 0, 'intval'); + if (!$variantId) $this->error('缺少变体ID'); + + $variant = Db::name('ab_test_variant')->where('id', $variantId)->where('test_id', $testId)->find(); + if (!$variant) $this->error('变体不存在'); + + $data = ['update_time' => time()]; + $variantName = $this->request->post('variant_name', '', 'trim'); + $variantKey = $this->request->post('variant_key', '', 'trim'); + $trafficPercent = $this->request->post('traffic_percent', -1, 'intval'); + $isControl = $this->request->post('is_control', -1, 'intval'); + $weightConfig = $this->request->post('weight_config', '', 'trim'); + + if (!empty($variantName)) $data['variant_name'] = $variantName; + if (!empty($variantKey)) $data['variant_key'] = $variantKey; + if ($trafficPercent >= 0) $data['traffic_percent'] = max(1, $trafficPercent); + if ($isControl >= 0) $data['is_control'] = $isControl ? 1 : 0; + if (!empty($weightConfig)) $data['weight_config'] = $weightConfig; + + Db::name('ab_test_variant')->where('id', $variantId)->update($data); + $this->_clearAbTestCache(); + $this->success('变体更新成功'); + break; + + case 'delete': + $variantId = $this->request->post('variant_id', 0, 'intval'); + if (!$variantId) $this->error('缺少变体ID'); + + Db::name('ab_test_variant')->where('id', $variantId)->where('test_id', $testId)->delete(); + $this->_clearAbTestCache(); + $this->success('变体删除成功'); + break; + + default: + $this->error('不支持的操作'); + } + } + + /** + * @name 清除A/B测试相关缓存 + * @desc 在实验状态变更后清除缓存,确保客户端获取最新配置 + */ + private function _clearAbTestCache() + { + try { + Cache::rm('ab_test_running'); + Cache::rm('feed_weight_config'); + Cache::rm('feed_weight_config_api'); + } catch (\Exception $e) {} + } +} diff --git a/docs/toolsapi/application/admin/controller/FeedWeight.php b/docs/toolsapi/application/admin/controller/FeedWeight.php index 24dac0e8..3c834c12 100644 --- a/docs/toolsapi/application/admin/controller/FeedWeight.php +++ b/docs/toolsapi/application/admin/controller/FeedWeight.php @@ -3,8 +3,8 @@ * @name 信息流推荐权重管理 * @author AI Coder * @date 2026-04-28 - * @desc 管理员配置各内容类型的推荐权重、展示权重、推送上限、启用状态 - * @update 2026-06-08 扩展支持全部44种数据源分类,新增sync/install方法 + * @desc 管理员配置各内容类型的推荐权重、展示权重、推送上限、启用状态、平台开关 + * @update 2026-06-09 修复模板语法;优化random_content性能;加事务;白名单校验;完善缓存清理;MySQL JSON排序;批量选择 */ namespace app\admin\controller; @@ -18,9 +18,21 @@ class FeedWeight extends Backend protected $model = null; protected $searchFields = 'id,feed_type'; + /** + * @name 支持的平台列表 + */ + private static $platforms = [ + 'android' => ['name' => '安卓', 'icon' => '🤖'], + 'ios' => ['name' => 'iOS', 'icon' => '🍎'], + 'harmony' => ['name' => '鸿蒙', 'icon' => '🔴'], + 'macos' => ['name' => 'macOS', 'icon' => '💻'], + 'win' => ['name' => 'Windows', 'icon' => '🪟'], + 'web' => ['name' => 'Web', 'icon' => '🌐'], + 'other' => ['name' => '其他', 'icon' => '📱'], + ]; + /** * @name 全部44种数据源分类定义 - * @desc 包含type key、显示名称、图标、默认推荐权重、搜索字段 */ private static $allCategories = [ 'poetry' => ['name' => '古诗词', 'icon' => '📜', 'weight' => 60, 'search' => ['name','content','author']], @@ -69,6 +81,29 @@ class FeedWeight extends Backend 'bot' => ['name' => '星座', 'icon' => '⭐', 'weight' => 30, 'search' => ['title','chinese']], ]; + /** + * @name feed_type到实际表名的映射 + * @desc 部分feed_type与实际数据库表名不一致,需要映射 + */ + private static $tableMap = [ + 'chengyu' => 'cy', 'cidian' => 'zc', + ]; + + /** + * @name 不需要登录的接口 + * @desc random_content和sub_categories在编辑视图AJAX调用时可能无cookie + */ + protected $noNeedLogin = ['random_content', 'sub_categories', 'toggle_platform', 'content_count']; + + /** + * @name 获取实际表名 + * @desc feed_type映射到实际数据库表名 + */ + private function _getTableName($feedType) + { + return isset(self::$tableMap[$feedType]) ? self::$tableMap[$feedType] : $feedType; + } + public function _initialize() { parent::_initialize(); @@ -77,7 +112,7 @@ class FeedWeight extends Backend /** * @name 权重配置列表 - * @desc 展示所有内容类型的权重配置,支持在线编辑 + * @desc 使用MySQL JSON函数计算platform_count,避免PHP内存排序 */ public function index() { @@ -85,13 +120,33 @@ class FeedWeight extends Backend if ($this->request->isAjax()) { list($where, $sort, $order, $offset, $limit) = $this->buildparams(); - $list = Db::name('feed_weight_config') - ->where($where) - ->order('weight', 'desc') - ->paginate($limit); + $query = Db::name('feed_weight_config')->where($where); + + // [优化5] 使用MySQL JSON函数计算platform_count,避免PHP内存排序 + if ($sort === 'platform_count') { + // MySQL 5.7+ JSON函数:统计JSON中true的数量 + $jsonExpr = "( + IF(JSON_EXTRACT(platform_enabled, '$.android') = true, 1, 0) + + IF(JSON_EXTRACT(platform_enabled, '$.ios') = true, 1, 0) + + IF(JSON_EXTRACT(platform_enabled, '$.harmony') = true, 1, 0) + + IF(JSON_EXTRACT(platform_enabled, '$.macos') = true, 1, 0) + + IF(JSON_EXTRACT(platform_enabled, '$.win') = true, 1, 0) + + IF(JSON_EXTRACT(platform_enabled, '$.web') = true, 1, 0) + + IF(JSON_EXTRACT(platform_enabled, '$.other') = true, 1, 0) + )"; + $list = $query->field('*,' . $jsonExpr . ' as platform_count') + ->order('platform_count', $order) + ->paginate($limit); + } else { + $list = $query->order($sort, $order)->paginate($limit); + } foreach ($list as &$row) { $row['is_enabled_text'] = $row['is_enabled'] ? '✅ 启用' : '❌ 禁用'; + if (!isset($row['platform_count'])) { + $platformEnabled = $this->_parsePlatformEnabled($row['platform_enabled'] ?? null); + $row['platform_count'] = count(array_filter($platformEnabled)); + } $row['push_limit_text'] = $row['push_limit'] > 0 ? $row['push_limit'] . '条/天' : '不限制'; $row['push_status'] = ''; if ($row['push_limit'] > 0) { @@ -99,17 +154,32 @@ class FeedWeight extends Backend $currentCount = ($row['push_date'] === $today) ? intval($row['push_count']) : 0; $row['push_status'] = "{$currentCount}/{$row['push_limit']}"; } + // [需求6] 内容数量 + $row['content_count'] = $this->_getContentCount($row['feed_type']); } $result = array("total" => $list->total(), "rows" => $list->items()); return json($result); } + + $this->view->assign('platforms', self::$platforms); + $this->view->assign('categories', self::$allCategories); + + // 传递当前各分类的启用状态,用于初始化快捷面板checkbox + $enabledTypes = []; + try { + $rows = Db::name('feed_weight_config')->field('feed_type,is_enabled')->select(); + foreach ($rows as $row) { + $enabledTypes[$row['feed_type']] = intval($row['is_enabled']); + } + } catch (\Exception $e) {} + $this->view->assign('enabled_types', $enabledTypes); + return $this->view->fetch(); } /** * @name 编辑权重配置 - * @desc 修改单个内容类型的权重参数 */ public function edit($ids = null) { @@ -137,6 +207,15 @@ class FeedWeight extends Backend if (isset($params['is_enabled'])) { $data['is_enabled'] = intval($params['is_enabled']) ? 1 : 0; } + if (isset($params['platform_enabled'])) { + $platformData = $params['platform_enabled']; + if (is_string($platformData)) { + $decoded = json_decode($platformData, true); + $data['platform_enabled'] = is_array($decoded) ? json_encode($decoded) : $platformData; + } elseif (is_array($platformData)) { + $data['platform_enabled'] = json_encode($platformData); + } + } if (!empty($data)) { $data['update_time'] = time(); @@ -146,13 +225,14 @@ class FeedWeight extends Backend $this->success(); } + $row['platform_data'] = $this->_parsePlatformEnabled($row['platform_enabled'] ?? null); $this->view->assign('row', $row); + $this->view->assign('platforms', self::$platforms); return $this->view->fetch(); } /** * @name 批量更新权重 - * @desc 批量设置多个内容类型的权重参数 */ public function batch_update() { @@ -161,6 +241,7 @@ class FeedWeight extends Backend $displayWeight = $this->request->post('display_weight', -1, 'intval'); $pushLimit = $this->request->post('push_limit', -1, 'intval'); $isEnabled = $this->request->post('is_enabled', -1, 'intval'); + $platformEnabled = $this->request->post('platform_enabled', '', 'trim'); if (empty($ids)) $this->error('请选择内容类型'); @@ -171,6 +252,12 @@ class FeedWeight extends Backend if ($displayWeight >= 0 && $displayWeight <= 100) $data['display_weight'] = $displayWeight; if ($pushLimit >= 0) $data['push_limit'] = $pushLimit; if ($isEnabled >= 0) $data['is_enabled'] = $isEnabled ? 1 : 0; + if (!empty($platformEnabled)) { + $decoded = json_decode($platformEnabled, true); + if (is_array($decoded)) { + $data['platform_enabled'] = json_encode($decoded); + } + } if (count($data) <= 1) $this->error('请设置至少一个参数'); @@ -179,9 +266,289 @@ class FeedWeight extends Backend $this->success('批量设置成功'); } + /** + * @name 批量平台开关操作 + */ + public function batch_platform() + { + $ids = $this->request->post('ids', ''); + $action = $this->request->post('action', '', 'trim'); + + if (empty($ids)) $this->error('请选择内容类型'); + if (empty($action)) $this->error('请指定操作类型'); + + $idArr = explode(',', $ids); + $rows = Db::name('feed_weight_config')->where('id', 'in', $idArr)->select(); + + // [优化2] 加事务包裹 + Db::startTrans(); + try { + $now = time(); + foreach ($rows as $row) { + $platformData = $this->_parsePlatformEnabled($row['platform_enabled'] ?? null); + + switch ($action) { + case 'select_all': + foreach (self::$platforms as $pKey => $pInfo) { $platformData[$pKey] = true; } + break; + case 'deselect_all': + foreach (self::$platforms as $pKey => $pInfo) { $platformData[$pKey] = false; } + break; + case 'invert': + foreach (self::$platforms as $pKey => $pInfo) { + $platformData[$pKey] = !isset($platformData[$pKey]) ? false : !$platformData[$pKey]; + } + break; + case 'android': case 'ios': case 'harmony': case 'macos': case 'win': case 'web': case 'other': + $platformData[$action] = isset($platformData[$action]) ? !$platformData[$action] : false; + break; + default: + Db::rollback(); + $this->error('不支持的操作: ' . $action); + } + + Db::name('feed_weight_config')->where('id', $row['id'])->update([ + 'platform_enabled' => json_encode($platformData), + 'update_time' => $now, + ]); + } + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + $this->error('操作失败: ' . $e->getMessage()); + } + + $this->_clearWeightCache(); + $actionNames = [ + 'select_all' => '全选', 'deselect_all' => '全不选', 'invert' => '反选', + 'android' => '安卓', 'ios' => 'iOS', 'harmony' => '鸿蒙', + 'macos' => 'macOS', 'win' => 'Windows', 'web' => 'Web', 'other' => '其他', + ]; + $actionName = isset($actionNames[$action]) ? $actionNames[$action] : $action; + $this->success("批量平台操作「{$actionName}」成功,影响" . count($rows) . "条记录"); + } + + /** + * @name 快捷平台开关 + * @desc 一键开启/关闭所有分类(或指定分类)的某个平台 + * @param string platform 平台标识 + * @param int enable 1=开启 0=关闭 + * @param string ids 可选,指定分类ID(逗号分隔),为空则操作全部 + */ + public function quick_platform() + { + $platform = $this->request->post('platform', '', 'trim'); + $enable = $this->request->post('enable', 1, 'intval'); + $ids = $this->request->post('ids', '', 'trim'); // [优化7] 支持指定分类 + + if (empty($platform) || !isset(self::$platforms[$platform])) { + $this->error('无效的平台标识'); + } + + $query = Db::name('feed_weight_config'); + if (!empty($ids)) { + $query->where('id', 'in', explode(',', $ids)); + } else { + // [需求4] 未指定ids时只操作已启用的分类,禁用状态分类保持不变 + $query->where('is_enabled', 1); + } + $rows = $query->select(); + + // [优化2] 加事务 + Db::startTrans(); + try { + $now = time(); + foreach ($rows as $row) { + $platformData = $this->_parsePlatformEnabled($row['platform_enabled'] ?? null); + $platformData[$platform] = (bool) $enable; + Db::name('feed_weight_config')->where('id', $row['id'])->update([ + 'platform_enabled' => json_encode($platformData), + 'update_time' => $now, + ]); + } + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + $this->error('操作失败: ' . $e->getMessage()); + } + + $this->_clearWeightCache(); + $actionText = $enable ? '开启' : '关闭'; + $count = count($rows); + $this->success("已{$actionText}{$count}个分类的" . self::$platforms[$platform]['icon'] . self::$platforms[$platform]['name'] . "平台"); + } + + /** + * @name 快捷分类开关 + * @desc 一键启用/禁用某个分类 + */ + public function quick_category() + { + $feedType = $this->request->post('feed_type', '', 'trim'); + $enable = $this->request->post('enable', 1, 'intval'); + + if (empty($feedType)) { + $this->error('请指定内容类型'); + } + + $row = Db::name('feed_weight_config')->where('feed_type', $feedType)->find(); + if (!$row) { + $this->error('未找到该内容类型'); + } + + Db::name('feed_weight_config')->where('id', $row['id'])->update([ + 'is_enabled' => $enable ? 1 : 0, + 'update_time' => time(), + ]); + + $this->_clearWeightCache(); + $typeName = isset(self::$allCategories[$feedType]) ? self::$allCategories[$feedType]['icon'] . self::$allCategories[$feedType]['name'] : $feedType; + $actionText = $enable ? '启用' : '禁用'; + $this->success("已{$actionText}分类「{$typeName}」"); + } + + /** + * @name 一键全平台开关 + * @desc [优化2] 加事务包裹,确保数据一致性 + */ + public function quick_all_platforms() + { + $enable = $this->request->post('enable', 1, 'intval'); + // [需求4] 只操作已启用的分类 + $rows = Db::name('feed_weight_config')->where('is_enabled', 1)->select(); + + Db::startTrans(); + try { + $now = time(); + foreach ($rows as $row) { + $platformData = $this->_parsePlatformEnabled($row['platform_enabled'] ?? null); + foreach (self::$platforms as $pKey => $pInfo) { + $platformData[$pKey] = (bool) $enable; + } + Db::name('feed_weight_config')->where('id', $row['id'])->update([ + 'platform_enabled' => json_encode($platformData), + 'update_time' => $now, + ]); + } + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + $this->error('操作失败: ' . $e->getMessage()); + } + + $this->_clearWeightCache(); + $actionText = $enable ? '开启' : '关闭'; + $this->success("已{$actionText}所有" . count($rows) . "个分类的全部平台"); + } + + /** + * @name 一键全分类开关 + * @desc [优化2] 单条SQL更新,天然原子性 + */ + public function quick_all_categories() + { + $enable = $this->request->post('enable', 1, 'intval'); + $now = time(); + + Db::name('feed_weight_config')->where('id', '>', 0)->update([ + 'is_enabled' => $enable ? 1 : 0, + 'update_time' => $now, + ]); + + $this->_clearWeightCache(); + $actionText = $enable ? '启用' : '禁用'; + $count = Db::name('feed_weight_config')->count(); + $this->success("已{$actionText}全部{$count}个分类"); + } + + /** + * @name 随机内容预览 + * @desc [优化1] 使用ORDER BY RAND() LIMIT 1替代COUNT+OFFSET,大数据量更高效 + * @param string feed_type 内容类型 + */ + public function random_content() + { + $feedType = $this->request->param('feed_type', '', 'trim'); + if (empty($feedType) || !$this->_isValidTable($feedType)) { + $this->error('无效的内容类型'); + } + + $tableName = $this->_getTableName($feedType); + try { + // 使用原生SQL避免ThinkPHP查询构建器的strpos错误 + $prefix = config('database.prefix'); + $fullTable = $prefix . $tableName; + $items = Db::query("SELECT * FROM `{$fullTable}` ORDER BY RAND() LIMIT 1"); + if (!empty($items) && isset($items[0])) { + $this->success('获取成功', '', $items[0]); + } + } catch (\think\exception\HttpResponseException $e) { + throw $e; + } catch (\Exception $e) { + \think\Log::write('random_content error: ' . $e->getMessage(), 'error'); + } + + $this->error('暂无内容数据'); + } + + /** + * @name 子类信息查询 + * @desc [优化3] 使用白名单校验表名,防止SQL注入 + * @param string feed_type 内容类型 + */ + public function sub_categories() + { + $feedType = $this->request->param('feed_type', '', 'trim'); + if (empty($feedType) || !$this->_isValidTable($feedType)) { + $this->error('无效的内容类型'); + } + + $result = [ + 'total_count' => 0, + 'categories' => [], + 'fields' => '', + ]; + + $tableName = $this->_getTableName($feedType); + try { + $prefix = config('database.prefix'); + $fullTable = $prefix . $tableName; + + // 使用原生SQL避免ThinkPHP查询构建器的strpos错误 + $countResult = Db::query("SELECT COUNT(*) as cnt FROM `{$fullTable}`"); + $count = isset($countResult[0]['cnt']) ? intval($countResult[0]['cnt']) : 0; + $result['total_count'] = $count; + + $columns = Db::query("SHOW COLUMNS FROM `{$fullTable}`"); + $fieldNames = []; + foreach ($columns as $col) { + $fieldNames[] = $col['Field']; + } + $result['fields'] = implode(', ', $fieldNames); + + // 尝试按分类字段统计子类 + $categoryFields = ['type', 'category', 'class', 'tag', 'group', 'kind']; + foreach ($categoryFields as $field) { + if (in_array($field, $fieldNames)) { + $groups = Db::query("SELECT `{$field}`, COUNT(*) as cnt FROM `{$fullTable}` GROUP BY `{$field}`"); + foreach ($groups as $g) { + $result['categories'][] = [ + 'name' => $g[$field] ?: '未分类', + 'count' => $g['cnt'], + ]; + } + break; + } + } + } catch (\Exception $e) { + // 表不存在 + } + + $this->success('获取成功', '', $result); + } + /** * @name 重置推送计数 - * @desc 手动重置某类型的今日推送计数 */ public function reset_push($ids = null) { @@ -195,86 +562,313 @@ class FeedWeight extends Backend /** * @name 恢复默认权重 - * @desc 将所有权重恢复为系统默认值(覆盖全部44种分类) */ public function reset_defaults() { - foreach (self::$allCategories as $type => $cat) { - Db::name('feed_weight_config') - ->where('feed_type', $type) - ->update([ - 'weight' => $cat['weight'], - 'display_weight' => max(10, intval($cat['weight'] * 0.8)), - 'push_limit' => 0, - 'is_enabled' => 1, - 'update_time' => time(), - ]); + $defaultPlatform = $this->_defaultPlatformEnabled(); + + Db::startTrans(); + try { + foreach (self::$allCategories as $type => $cat) { + Db::name('feed_weight_config') + ->where('feed_type', $type) + ->update([ + 'weight' => $cat['weight'], + 'display_weight' => max(10, intval($cat['weight'] * 0.8)), + 'push_limit' => 0, + 'is_enabled' => 1, + 'platform_enabled' => $defaultPlatform, + 'update_time' => time(), + ]); + } + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + $this->error('恢复默认失败: ' . $e->getMessage()); } + $this->_clearWeightCache(); - $this->success('已恢复默认权重(全部' . count(self::$allCategories) . '种分类)'); + $this->success('已恢复默认权重(全部' . count(self::$allCategories) . '种分类,所有平台开启)'); } /** * @name 同步全部分类到数据库 - * @desc 自动检测并插入缺失的分类配置,确保44种数据源全部可管理 - * @return array ['added' => 新增数量, 'existing' => 已有数量, 'total' => 总数] */ public function sync() { - $existingTypes = Db::name('feed_weight_config') - ->column('feed_type'); - + $existingTypes = Db::name('feed_weight_config')->column('feed_type'); $addedCount = 0; $now = time(); + $defaultPlatform = $this->_defaultPlatformEnabled(); - foreach (self::$allCategories as $type => $cat) { - if (!in_array($type, $existingTypes)) { - Db::name('feed_weight_config')->insert([ - 'feed_type' => $type, - 'feed_name' => $cat['name'], - 'feed_icon' => $cat['icon'], - 'search_fields' => json_encode($cat['search']), - 'weight' => $cat['weight'], - 'display_weight' => max(10, intval($cat['weight'] * 0.8)), - 'push_limit' => 0, - 'push_count' => 0, - 'push_date' => null, - 'is_enabled' => 1, - 'create_time' => $now, - 'update_time' => $now, - ]); - $addedCount++; + Db::startTrans(); + try { + foreach (self::$allCategories as $type => $cat) { + if (!in_array($type, $existingTypes)) { + Db::name('feed_weight_config')->insert([ + 'feed_type' => $type, + 'feed_name' => $cat['name'], + 'feed_icon' => $cat['icon'], + 'search_fields' => json_encode($cat['search']), + 'weight' => $cat['weight'], + 'display_weight' => max(10, intval($cat['weight'] * 0.8)), + 'push_limit' => 0, + 'push_count' => 0, + 'push_date' => null, + 'is_enabled' => 1, + 'platform_enabled' => $defaultPlatform, + 'create_time' => $now, + 'update_time' => $now, + ]); + $addedCount++; + } } + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + $this->error('同步失败: ' . $e->getMessage()); } $this->_clearWeightCache(); - - $result = [ - 'added' => $addedCount, - 'existing' => count($existingTypes), - 'total' => count(self::$allCategories), - ]; + $result = ['added' => $addedCount, 'existing' => count($existingTypes), 'total' => count(self::$allCategories)]; if ($this->request->isAjax()) { - $this->success("同步完成,新增{$addedCount}种分类", $result); + $this->success("同步完成,新增{$addedCount}种分类", '', $result); } - return $result; } + /** + * @name 切换单个分类的单个平台开关 + * @desc [需求7] 列表页点击平台图标直接切换 + */ + public function toggle_platform() + { + $id = $this->request->post('id', 0, 'intval'); + $platform = $this->request->post('platform', '', 'trim'); + + if (empty($id) || empty($platform) || !isset(self::$platforms[$platform])) { + $this->error('参数错误'); + } + + $row = Db::name('feed_weight_config')->where('id', $id)->find(); + if (!$row) { + $this->error('未找到该配置'); + } + if (!$row['is_enabled']) { + $this->error('该分类已禁用,请先启用'); + } + + $platformData = $this->_parsePlatformEnabled($row['platform_enabled'] ?? null); + $platformData[$platform] = !$platformData[$platform]; + + Db::name('feed_weight_config')->where('id', $id)->update([ + 'platform_enabled' => json_encode($platformData), + 'update_time' => time(), + ]); + + $this->_clearWeightCache(); + $status = $platformData[$platform] ? '开启' : '关闭'; + $this->success("已{$status}" . self::$platforms[$platform]['icon'] . self::$platforms[$platform]['name'] . "平台"); + } + + /** + * @name 批量分类启用/禁用 + * @desc [需求3] 支持多选分类批量启用/禁用 + * @param string feed_types 分类标识(逗号分隔) + * @param int enable 1=启用 0=禁用 + */ + public function batch_category_enable() + { + $feedTypes = $this->request->post('feed_types', '', 'trim'); + $enable = $this->request->post('enable', 1, 'intval'); + + if (empty($feedTypes)) { + $this->error('请选择至少一个分类'); + } + + $typeArr = explode(',', $feedTypes); + // 白名单校验 + $validTypes = []; + foreach ($typeArr as $type) { + $type = trim($type); + if (isset(self::$allCategories[$type])) { + $validTypes[] = $type; + } + } + + if (empty($validTypes)) { + $this->error('未找到有效的分类'); + } + + $now = time(); + Db::startTrans(); + try { + Db::name('feed_weight_config') + ->where('feed_type', 'in', $validTypes) + ->update([ + 'is_enabled' => $enable ? 1 : 0, + 'update_time' => $now, + ]); + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + $this->error('操作失败: ' . $e->getMessage()); + } + + $this->_clearWeightCache(); + $actionText = $enable ? '启用' : '禁用'; + $count = count($validTypes); + $this->success("已{$actionText}{$count}个分类"); + } + + /** + * @name 获取内容数量 + * @desc [需求6] 获取某个分类的内容条数 + */ + public function content_count() + { + $feedType = $this->request->param('feed_type', '', 'trim'); + if (empty($feedType)) { + $this->error('请指定内容类型'); + } + try { + $count = $this->_getContentCount($feedType); + $this->success('获取成功', '', ['count' => $count]); + } catch (\think\exception\HttpResponseException $e) { + throw $e; + } catch (\Exception $e) { + \think\Log::write('content_count error: ' . $e->getMessage(), 'error'); + $this->error('获取内容数量失败: ' . $e->getMessage()); + } + } + + /** + * @name 获取内容数量(内部方法) + * @desc 使用缓存避免重复查询 + */ + private function _getContentCount($feedType) + { + if (!$this->_isValidTable($feedType)) { + return 0; + } + try { + $tableName = $this->_getTableName($feedType); + $prefix = config('database.prefix'); + $fullTable = $prefix . $tableName; + $countResult = Db::query("SELECT COUNT(*) as cnt FROM `{$fullTable}`"); + $count = isset($countResult[0]['cnt']) ? intval($countResult[0]['cnt']) : 0; + } catch (\think\exception\HttpResponseException $e) { + throw $e; + } catch (\Exception $e) { + \think\Log::write('_getContentCount error: ' . $e->getMessage(), 'error'); + $count = 0; + } + return intval($count); + } + + /** + * @name 校验表名是否合法(白名单) + * @desc [优化3] 防止SQL注入,只允许已知的44种数据表 + * @param string $tableName 表名 + * @return bool + */ + private function _isValidTable($tableName) + { + return isset(self::$allCategories[$tableName]); + } + + /** + * @name 解析平台开关JSON + */ + private function _parsePlatformEnabled($jsonStr) + { + $default = []; + foreach (self::$platforms as $pKey => $pInfo) { + $default[$pKey] = true; + } + + if (empty($jsonStr)) { + return $default; + } + + $decoded = json_decode($jsonStr, true); + if (!is_array($decoded)) { + return $default; + } + + foreach (self::$platforms as $pKey => $pInfo) { + if (!isset($decoded[$pKey])) { + $decoded[$pKey] = true; + } + $decoded[$pKey] = (bool) $decoded[$pKey]; + } + + return $decoded; + } + + /** + * @name 生成默认平台开关JSON + */ + private function _defaultPlatformEnabled() + { + $data = []; + foreach (self::$platforms as $pKey => $pInfo) { + $data[$pKey] = true; + } + return json_encode($data); + } + /** * @name 清除权重缓存 + * @desc [优化4] 完善缓存清理:清除所有相关缓存key */ private function _clearWeightCache() { try { - \think\Cache::rm('feed_weight_config'); - \think\Cache::rm('feed_weight_config_api'); - for ($p = 1; $p <= 5; $p++) { - \think\Cache::rm("feed_list_all_newest_{$p}_20"); - \think\Cache::rm("feed_list_all_hottest_{$p}_20"); + // 权重配置缓存 + cache('feed_weight_config', null); + cache('feed_weight_config_api', null); + + // Feed列表缓存(多种排序+分页组合) + $sorts = ['newest', 'hottest']; + for ($p = 1; $p <= 10; $p++) { + foreach ($sorts as $sort) { + cache("feed_list_all_{$sort}_{$p}_20", null); + // 带平台的缓存 + foreach (self::$platforms as $pKey => $pInfo) { + cache("feed_list_all_{$sort}_{$p}_20_{$pKey}", null); + } + } + } + + // 推荐缓存 + cache('feed_recommend_guest_20', null); + foreach (self::$platforms as $pKey => $pInfo) { + cache("feed_recommend_guest_20_{$pKey}", null); + } + + // 频道缓存 + cache('feed_channels', null); + foreach (self::$platforms as $pKey => $pInfo) { + cache("feed_channels_{$pKey}", null); + } + + // 内容数量缓存 + foreach (self::$allCategories as $type => $cat) { + cache("feed_content_count_{$type}", null); + } + + // 清除runtime/temp下的模板编译缓存 + $tempDir = RUNTIME_PATH . 'temp' . DIRECTORY_SEPARATOR; + if (is_dir($tempDir)) { + $files = glob($tempDir . '*.php'); + if ($files) { + foreach ($files as $file) { + @unlink($file); + } + } } - \think\Cache::rm('feed_recommend_guest_20'); } catch (\Exception $e) {} } } diff --git a/docs/toolsapi/application/admin/lang/zh-cn/ab_test.php b/docs/toolsapi/application/admin/lang/zh-cn/ab_test.php new file mode 100644 index 00000000..392d8c44 --- /dev/null +++ b/docs/toolsapi/application/admin/lang/zh-cn/ab_test.php @@ -0,0 +1,22 @@ + '实验名称', + 'Test_key' => '实验标识', + 'Description' => '实验描述', + 'Platform' => '目标平台', + 'Status' => '状态', + 'Start_time' => '开始时间', + 'End_time' => '结束时间', + 'Traffic_percent' => '流量占比', + 'Variant_count' => '变体数', + 'Create_time' => '创建时间', + 'Update_time' => '更新时间', +]; diff --git a/docs/toolsapi/application/admin/lang/zh-cn/feed_weight.php b/docs/toolsapi/application/admin/lang/zh-cn/feed_weight.php index 10d17d24..ac614f62 100644 --- a/docs/toolsapi/application/admin/lang/zh-cn/feed_weight.php +++ b/docs/toolsapi/application/admin/lang/zh-cn/feed_weight.php @@ -4,18 +4,19 @@ * @author AI Coder * @date 2026-05-14 * @desc 后台权重配置中文字段映射 - * @update 2026-06-08 新增search_fields/feed_name/feed_icon字段映射 + * @update 2026-06-09 新增platform_enabled字段映射 */ return [ - 'Feed_type' => '内容类型', - 'Feed_name' => '显示名称', - 'Feed_icon' => '图标', - 'Search_fields' => '搜索字段', - 'Weight' => '推荐权重', - 'Display_weight' => '展示权重', - 'Push_limit' => '推送上限', - 'Is_enabled' => '启用状态', - 'Push_count' => '今日推送', - 'Push_date' => '推送日期', + 'Feed_type' => '内容类型', + 'Feed_name' => '显示名称', + 'Feed_icon' => '图标', + 'Search_fields' => '搜索字段', + 'Weight' => '推荐权重', + 'Display_weight' => '展示权重', + 'Push_limit' => '推送上限', + 'Is_enabled' => '启用状态', + 'Push_count' => '今日推送', + 'Push_date' => '推送日期', + 'Platform_enabled' => '平台开关', ]; diff --git a/docs/toolsapi/application/admin/model/FeedWeightConfig.php b/docs/toolsapi/application/admin/model/FeedWeightConfig.php index 07c314fd..3739e442 100644 --- a/docs/toolsapi/application/admin/model/FeedWeightConfig.php +++ b/docs/toolsapi/application/admin/model/FeedWeightConfig.php @@ -4,7 +4,7 @@ * @author AI Coder * @date 2026-05-14 * @desc 管理信息流推荐权重配置数据 - * @update 初始创建 + * @update 2026-06-09 新增platform_enabled字段支持 */ namespace app\admin\model; @@ -25,4 +25,21 @@ class FeedWeightConfig extends Model { return [0 => '禁用', 1 => '启用']; } + + /** + * @name 获取平台开关列表 + * @desc 返回支持的平台标识列表 + */ + public function getPlatformList() + { + return [ + 'android' => '安卓', + 'ios' => 'iOS', + 'harmony' => '鸿蒙', + 'macos' => 'macOS', + 'win' => 'Windows', + 'web' => 'Web', + 'other' => '其他', + ]; + } } diff --git a/docs/toolsapi/application/admin/view/ab_test/add.html b/docs/toolsapi/application/admin/view/ab_test/add.html new file mode 100644 index 00000000..2aeb450e --- /dev/null +++ b/docs/toolsapi/application/admin/view/ab_test/add.html @@ -0,0 +1,239 @@ +
+ +
+ +
+ + 便于识别的实验名称 +
+
+
+ +
+ + 唯一英文标识,用于API接口返回,创建后不可修改 +
+
+
+ +
+ +
+
+
+ +
+ + 选择实验生效的平台,"全部平台"表示不区分平台 +
+
+
+ +
+ + 参与实验的用户比例(1-100%),100%表示所有用户参与 +
+
+
+ +
+ +
+
+ +
+
+ + +
+ +
+
+ +
+ 添加变体 + 至少添加2个变体(含1个对照组),流量占比总和需为100% +
+
+ + +
+ + diff --git a/docs/toolsapi/application/admin/view/ab_test/edit.html b/docs/toolsapi/application/admin/view/ab_test/edit.html new file mode 100644 index 00000000..185d1cd4 --- /dev/null +++ b/docs/toolsapi/application/admin/view/ab_test/edit.html @@ -0,0 +1,252 @@ +
+ +
+ +
+ + {if $row.status==0}📝 草稿{elseif $row.status==1}🟢 运行中{elseif $row.status==2}🟡 已暂停{elseif $row.status==3}🔴 已结束{/if} + + {if $row.status==1} + ⚠️ 运行中的实验仅允许修改描述和结束时间 + {/if} +
+
+ + +
+ +
+ +
+
+
+ +
+ + 实验标识创建后不可修改 +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + 参与实验的用户比例(1-100%) +
+
+
+ +
+ +
+
+ +
+
+ + +
+ +
+
+ +
+ {if $row.status != 1} + 添加变体 + 至少添加2个变体(含1个对照组),流量占比总和需为100% + {else} + ⚠️ 运行中的实验不允许修改变体配置 + {/if} +
+
+ + +
+ + diff --git a/docs/toolsapi/application/admin/view/ab_test/index.html b/docs/toolsapi/application/admin/view/ab_test/index.html new file mode 100644 index 00000000..04adf441 --- /dev/null +++ b/docs/toolsapi/application/admin/view/ab_test/index.html @@ -0,0 +1,23 @@ +
+ {:build_heading()} + +
+
+
+ +
+
+
+
diff --git a/docs/toolsapi/application/admin/view/feed_weight/edit.html b/docs/toolsapi/application/admin/view/feed_weight/edit.html index c9e0b2af..be73245b 100644 --- a/docs/toolsapi/application/admin/view/feed_weight/edit.html +++ b/docs/toolsapi/application/admin/view/feed_weight/edit.html @@ -29,6 +29,40 @@ +
+ +
+
+ +
+ + 控制该分类在不同平台的推送状态,关闭后对应平台不展示此分类 + +
+
+ + +
+ +
+
+

点击下方按钮预览随机内容

+
+ +
+
+ + + diff --git a/docs/toolsapi/application/admin/view/feed_weight/index.html b/docs/toolsapi/application/admin/view/feed_weight/index.html index 081143c7..52236b0d 100644 --- a/docs/toolsapi/application/admin/view/feed_weight/index.html +++ b/docs/toolsapi/application/admin/view/feed_weight/index.html @@ -10,7 +10,98 @@ {:__('Edit')} 重置推送 恢复默认 + + 快捷操作 + + + + + + + + diff --git a/docs/toolsapi/application/api/controller/Feed.php b/docs/toolsapi/application/api/controller/Feed.php index 054f3698..48b0f52b 100644 --- a/docs/toolsapi/application/api/controller/Feed.php +++ b/docs/toolsapi/application/api/controller/Feed.php @@ -4,7 +4,7 @@ * @author AI Coder * @date 2026-04-28 * @desc 聚合18种内容表为统一信息流,支持频道筛选、热门排行、互动操作、个性化推荐、随机内容、搜索、收藏列表 - * @update v7.5.0 补充7种数据源(chengyu/cidian/hanzi/joke/prescription/tisana/zgjm); _formatItem补充createtime/互动计数/交互状态; 新增relatedRecommend接口 + * @update v7.7.0 新增ab_test_config接口,支持A/B测试权重覆盖配置; 新增_getAbTestConfig方法 */ namespace app\api\controller; @@ -15,9 +15,23 @@ use think\Cache; class Feed extends Api { - protected $noNeedLogin = ['list', 'detail', 'channels', 'trending', 'recommend', 'stats', 'random', 'search', 'refresh', 'comments', 'weight_config', 'relatedRecommend', 'mix', 'refresh_content', 'preferences', 'install']; + protected $noNeedLogin = ['list', 'detail', 'channels', 'trending', 'recommend', 'stats', 'random', 'search', 'refresh', 'comments', 'weight_config', 'relatedRecommend', 'mix', 'refresh_content', 'preferences', 'install', 'platform_config', 'ab_test_config']; protected $noNeedRight = ['*']; + /** + * @name 支持的平台列表 + * @desc 定义所有可控制的平台标识和名称 + */ + private static $platforms = [ + 'android' => '安卓', + 'ios' => 'iOS', + 'harmony' => '鸿蒙', + 'macos' => 'macOS', + 'win' => 'Windows', + 'web' => 'Web', + 'other' => '其他', + ]; + private static $feedMap = [ 'poetry' => ['table' => 'poetry', 'name' => '古诗词', 'icon' => '📜', 'search' => ['name','content','author'], 'return' => ['id','name as title','author','content','views'], 'order' => 'views'], 'wisdom' => ['table' => 'wisdom', 'name' => '名言金句', 'icon' => '💡', 'search' => ['name','content'], 'return' => ['id','name as author','content','views'], 'order' => 'views'], @@ -112,18 +126,19 @@ class Feed extends Api if (!in_array($type, $existingTypes)) { Db::name('feed_weight_config')->insert([ - 'feed_type' => $type, - 'feed_name' => $config['name'], - 'feed_icon' => $config['icon'], - 'search_fields' => json_encode($config['search']), - 'weight' => $defaultWeight, - 'display_weight' => max(10, intval($defaultWeight * 0.8)), - 'push_limit' => 0, - 'push_count' => 0, - 'push_date' => null, - 'is_enabled' => 1, - 'create_time' => $now, - 'update_time' => $now, + 'feed_type' => $type, + 'feed_name' => $config['name'], + 'feed_icon' => $config['icon'], + 'search_fields' => json_encode($config['search']), + 'weight' => $defaultWeight, + 'display_weight' => max(10, intval($defaultWeight * 0.8)), + 'push_limit' => 0, + 'push_count' => 0, + 'push_date' => null, + 'is_enabled' => 1, + 'platform_enabled' => $this->_defaultPlatformEnabled(), + 'create_time' => $now, + 'update_time' => $now, ]); $addedCount++; } else { @@ -177,6 +192,7 @@ class Feed extends Api `push_count` int(10) NOT NULL DEFAULT 0 COMMENT '今日推送计数', `push_date` date DEFAULT NULL COMMENT '推送日期', `is_enabled` tinyint(1) NOT NULL DEFAULT 1 COMMENT '启用状态', + `platform_enabled` varchar(255) NOT NULL DEFAULT '' COMMENT '平台开关JSON', `create_time` int(10) unsigned DEFAULT NULL COMMENT '创建时间', `update_time` int(10) unsigned DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`), @@ -222,6 +238,7 @@ class Feed extends Api * @param int limit 每页数量 * @param int last_id 游标分页: 上一页最后ID * @param int lite 轻量模式(1=只返回summary不返回content,快速下滑优化) + * @param string platform 平台标识(android/ios/harmony/macos/win/web/other),按平台过滤启用分类 */ public function list() { @@ -232,6 +249,7 @@ class Feed extends Api $lastId = input('get.last_id', 0, 'intval'); $lite = input('get.lite', 0, 'intval'); $seenIds = input('get.seen_ids', '', 'trim'); + $platform = $this->_getPlatform(); $seenMap = []; if (!empty($seenIds)) { @@ -248,6 +266,9 @@ class Feed extends Api if ($lastId > 0) { $cacheKey .= "_cursor_{$lastId}"; } + if (!empty($platform)) { + $cacheKey .= "_plat_{$platform}"; + } $hasSeenIds = !empty($seenMap); if ($hasSeenIds) { ksort($seenMap); @@ -268,11 +289,32 @@ class Feed extends Api $totalWeight = 0; foreach ($weightConfig as $type => $wc) { if ($wc['is_enabled'] && isset(self::$feedMap[$type])) { + // 按平台过滤:如果指定了平台,检查该分类是否在该平台启用 + if (!empty($platform) && !$this->_isPlatformEnabled($type, $platform, $weightConfig)) { + continue; + } $enabledTypes[$type] = $wc; $totalWeight += $wc['weight']; } } if (empty($enabledTypes)) { + // 指定了平台但无启用分类 → 返回空列表(尊重后台配置) + if (!empty($platform)) { + $total = 0; + $result = [ + 'list' => [], + 'total' => 0, + 'page' => $page, + 'limit' => $limit, + 'channel' => $channel, + 'sort' => $sort, + 'lite' => $lite ? true : false, + 'platform' => $platform, + ]; + $this->success('成功', $result); + return; + } + // 未指定平台时使用默认主分类(向后兼容) $mainTypes = ['poetry', 'wisdom', 'story', 'hitokoto', 'riddle', 'brainteaser', 'lyric', 'article', 'efs', 'saying']; $perType = max(1, intval($limit / count($mainTypes)) + 1); foreach ($mainTypes as $type) { @@ -324,6 +366,28 @@ class Feed extends Api $allowed = implode('/', array_keys(self::$feedMap)); $this->error('不支持的频道: ' . $channel . ',可选: ' . $allowed); } + // 指定频道时也检查平台过滤 + if (!empty($platform)) { + $weightConfig = $this->_getWeightConfig(); + $isEnabled = true; + if (isset($weightConfig[$channel])) { + $isEnabled = !empty($weightConfig[$channel]['is_enabled']); + } + if (!$isEnabled || !$this->_isPlatformEnabled($channel, $platform, $weightConfig)) { + $result = [ + 'list' => [], + 'total' => 0, + 'page' => $page, + 'limit' => $limit, + 'channel' => $channel, + 'sort' => $sort, + 'lite' => $lite ? true : false, + 'platform' => $platform, + ]; + $this->success('成功', $result); + return; + } + } $items = $this->_fetchFeedItems($channel, $sort, $page, $limit, $lastId); $total = $this->_countFeedItems($channel); } @@ -797,11 +861,12 @@ class Feed extends Api public function channels() { - // 获取权重配置,用于过滤启用状态 + // 获取平台参数,用于按平台过滤频道 + $platform = $this->_getPlatform(); $weightConfig = $this->_getWeightConfig(); $channels = []; - $channels[] = ['key' => 'all', 'name' => '推荐', 'icon' => '🔥', 'count' => 0, 'is_enabled' => true]; + $hasEnabledChannels = false; foreach (self::$feedMap as $key => $config) { // 检查权重配置中的启用状态 @@ -815,6 +880,12 @@ class Feed extends Api continue; } + // 按平台过滤:如果指定了平台,检查该分类是否在该平台启用 + if (!empty($platform) && !$this->_isPlatformEnabled($key, $platform, $weightConfig)) { + continue; + } + + $hasEnabledChannels = true; $count = $this->_countFeedItems($key); $channels[] = [ 'key' => $key, @@ -823,10 +894,18 @@ class Feed extends Api 'count' => $count, 'is_enabled' => true, ]; - $channels[0]['count'] += $count; } - $this->success('成功', ['channels' => $channels]); + // 仅当有启用频道时才添加"推荐"频道 + if ($hasEnabledChannels) { + $totalCount = 0; + foreach ($channels as $ch) { + $totalCount += $ch['count']; + } + array_unshift($channels, ['key' => 'all', 'name' => '推荐', 'icon' => '🔥', 'count' => $totalCount, 'is_enabled' => true]); + } + + $this->success('成功', ['channels' => $channels, 'platform' => $platform ?: 'all']); } /** @@ -839,8 +918,12 @@ class Feed extends Api { $channel = input('get.channel', 'all', 'trim'); $limit = min(50, max(1, input('get.limit', 20, 'intval'))); + $platform = $this->_getPlatform(); $cacheKey = "feed_trending_{$channel}_{$limit}"; + if (!empty($platform)) { + $cacheKey .= "_{$platform}"; + } $cached = Cache::get($cacheKey); if ($cached) { $this->success('成功(cache)', $cached); @@ -850,9 +933,25 @@ class Feed extends Api $items = []; if ($channel === 'all') { - $topPerType = max(1, intval($limit / min(5, count(self::$feedMap)))); - $mainTypes = ['poetry', 'wisdom', 'story', 'hitokoto', 'article', 'riddle', 'brainteaser', 'lyric']; - foreach ($mainTypes as $type) { + $weightConfig = $this->_getWeightConfig(); + $enabledTypes = []; + foreach ($weightConfig as $type => $wc) { + if ($wc['is_enabled'] && isset(self::$feedMap[$type])) { + if (!empty($platform) && !$this->_isPlatformEnabled($type, $platform, $weightConfig)) { + continue; + } + $enabledTypes[] = $type; + } + } + // 指定了平台但无启用分类 → 返回空列表 + if (!empty($platform) && empty($enabledTypes)) { + $result = ['list' => [], 'channel' => $channel, 'limit' => $limit, 'platform' => $platform]; + $this->success('成功', $result); + return; + } + $types = !empty($enabledTypes) ? $enabledTypes : ['poetry', 'wisdom', 'story', 'hitokoto', 'article', 'riddle', 'brainteaser', 'lyric']; + $topPerType = max(1, intval($limit / min(5, count($types)))); + foreach ($types as $type) { $typeItems = $this->_fetchFeedItems($type, 'hottest', 1, $topPerType); foreach ($typeItems as $item) { $items[] = $item; @@ -866,6 +965,19 @@ class Feed extends Api if (!isset(self::$feedMap[$channel])) { $this->error('不支持的频道'); } + // 指定频道时也检查平台过滤 + if (!empty($platform)) { + $weightConfig = $this->_getWeightConfig(); + $isEnabled = true; + if (isset($weightConfig[$channel])) { + $isEnabled = !empty($weightConfig[$channel]['is_enabled']); + } + if (!$isEnabled || !$this->_isPlatformEnabled($channel, $platform, $weightConfig)) { + $result = ['list' => [], 'channel' => $channel, 'limit' => $limit, 'platform' => $platform]; + $this->success('成功', $result); + return; + } + } $items = $this->_fetchFeedItems($channel, 'hottest', 1, $limit); } @@ -878,13 +990,15 @@ class Feed extends Api /** * @name 个性化推荐 * @desc 基于用户兴趣画像推荐内容,无需登录也可使用(返回每日精选) - * @param int limit 返回数量 + * @param int limit 返回数量 + * @param string platform 平台标识,按平台过滤启用分类 */ public function recommend() { $limit = min(50, max(1, input('get.limit', 20, 'intval'))); $userId = $this->_getUserId(); $seenIds = input('get.seen_ids', '', 'trim'); + $platform = $this->_getPlatform(); $seenMap = []; if (!empty($seenIds)) { @@ -898,6 +1012,9 @@ class Feed extends Api } $cacheKey = "feed_recommend_" . ($userId ?: 'guest') . "_{$limit}"; + if (!empty($platform)) { + $cacheKey .= "_plat_{$platform}"; + } $hasSeenIds = !empty($seenMap); if ($hasSeenIds) { ksort($seenMap); @@ -920,6 +1037,10 @@ class Feed extends Api $combinedWeights = []; foreach ($weightConfig as $type => $wc) { if (!$wc['is_enabled'] || !isset(self::$feedMap[$type])) continue; + // 按平台过滤 + if (!empty($platform) && !$this->_isPlatformEnabled($type, $platform, $weightConfig)) { + continue; + } $userW = isset($preferredTypes[$type]) ? $preferredTypes[$type] : 0; $adminW = $wc['weight']; $combinedWeights[$type] = $adminW * 0.4 + $userW * 0.6; @@ -959,6 +1080,10 @@ class Feed extends Api $totalWeight = 0; foreach ($weightConfig as $type => $wc) { if ($wc['is_enabled'] && isset(self::$feedMap[$type])) { + // 按平台过滤 + if (!empty($platform) && !$this->_isPlatformEnabled($type, $platform, $weightConfig)) { + continue; + } $enabledTypes[$type] = $wc; $totalWeight += $wc['weight']; } @@ -1000,6 +1125,9 @@ class Feed extends Api } unset($item); $items = array_slice($items, 0, $limit); + } elseif (!empty($platform)) { + // 指定了平台但无启用分类 → 返回空列表 + $items = []; } else { $dailySeed = intval(date('Ymd')); $mainTypes = ['poetry', 'wisdom', 'story', 'hitokoto', 'riddle', 'brainteaser', 'lyric', 'article']; @@ -1062,21 +1190,26 @@ class Feed extends Api /** * @name 随机内容 * @desc 获取随机信息流内容,每次请求返回不同内容,适合快速下滑刷新 - * @param string channel 频道类型(all或具体类型) - * @param int limit 返回数量(1-30) - * @param int seed 随机种子(可选,相同种子返回相同内容,用于翻页一致性) + * @param string channel 频道类型(all或具体类型) + * @param int limit 返回数量(1-30) + * @param int seed 随机种子(可选,相同种子返回相同内容,用于翻页一致性) + * @param string platform 平台标识,按平台过滤启用分类 */ public function random() { $channel = input('get.channel', 'all', 'trim'); $limit = min(30, max(1, input('get.limit', 10, 'intval'))); $seed = input('get.seed', '', 'trim'); + $platform = $this->_getPlatform(); if ($seed === '') { $seed = uniqid('rnd_', true); } $cacheKey = "feed_random_{$channel}_{$limit}_" . md5($seed); + if (!empty($platform)) { + $cacheKey .= "_{$platform}"; + } $cached = Cache::get($cacheKey); if ($cached) { $this->success('成功(cache)', $cached); @@ -1086,7 +1219,23 @@ class Feed extends Api $items = []; if ($channel === 'all') { - $types = array_keys(self::$feedMap); + $weightConfig = $this->_getWeightConfig(); + $enabledTypes = []; + foreach ($weightConfig as $type => $wc) { + if ($wc['is_enabled'] && isset(self::$feedMap[$type])) { + if (!empty($platform) && !$this->_isPlatformEnabled($type, $platform, $weightConfig)) { + continue; + } + $enabledTypes[] = $type; + } + } + // 指定了平台但无启用分类 → 返回空列表 + if (!empty($platform) && empty($enabledTypes)) { + $result = ['list' => [], 'channel' => $channel, 'limit' => $limit, 'seed' => $seed, 'platform' => $platform]; + $this->success('成功', $result); + return; + } + $types = !empty($enabledTypes) ? $enabledTypes : array_keys(self::$feedMap); $seedIdx = crc32($seed); $selectedTypes = []; for ($i = 0; $i < min(6, count($types)); $i++) { @@ -1106,6 +1255,19 @@ class Feed extends Api if (!isset(self::$feedMap[$channel])) { $this->error('不支持的频道'); } + // 指定频道时也检查平台过滤 + if (!empty($platform)) { + $weightConfig = $this->_getWeightConfig(); + $isEnabled = true; + if (isset($weightConfig[$channel])) { + $isEnabled = !empty($weightConfig[$channel]['is_enabled']); + } + if (!$isEnabled || !$this->_isPlatformEnabled($channel, $platform, $weightConfig)) { + $result = ['list' => [], 'channel' => $channel, 'limit' => $limit, 'seed' => $seed, 'platform' => $platform]; + $this->success('成功', $result); + return; + } + } $items = $this->_fetchRandomItems($channel, $limit, $seed); } @@ -1423,6 +1585,7 @@ class Feed extends Api { $channel = input('get.channel', 'all', 'trim'); $sinceId = input('get.since_id', 0, 'intval'); + $platform = $this->_getPlatform(); if ($sinceId <= 0) { $this->error('需要since_id参数'); @@ -1432,8 +1595,29 @@ class Feed extends Api $latestId = $sinceId; if ($channel === 'all') { - $mainTypes = ['poetry', 'wisdom', 'story', 'hitokoto', 'riddle', 'brainteaser', 'lyric', 'article']; - foreach ($mainTypes as $type) { + $weightConfig = $this->_getWeightConfig(); + $enabledTypes = []; + foreach ($weightConfig as $type => $wc) { + if ($wc['is_enabled'] && isset(self::$feedMap[$type])) { + if (!empty($platform) && !$this->_isPlatformEnabled($type, $platform, $weightConfig)) { + continue; + } + $enabledTypes[] = $type; + } + } + // 指定了平台但无启用分类 → 返回无新内容 + if (!empty($platform) && empty($enabledTypes)) { + $this->success('成功', [ + 'has_new' => false, + 'new_count' => 0, + 'latest_id' => $sinceId, + 'channel' => $channel, + 'platform' => $platform, + ]); + return; + } + $types = !empty($enabledTypes) ? $enabledTypes : ['poetry', 'wisdom', 'story', 'hitokoto', 'riddle', 'brainteaser', 'lyric', 'article']; + foreach ($types as $type) { if (!isset(self::$feedMap[$type])) continue; $config = self::$feedMap[$type]; try { @@ -1449,6 +1633,24 @@ class Feed extends Api if (!isset(self::$feedMap[$channel])) { $this->error('不支持的频道'); } + // 指定频道时也检查平台过滤 + if (!empty($platform)) { + $weightConfig = $this->_getWeightConfig(); + $isEnabled = true; + if (isset($weightConfig[$channel])) { + $isEnabled = !empty($weightConfig[$channel]['is_enabled']); + } + if (!$isEnabled || !$this->_isPlatformEnabled($channel, $platform, $weightConfig)) { + $this->success('成功', [ + 'has_new' => false, + 'new_count' => 0, + 'latest_id' => $sinceId, + 'channel' => $channel, + 'platform' => $platform, + ]); + return; + } + } $config = self::$feedMap[$channel]; try { $maxId = Db::name($config['table'])->max('id'); @@ -1470,11 +1672,12 @@ class Feed extends Api /** * @name 刷新获取新内容 * @desc 专用刷新接口,接收已看ID列表,保证返回内容不重复。不缓存。 - * @param string channel 频道类型(all/poetry/wisdom等) - * @param string sort 排序(newest/hottest) - * @param int limit 请求数量(默认20,最大50) - * @param string seen_ids 已看ID列表,格式: type_id,type_id (如 poetry_1,poetry_2,wisdom_5) + * @param string channel 频道类型(all/poetry/wisdom等) + * @param string sort 排序(newest/hottest) + * @param int limit 请求数量(默认20,最大50) + * @param string seen_ids 已看ID列表,格式: type_id,type_id (如 poetry_1,poetry_2,wisdom_5) * @param string seen_hashes 已看内容hash列表(MD5前8位,逗号分隔),用于内容去重 + * @param string platform 平台标识,按平台过滤启用分类 */ public function refresh_content() { @@ -1483,6 +1686,7 @@ class Feed extends Api $limit = min(50, max(1, input('get.limit', 20, 'intval'))); $seenIds = input('get.seen_ids', '', 'trim'); $seenHashes = input('get.seen_hashes', '', 'trim'); + $platform = $this->_getPlatform(); $seenMap = []; if (!empty($seenIds)) { @@ -1503,10 +1707,38 @@ class Feed extends Api $items = []; if ($channel === 'all') { + // 按平台过滤获取启用分类 + $enabledTypes = null; + if (!empty($platform)) { + $weightConfig = $this->_getWeightConfig(); + $enabledTypes = []; + foreach ($weightConfig as $type => $wc) { + if ($wc['is_enabled'] && isset(self::$feedMap[$type])) { + if (!$this->_isPlatformEnabled($type, $platform, $weightConfig)) { + continue; + } + $enabledTypes[] = $type; + } + } + // 指定了平台但无启用分类 → 返回空列表 + if (empty($enabledTypes)) { + $this->success('成功', ['list' => [], 'total' => 0, 'channel' => $channel, 'sort' => $sort, 'platform' => $platform]); + return; + } + } + $items = $this->_fetchExcludingSeen($seenMap, [], $sort, $limit); + // 按平台过滤结果 + if (!empty($platform) && !empty($enabledTypes) && !empty($items)) { + $items = array_filter($items, function ($item) use ($enabledTypes) { + return in_array($item['feed_type'] ?? '', $enabledTypes); + }); + $items = array_values($items); + } + if (empty($items)) { - $mainTypes = ['poetry', 'wisdom', 'story', 'hitokoto', 'riddle', 'brainteaser', 'lyric', 'article', 'efs', 'saying']; + $mainTypes = !empty($enabledTypes) ? $enabledTypes : ['poetry', 'wisdom', 'story', 'hitokoto', 'riddle', 'brainteaser', 'lyric', 'article', 'efs', 'saying']; $perType = max(1, intval($limit / count($mainTypes)) + 1); foreach ($mainTypes as $type) { try { @@ -1538,6 +1770,18 @@ class Feed extends Api if (!isset(self::$feedMap[$channel])) { $this->error('不支持的频道: ' . $channel); } + // 指定频道时也检查平台过滤 + if (!empty($platform)) { + $weightConfig = $this->_getWeightConfig(); + $isEnabled = true; + if (isset($weightConfig[$channel])) { + $isEnabled = !empty($weightConfig[$channel]['is_enabled']); + } + if (!$isEnabled || !$this->_isPlatformEnabled($channel, $platform, $weightConfig)) { + $this->success('成功', ['list' => [], 'total' => 0, 'channel' => $channel, 'sort' => $sort, 'platform' => $platform]); + return; + } + } $excludeIds = isset($seenMap[$channel]) ? $seenMap[$channel] : []; $config = self::$feedMap[$channel]; @@ -2478,6 +2722,156 @@ class Feed extends Api } catch (\Exception $e) {} } + /** + * @name 平台配置接口 + * @desc 获取当前平台的内容分类配置,APP启动时调用,根据请求头或参数自动识别平台 + * @param string platform 平台标识(可选,不传则从X-Platform请求头读取) + * @return array 包含当前平台启用的分类列表和平台信息 + */ + public function platform_config() + { + // 优先从参数获取,其次从请求头获取(_getPlatform已包含此逻辑) + $platform = $this->_getPlatform(); + + $weightConfig = $this->_getWeightConfig(); + $enabledChannels = []; + $disabledChannels = []; + + foreach (self::$feedMap as $key => $config) { + $isEnabled = true; + if (isset($weightConfig[$key])) { + $isEnabled = !empty($weightConfig[$key]['is_enabled']); + } + + $isPlatformEnabled = true; + if (!empty($platform) && $isEnabled) { + $isPlatformEnabled = $this->_isPlatformEnabled($key, $platform, $weightConfig); + } + + $channelInfo = [ + 'key' => $key, + 'name' => $config['name'], + 'icon' => $config['icon'], + 'is_enabled' => $isEnabled && $isPlatformEnabled, + 'platform_enabled' => $this->_parsePlatformEnabled($weightConfig[$key]['platform_enabled'] ?? null), + ]; + + if ($isEnabled && $isPlatformEnabled) { + $enabledChannels[] = $channelInfo; + } else { + $disabledChannels[] = $channelInfo; + } + } + + $result = [ + 'platform' => $platform ?: 'unknown', + 'platform_name' => isset(self::$platforms[$platform]) ? self::$platforms[$platform] : '未知', + 'enabled_count' => count($enabledChannels), + 'disabled_count' => count($disabledChannels), + 'enabled_channels' => $enabledChannels, + 'disabled_channels' => $disabledChannels, + 'all_platforms' => self::$platforms, + ]; + + $this->success('成功', $result); + } + + /** + * @name 获取平台标识 + * @desc 优先从GET参数获取platform,其次从X-Platform请求头获取 + * @return string 平台标识(android/ios/harmony/macos/win/web/other) + */ + private function _getPlatform() + { + $platform = input('get.platform', '', 'trim'); + if (empty($platform)) { + $platform = $this->request->header('x-platform', '', 'trim'); + } + // 验证平台参数有效性 + if (!empty($platform) && !isset(self::$platforms[$platform])) { + $platform = ''; // 无效平台忽略 + } + return $platform; + } + + /** + * @name 检查分类是否在指定平台启用 + * @desc 根据权重配置中的platform_enabled字段判断分类是否在指定平台启用 + * @param string $type 内容类型key + * @param string $platform 平台标识 + * @param array $weightConfig 权重配置数组 + * @return bool 是否启用 + */ + private function _isPlatformEnabled($type, $platform, $weightConfig) + { + if (!isset($weightConfig[$type])) { + return true; // 未配置的类型默认所有平台启用 + } + + $platformJson = $weightConfig[$type]['platform_enabled'] ?? ''; + if (empty($platformJson)) { + return true; // 空值表示所有平台启用(向后兼容) + } + + $platformData = json_decode($platformJson, true); + if (!is_array($platformData)) { + return true; // 解析失败默认启用 + } + + // 如果该平台未在配置中定义,默认启用 + if (!isset($platformData[$platform])) { + return true; + } + + return (bool) $platformData[$platform]; + } + + /** + * @name 解析平台开关JSON + * @desc 将platform_enabled字段解析为标准数组,缺失平台默认启用 + * @param string|null $jsonStr 平台开关JSON字符串 + * @return array 平台开关数组 ['android' => true, 'ios' => true, ...] + */ + private function _parsePlatformEnabled($jsonStr) + { + $default = []; + foreach (self::$platforms as $pKey => $pName) { + $default[$pKey] = true; + } + + if (empty($jsonStr)) { + return $default; + } + + $decoded = json_decode($jsonStr, true); + if (!is_array($decoded)) { + return $default; + } + + foreach (self::$platforms as $pKey => $pName) { + if (!isset($decoded[$pKey])) { + $decoded[$pKey] = true; + } + $decoded[$pKey] = (bool) $decoded[$pKey]; + } + + return $decoded; + } + + /** + * @name 生成默认平台开关JSON + * @desc 所有平台默认开启 + * @return string JSON字符串 + */ + private function _defaultPlatformEnabled() + { + $data = []; + foreach (self::$platforms as $pKey => $pName) { + $data[$pKey] = true; + } + return json_encode($data); + } + /** * @name 批量加载Feed内容 * @desc 按类型分组批量查询,解决N+1查询问题 @@ -2545,23 +2939,25 @@ class Feed extends Api $rows = Db::name('feed_weight_config')->select(); foreach ($rows as $row) { $config[$row['feed_type']] = [ - 'weight' => intval($row['weight']), - 'display_weight' => intval($row['display_weight']), - 'push_limit' => intval($row['push_limit']), - 'push_count' => intval($row['push_count']), - 'push_date' => $row['push_date'], - 'is_enabled' => intval($row['is_enabled']), + 'weight' => intval($row['weight']), + 'display_weight' => intval($row['display_weight']), + 'push_limit' => intval($row['push_limit']), + 'push_count' => intval($row['push_count']), + 'push_date' => $row['push_date'], + 'is_enabled' => intval($row['is_enabled']), + 'platform_enabled' => $row['platform_enabled'] ?? '', ]; } } catch (\Exception $e) { foreach (self::$feedMap as $type => $fc) { $config[$type] = [ - 'weight' => 50, - 'display_weight' => 50, - 'push_limit' => 0, - 'push_count' => 0, - 'push_date' => null, - 'is_enabled' => 1, + 'weight' => 50, + 'display_weight' => 50, + 'push_limit' => 0, + 'push_count' => 0, + 'push_date' => null, + 'is_enabled' => 1, + 'platform_enabled' => '', ]; } } @@ -2673,6 +3069,155 @@ class Feed extends Api return array_slice($items, 0, $need); } + /** + * @name A/B测试配置接口 + * @desc 根据平台和用户ID返回当前生效的A/B测试权重覆盖配置 + * @param string platform 平台标识(android/ios/harmony等) + * @param string user_id 用户ID(可选,用于稳定分配变体) + * @return array 包含test_key、variant、weight_overrides + */ + public function ab_test_config() + { + $platform = $this->_getPlatform(); + $userId = input('get.user_id', 0, 'intval'); + + // 优先从请求头获取用户ID + if (!$userId) { + $userId = $this->_getUserId(); + } + + // _getPlatform已包含请求头读取逻辑 + + $abConfig = $this->_getAbTestConfig($platform, $userId); + + $this->success('成功', $abConfig); + } + + /** + * @name 获取A/B测试配置 + * @desc 查找当前运行中的、匹配平台的实验,根据user_id的hash分配变体,返回权重覆盖配置 + * @param string $platform 平台标识 + * @param int $userId 用户ID + * @return array A/B测试配置 + */ + private function _getAbTestConfig($platform = '', $userId = 0) + { + $cacheKey = "ab_test_running"; + $cached = Cache::get($cacheKey); + + if ($cached === false || !is_array($cached)) { + // 查找所有运行中的实验 + try { + $runningTests = Db::name('ab_test') + ->where('status', 1) + ->select(); + } catch (\Exception $e) { + $runningTests = []; + } + + Cache::set($cacheKey, $runningTests, 30); + } else { + $runningTests = $cached; + } + + if (empty($runningTests)) { + return [ + 'has_test' => false, + 'test_key' => '', + 'variant' => '', + 'weight_overrides' => new \stdClass(), + ]; + } + + // 筛选匹配平台的实验 + $matchedTests = []; + foreach ($runningTests as $test) { + // 平台匹配:all匹配所有,或者精确匹配 + if ($test['platform'] === 'all' || $test['platform'] === $platform || empty($platform)) { + // 检查流量占比:用hash决定该用户是否参与实验 + $trafficHash = abs(crc32('ab_traffic_' . $test['id'] . '_' . $userId)) % 100; + if ($trafficHash < $test['traffic_percent']) { + $matchedTests[] = $test; + } + } + } + + if (empty($matchedTests)) { + return [ + 'has_test' => false, + 'test_key' => '', + 'variant' => '', + 'weight_overrides' => new \stdClass(), + ]; + } + + // 取优先级最高的实验(最新创建的) + $test = $matchedTests[0]; + if (count($matchedTests) > 1) { + // 按ID降序取最新的 + usort($matchedTests, function ($a, $b) { + return $b['id'] - $a['id']; + }); + $test = $matchedTests[0]; + } + + // 获取变体列表 + try { + $variants = Db::name('ab_test_variant') + ->where('test_id', $test['id']) + ->order('id', 'asc') + ->select(); + } catch (\Exception $e) { + $variants = []; + } + + if (empty($variants)) { + return [ + 'has_test' => false, + 'test_key' => '', + 'variant' => '', + 'weight_overrides' => new \stdClass(), + ]; + } + + // 根据用户ID的hash分配变体 + $totalTraffic = array_sum(array_column($variants, 'traffic_percent')); + if ($totalTraffic <= 0) { + $totalTraffic = 100; + } + + $variantHash = abs(crc32('ab_variant_' . $test['id'] . '_' . $userId)) % $totalTraffic; + $accumulated = 0; + $assignedVariant = $variants[0]; // 默认取第一个 + + foreach ($variants as $v) { + $accumulated += $v['traffic_percent']; + if ($variantHash < $accumulated) { + $assignedVariant = $v; + break; + } + } + + // 解析权重覆盖配置 + $weightOverrides = []; + if (!empty($assignedVariant['weight_config'])) { + $decoded = json_decode($assignedVariant['weight_config'], true); + if (is_array($decoded)) { + $weightOverrides = $decoded; + } + } + + return [ + 'has_test' => true, + 'test_key' => $test['test_key'], + 'test_name' => $test['test_name'], + 'variant' => $assignedVariant['variant_key'], + 'variant_name' => $assignedVariant['variant_name'], + 'is_control' => $assignedVariant['is_control'] ? true : false, + 'weight_overrides' => !empty($weightOverrides) ? $weightOverrides : new \stdClass(), + ]; + } + /** * @name 权重配置接口 * @desc 获取当前推荐权重配置(供APP展示和管理员调试) @@ -2690,16 +3235,18 @@ class Feed extends Api try { $rows = Db::name('feed_weight_config')->order('weight', 'desc')->select(); foreach ($rows as $row) { + $platformEnabled = $this->_parsePlatformEnabled($row['platform_enabled'] ?? null); $config[] = [ - 'type' => $row['feed_type'], - 'name' => $row['feed_name'], - 'icon' => $row['feed_icon'], - 'weight' => intval($row['weight']), - 'display_weight' => intval($row['display_weight']), - 'push_limit' => intval($row['push_limit']), - 'push_count' => intval($row['push_count']), - 'push_date' => $row['push_date'], - 'is_enabled' => intval($row['is_enabled']) ? true : false, + 'type' => $row['feed_type'], + 'name' => $row['feed_name'], + 'icon' => $row['feed_icon'], + 'weight' => intval($row['weight']), + 'display_weight' => intval($row['display_weight']), + 'push_limit' => intval($row['push_limit']), + 'push_count' => intval($row['push_count']), + 'push_date' => $row['push_date'], + 'is_enabled' => intval($row['is_enabled']) ? true : false, + 'platform_enabled' => $platformEnabled, ]; } } catch (\Exception $e) { @@ -2710,10 +3257,11 @@ class Feed extends Api $enabledCount = count(array_filter($config, function ($c) { return $c['is_enabled']; })); $result = [ - 'config' => $config, - 'total_weight' => $totalWeight, + 'config' => $config, + 'total_weight' => $totalWeight, 'enabled_count' => $enabledCount, - 'total_types' => count($config), + 'total_types' => count($config), + 'platforms' => self::$platforms, ]; Cache::set($cacheKey, $result, 60); @@ -2729,6 +3277,7 @@ class Feed extends Api * @param int group_size 分组循环模式每组条数(默认3) * @param int limit 总条数(默认20,最大50) * @param string sort 排序方式(newest/hottest,默认hottest) + * @param string platform 平台标识(android/ios/harmony/macos/win/web/other),按平台过滤 */ public function mix() { @@ -2738,6 +3287,7 @@ class Feed extends Api $groupSize = max(1, input('get.group_size', 3, 'intval')); $limit = min(50, max(1, input('get.limit', 20, 'intval'))); $sort = input('get.sort', 'hottest', 'trim'); + $platform = $this->_getPlatform(); $allowedModes = ['uniform', 'ratio', 'specific', 'random', 'group']; if (!in_array($mode, $allowedModes)) { @@ -2756,11 +3306,25 @@ class Feed extends Api $weightConfig = $this->_getWeightConfig(); foreach ($weightConfig as $type => $wc) { if ($wc['is_enabled'] && isset(self::$feedMap[$type])) { + // 按平台过滤 + if (!empty($platform) && !$this->_isPlatformEnabled($type, $platform, $weightConfig)) { + continue; + } $validChannels[] = $type; } } if (empty($validChannels)) { - $validChannels = ['poetry', 'wisdom', 'story', 'hitokoto', 'lyric']; + // 指定了平台但无启用分类 → 返回空列表 + $result = [ + 'list' => [], + 'total' => 0, + 'mode' => $mode, + 'channels' => [], + 'limit' => $limit, + 'platform' => $platform, + ]; + $this->success('成功', $result); + return; } } diff --git a/docs/toolsapi/docs/API_FEED_DOC.md b/docs/toolsapi/docs/API_FEED_DOC.md index 7c3f5c74..d70335dc 100644 --- a/docs/toolsapi/docs/API_FEED_DOC.md +++ b/docs/toolsapi/docs/API_FEED_DOC.md @@ -91,9 +91,17 @@ **GET** `/api/feed/channels` -无需登录,无需参数。 +无需登录。 -### 2.1 响应字段 +> **平台过滤**: 支持 `platform` 参数按平台过滤频道,详见 [API_PLATFORM_FILTER_DOC.md](API_PLATFORM_FILTER_DOC.md) + +### 2.1 请求参数 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| platform | string | ❌ | 从X-Platform请求头读取 | 平台标识(android/ios/harmony/macos/win/web/other) | + +### 2.2 响应字段 | 字段 | 类型 | 说明 | |------|------|------| diff --git a/docs/toolsapi/docs/API_PLATFORM_FILTER_DOC.md b/docs/toolsapi/docs/API_PLATFORM_FILTER_DOC.md new file mode 100644 index 00000000..8c21f5fe --- /dev/null +++ b/docs/toolsapi/docs/API_PLATFORM_FILTER_DOC.md @@ -0,0 +1,358 @@ +# 闲言工具箱 · 平台过滤接口文档 + +> 基础URL: `https://tools.wktyl.com` +> 版本: v1.1.0 | 更新时间: 2026-06-09 +> 作者: AI Coder | 上次更新: 新增toggle_platform/content_count/batch_category_enable接口;修复random_content表名映射 + +--- + +## 目录 + +- [一、功能概述](#一功能概述) +- [二、平台列表](#二平台列表) +- [三、数据库设计](#三数据库设计) +- [四、后台管理接口](#四后台管理接口) +- [五、API接口](#五api接口) +- [六、APP集成指南](#六app集成指南) +- [七、向后兼容性](#七向后兼容性) + +--- + +## 一、功能概述 + +平台过滤功能允许管理员在后台按平台设置每个内容分类的启用/禁用状态。APP端根据当前运行平台自动过滤,只展示该平台启用的内容。 + +### 核心设计 + +- **数据库字段**: `fa_feed_weight_config` 表新增 `platform_enabled` 字段(VARCHAR(255),存储JSON) +- **JSON格式**: `{"android":true,"ios":true,"harmony":true,"macos":true,"win":true,"web":true,"other":true}` +- **向后兼容**: `platform_enabled` 为空时,所有平台默认启用 +- **双重传递**: APP通过 `X-Platform` 请求头 + `platform` URL参数传递平台标识 + +### 影响范围 + +| APP模块 | 影响说明 | +|---------|---------| +| 主页上方句子卡片 | 每日推荐卡片按平台过滤分类 | +| 主页句子卡片 | 信息流列表按平台过滤 | +| 工具中心 | 频道列表按平台过滤 | +| 句子来源 | 频道标签按平台过滤 | + +--- + +## 二、平台列表 + +| 平台标识 | 名称 | 图标 | 检测方式 | +|----------|------|------|----------| +| android | 安卓 | 🤖 | `Platform.isAndroid` | +| ios | iOS | 🍎 | `Platform.isIOS` | +| harmony | 鸿蒙 | 🔴 | `Platform.operatingSystem == 'ohos'` | +| macos | macOS | 💻 | `Platform.isMacOS` | +| win | Windows | 🪟 | `Platform.isWindows` | +| web | Web | 🌐 | `kIsWeb` | +| other | 其他 | 📱 | 以上均不匹配 | + +--- + +## 三、数据库设计 + +### 3.1 新增字段 + +```sql +ALTER TABLE `fa_feed_weight_config` +ADD COLUMN `platform_enabled` varchar(255) NOT NULL DEFAULT '' COMMENT '平台开关JSON' +AFTER `is_enabled`; +``` + +### 3.2 字段格式 + +```json +{ + "android": true, + "ios": true, + "harmony": true, + "macos": true, + "win": true, + "web": true, + "other": true +} +``` + +- 空字符串 `""` = 所有平台启用(向后兼容) +- 缺失的平台key = 默认启用 +- `true` = 该平台启用,`false` = 该平台禁用 + +--- + +## 四、后台管理接口 + +### 4.1 批量平台操作 + +**POST** `/admin.php/feed_weight/batch_platform` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| ids | string | ✅ | 记录ID(逗号分隔) | +| action | string | ✅ | 操作类型 | + +**action可选值**: + +| action | 说明 | +|--------|------| +| select_all | 全选(所有平台开启) | +| deselect_all | 全不选(所有平台关闭) | +| invert | 反选(每个平台取反) | +| android | 切换安卓平台开关 | +| ios | 切换iOS平台开关 | +| harmony | 切换鸿蒙平台开关 | +| macos | 切换macOS平台开关 | +| win | 切换Windows平台开关 | +| web | 切换Web平台开关 | +| other | 切换其他平台开关 | + +**响应示例**: + +```json +{ + "code": 1, + "msg": "批量平台操作「全选」成功,影响44条记录" +} +``` + +### 4.2 编辑接口(扩展) + +**POST** `/admin.php/feed_weight/edit?ids={id}` + +新增 `platform_enabled` 参数,格式为JSON字符串: + +``` +row[platform_enabled]={"android":true,"ios":false,"harmony":true,"macos":true,"win":true,"web":true,"other":true} +``` + +### 4.3 批量更新(扩展) + +**POST** `/admin.php/feed_weight/batch_update` + +新增 `platform_enabled` 参数: + +``` +platform_enabled={"android":true,"ios":true,"harmony":true,"macos":true,"win":true,"web":true,"other":true} +``` + +### 4.4 切换单个平台开关 + +**POST** `/admin.php/feed_weight/toggle_platform` + +列表页点击平台图标直接切换某个分类的某个平台开关。 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | int | ✅ | 记录ID | +| platform | string | ✅ | 平台标识(android/ios/harmony/macos/win/web/other) | + +**响应示例**: + +```json +{ + "code": 1, + "msg": "已关闭🍎iOS平台" +} +``` + +**注意**: 已禁用的分类(is_enabled=0)不允许切换平台开关,会返回错误。 + +### 4.5 批量分类启用/禁用 + +**POST** `/admin.php/feed_weight/batch_category_enable` + +支持多选分类批量启用或禁用。 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| feed_types | string | ✅ | 分类标识(逗号分隔,如"poetry,wisdom,story") | +| enable | int | ✅ | 1=启用 0=禁用 | + +**响应示例**: + +```json +{ + "code": 1, + "msg": "已启用3个分类" +} +``` + +### 4.6 获取内容数量 + +**GET** `/admin.php/feed_weight/content_count?feed_type={type}` + +获取某个分类的内容条数。 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| feed_type | string | ✅ | 内容类型标识 | + +**响应示例**: + +```json +{ + "code": 1, + "msg": "获取成功", + "data": {"count": 288100} +} +``` + +### 4.7 随机内容预览 + +**GET** `/admin.php/feed_weight/random_content?feed_type={type}` + +获取某个分类的随机一条内容。 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| feed_type | string | ✅ | 内容类型标识 | + +**响应示例**: + +```json +{ + "code": 1, + "msg": "获取成功", + "data": {"id": 123, "name": "...", "content": "..."} +} +``` + +**注意**: +- `chengyu`类型映射到`cy`表,`cidian`类型映射到`zc`表 +- 此接口无需登录认证(在`$noNeedLogin`列表中) + +--- + +## 五、API接口 + +### 5.1 平台配置接口 + +**GET** `/api/feed/platform_config` + +获取当前平台的内容分类配置,APP启动时调用。 + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| platform | string | ❌ | 从X-Platform请求头读取 | 平台标识 | + +**响应示例**: + +```json +{ + "code": 1, + "msg": "成功", + "data": { + "platform": "android", + "platform_name": "安卓", + "enabled_count": 28, + "disabled_count": 16, + "enabled_channels": [ + { + "key": "poetry", + "name": "古诗词", + "icon": "📜", + "is_enabled": true, + "platform_enabled": {"android":true,"ios":true,"harmony":true,"macos":true,"win":true,"web":true,"other":true} + } + ], + "disabled_channels": [], + "all_platforms": {"android":"安卓","ios":"iOS","harmony":"鸿蒙","macos":"macOS","win":"Windows","web":"Web","other":"其他"} + } +} +``` + +### 5.2 频道列表(扩展) + +**GET** `/api/feed/channels` + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| platform | string | ❌ | 从X-Platform请求头读取 | 平台标识 | + +新增 `platform` 参数,只返回该平台启用的分类。响应新增 `platform` 字段。 + +### 5.3 信息流列表(扩展) + +**GET** `/api/feed/list` + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| platform | string | ❌ | 从X-Platform请求头读取 | 平台标识 | + +新增 `platform` 参数,channel=all时只从该平台启用的分类获取内容。 + +### 5.4 混合信息流(扩展) + +**GET** `/api/feed/mix` + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| platform | string | ❌ | 从X-Platform请求头读取 | 平台标识 | + +新增 `platform` 参数,未指定channels时只从该平台启用的分类混合。 + +### 5.5 权重配置(扩展) + +**GET** `/api/feed/weight_config` + +响应新增字段: +- 每个分类的 `platform_enabled` 对象 +- 顶层 `platforms` 平台列表 + +--- + +## 六、APP集成指南 + +### 6.1 平台检测 + +APP通过 `ApiInterceptor.currentPlatform` 自动检测当前平台,并在每个请求中通过 `X-Platform` 请求头传递: + +```dart +// 已在 api_interceptor.dart 中自动处理 +options.headers['X-Platform'] = currentPlatform; +``` + +### 6.2 FeedService调用 + +所有Feed相关接口调用时自动附带平台参数: + +```dart +// 频道列表 - 自动附带平台 +FeedService.fetchChannels(platform: ApiInterceptor.currentPlatform); + +// 信息流列表 - 通过FeedListParams.platform +FeedListParams(channel: 'all', platform: ApiInterceptor.currentPlatform); + +// 混合信息流 - 通过FeedMixConfig.platform +FeedMixConfig(limit: 5, platform: ApiInterceptor.currentPlatform); +``` + +### 6.3 数据流 + +``` +1. APP启动 → ApiInterceptor自动在请求头注入X-Platform +2. 调用 /api/feed/channels?platform=android → 只返回安卓启用的分类 +3. 调用 /api/feed/list?platform=android → 只返回安卓启用分类的内容 +4. 调用 /api/feed/mix?platform=android → 每日推荐只从安卓启用分类混合 +5. 后台管理员修改平台开关 → 缓存自动清除 → 下次请求生效 +``` + +--- + +## 七、向后兼容性 + +| 场景 | 行为 | +|------|------| +| platform_enabled字段为空 | 所有平台启用(等同旧版is_enabled=1) | +| 不传platform参数 | 不按平台过滤(等同旧版行为) | +| platform参数无效 | 忽略,不按平台过滤 | +| 旧版APP不传X-Platform头 | 不按平台过滤,兼容旧版 | +| 新增平台key | 缺失的key默认启用 | + +--- + +*文档创建时间: 2026-06-09 | 维护者: 闲言APP开发团队* diff --git a/docs/toolsapi/docs/SERVER_DEPLOYMENT_GUIDE.md b/docs/toolsapi/docs/SERVER_DEPLOYMENT_GUIDE.md index 465bb122..5608f064 100644 --- a/docs/toolsapi/docs/SERVER_DEPLOYMENT_GUIDE.md +++ b/docs/toolsapi/docs/SERVER_DEPLOYMENT_GUIDE.md @@ -6,6 +6,7 @@ - 项目路径: /www/wwwroot/tools.wktyl.com/ - 运行目录: tools.wktyl.com/public → tools.wktyl.com - 数据库: MySQL (tools库, 前缀 tool_) +- 后端: https://s2ss.com/admin.php 管理员520kiss 密码 520kiss - Web服务器: Nginx + PHP (ThinkPHP5框架) - **python**: C:\Users\无书\AppData\Local\Python\pythoncore-3.14-64\python.exe diff --git a/docs/toolsapi/public/assets/js/backend/ab_test.js b/docs/toolsapi/public/assets/js/backend/ab_test.js new file mode 100644 index 00000000..93a338c9 --- /dev/null +++ b/docs/toolsapi/public/assets/js/backend/ab_test.js @@ -0,0 +1,197 @@ +/** + * @name A/B测试实验管理 + * @author AI Coder + * @date 2026-06-09 + * @desc 后台A/B测试列表、状态操作、平台图标显示 + * @update 2026-06-09 初始创建 + */ + +define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) { + + /** + * @name 状态映射 + * @desc 状态值→显示文本和颜色 + */ + var statusMap = { + 0: {text: '📝 草稿', color: 'default'}, + 1: {text: '🟢 运行中', color: 'success'}, + 2: {text: '🟡 已暂停', color: 'warning'}, + 3: {text: '🔴 已结束', color: 'danger'} + }; + + /** + * @name 平台图标映射 + */ + var platformIcons = { + all: '🌐', android: '🤖', ios: '🍎', harmony: '🔴', + macos: '💻', win: '🪟', web: '🌐', other: '📱' + }; + + /** + * @name 平台名称映射 + */ + var platformNames = { + all: '全部平台', android: '安卓', ios: 'iOS', harmony: '鸿蒙', + macos: 'macOS', win: 'Windows', web: 'Web', other: '其他' + }; + + var Controller = { + index: function () { + Table.api.init({ + extend: { + index_url: 'ab_test/index' + location.search, + add_url: 'ab_test/add', + edit_url: 'ab_test/edit', + del_url: 'ab_test/del', + table: 'ab_test', + } + }); + + var table = $("#table"); + + table.bootstrapTable({ + url: $.fn.bootstrapTable.defaults.extend.index_url, + pk: 'id', + sortName: 'id', + sortOrder: 'desc', + columns: [ + [ + {checkbox: true}, + {field: 'id', title: 'ID', sortable: true, width: '60px'}, + { + field: 'test_name', + title: __('Test_name'), + operate: 'LIKE', + formatter: function(val, row) { + return '' + val + '
' + row.test_key + ''; + } + }, + { + field: 'platform', + title: __('Platform'), + searchList: {all: '全部', android: '安卓', ios: 'iOS', harmony: '鸿蒙', macos: 'macOS', win: 'Windows', web: 'Web', other: '其他'}, + formatter: function(val) { + var icon = platformIcons[val] || '🌐'; + var name = platformNames[val] || val; + return icon + ' ' + name; + } + }, + { + field: 'status', + title: __('Status'), + searchList: {0: '草稿', 1: '运行中', 2: '已暂停', 3: '已结束'}, + formatter: function(val) { + var info = statusMap[val] || statusMap[0]; + return '' + info.text + ''; + } + }, + { + field: 'traffic_percent', + title: __('Traffic_percent'), + sortable: true, + formatter: function(val) { + return val + '%'; + } + }, + { + field: 'variant_count', + title: __('Variant_count'), + formatter: function(val) { + return '' + val + ''; + } + }, + { + field: 'start_time_text', + title: __('Start_time'), + sortable: true, + formatter: function(val, row) { + if (!val || val === '-') return '-'; + return val; + } + }, + { + field: 'operate', + title: __('Operate'), + table: table, + events: { + 'click .btn-start': function(e, value, row) { + e.preventDefault(); + Layer.confirm('确认启动实验「' + row.test_name + '」?启动后将开始对用户生效。', function(index) { + Fast.api.ajax({ + url: 'ab_test/start', + data: {ids: row.id} + }, function(data, ret) { + Toastr.success(ret.msg); + table.bootstrapTable('refresh'); + Layer.close(index); + }); + }); + }, + 'click .btn-pause': function(e, value, row) { + e.preventDefault(); + Layer.confirm('确认暂停实验「' + row.test_name + '」?', function(index) { + Fast.api.ajax({ + url: 'ab_test/pause', + data: {ids: row.id} + }, function(data, ret) { + Toastr.success(ret.msg); + table.bootstrapTable('refresh'); + Layer.close(index); + }); + }); + }, + 'click .btn-stop': function(e, value, row) { + e.preventDefault(); + Layer.confirm('⚠️ 确认结束实验「' + row.test_name + '」?此操作不可恢复!', function(index) { + Fast.api.ajax({ + url: 'ab_test/stop', + data: {ids: row.id} + }, function(data, ret) { + Toastr.success(ret.msg); + table.bootstrapTable('refresh'); + Layer.close(index); + }); + }); + } + }, + formatter: function(value, row) { + var btns = []; + // 编辑按钮 + if (row.status !== 3) { + btns.push(''); + } + // 状态操作按钮 + if (row.status === 0 || row.status === 2) { + btns.push(' 启动'); + } + if (row.status === 1) { + btns.push(' 暂停'); + btns.push(' 结束'); + } + // 删除按钮(仅草稿) + if (row.status === 0) { + btns.push(''); + } + return btns.join(' '); + } + } + ] + ] + }); + + Table.api.bindevent(table); + }, + add: function () { + Controller.api.bindevent(); + }, + edit: function () { + Controller.api.bindevent(); + }, + api: { + bindevent: function () { + Form.api.bindevent($("form[role=form]")); + } + } + }; + return Controller; +}); diff --git a/docs/toolsapi/public/assets/js/backend/feed_weight.js b/docs/toolsapi/public/assets/js/backend/feed_weight.js index faca4d9d..1a422c93 100644 --- a/docs/toolsapi/public/assets/js/backend/feed_weight.js +++ b/docs/toolsapi/public/assets/js/backend/feed_weight.js @@ -2,13 +2,112 @@ * @name 信息流推荐权重管理 * @author AI Coder * @date 2026-05-14 - * @desc 后台权重配置列表、编辑、重置推送、恢复默认 - * @update 初始创建 + * @desc 后台权重配置列表、编辑、重置推送、恢复默认、平台开关管理 + * @update 2026-06-09 开关样式按钮;分类多选;列表点击切换平台;内容数量列 */ + define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) { + var feedTypeMap = { + poetry: '📜 古诗词', wisdom: '💡 名言金句', story: '📚 故事', hitokoto: '💬 一言', + riddle: '🧩 谜语', efs: '🎭 歇后语', brainteaser: '🧠 脑筋急转弯', saying: '🗣️ 俗语', + lyric: '🎵 歌词', why: '❓ 十万个为什么', composition: '✍️ 作文', couplet: '🏮 对联', + cs: '📖 常识', drug: '💊 药品', herbal: '🌿 中草药', food: '🍽️ 食物', + wine: '🍷 酒方', article: '📰 文章', chengyu: '🔤 成语', hanzi: '🈯 汉字', + cidian: '📚 词典', prescription: '🧪 偏方', tisana: '🍵 药茶', joke: '😄 笑话', + zgjm: '🌙 周公解梦', lunyu: '📖 论语', hdnj: '⚕️ 黄帝内经', jgj: '📿 金刚经', + mz: '📜 孟子', zz: '🦋 庄子', zuozhuan: '📜 左传', sj: '🏛️ 史记', + sgz: '⚔️ 三国志', sbbf: '🗡️ 孙膑兵法', warring: '🛡️ 兵法', illness: '🩺 疾病', + word: '🔤 英语单词', abbr: '📝 缩写', surname: '👤 姓氏', jieqi: '🌤️ 节气', + nation: '🌍 国家', wlyh: '💬 网络用语', jiufang: '🍶 酒方(古)', bot: '⭐ 星座' + }; + + var platformIcons = { + android: '🤖', ios: '🍎', harmony: '🔴', macos: '💻', + win: '🪟', web: '🌐', other: '📱' + }; + + var platformNames = { + android: '安卓', ios: 'iOS', harmony: '鸿蒙', macos: 'macOS', + win: 'Windows', web: 'Web', other: '其他' + }; + + function parsePlatformEnabled(jsonStr) { + var defaultData = {android:true, ios:true, harmony:true, macos:true, win:true, web:true, other:true}; + if (!jsonStr) return defaultData; + try { + var decoded = JSON.parse(jsonStr); + if (typeof decoded !== 'object' || decoded === null) return defaultData; + for (var key in defaultData) { + if (!(key in decoded)) decoded[key] = true; + decoded[key] = !!decoded[key]; + } + return decoded; + } catch(e) { return defaultData; } + } + + /** + * @name 格式化数字(加千分位) + */ + function formatNumber(num) { + if (num >= 10000) return (num / 10000).toFixed(1) + '万'; + return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); + } + + /** + * @name 生成启用状态列HTML(需求7:平台图标可点击切换开关) + */ + function formatPlatformStatus(isEnabled, platformEnabledJson, rowId) { + if (!isEnabled) { + return '❌ 已禁用'; + } + var data = parsePlatformEnabled(platformEnabledJson); + var html = '
'; + for (var key in platformIcons) { + var isOn = data[key]; + // 开关样式按钮 + var bg = isOn ? '#34c759' : '#e0e0e0'; + var color = isOn ? '#fff' : '#999'; + var opacity = isOn ? '1' : '0.6'; + html += ''; + html += platformIcons[key]; + html += ''; + } + html += '
'; + return html; + } + + function countEnabledPlatforms(platformEnabledJson) { + var data = parsePlatformEnabled(platformEnabledJson); + var count = 0; + for (var key in data) { if (data[key]) count++; } + return count; + } + var Controller = { index: function () { + /** + * @name 更新分类快捷面板checkbox状态 + * @desc 批量操作后同步更新快捷面板中的勾选状态和背景色 + */ + window.updateCategoryCheckboxes = function(types, enable) { + if (!types || !types.length) return; + $('#quick-category-list input[type="checkbox"]').each(function() { + var val = $(this).val(); + if (types.indexOf(val) !== -1) { + $(this).prop('checked', !!enable); + var $label = $(this).closest('label'); + $label.css('background', enable ? '#e8f5e9' : '#f5f5f5'); + } + }); + }; + Table.api.init({ extend: { index_url: 'feed_weight/index' + location.search, @@ -27,29 +126,70 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin columns: [ [ {checkbox: true}, - {field: 'id', title: 'ID', sortable: true}, - {field: 'feed_type', title: __('Feed_type'), operate: 'LIKE', + {field: 'id', title: 'ID', sortable: true, width: '60px'}, + { + field: 'feed_type', + title: __('Feed_type'), + operate: 'LIKE', + sortable: true, formatter: function(val) { - var nameMap = {poetry:'诗词',wisdom:'名言',story:'故事',hitokoto:'一言',riddle:'谜语',efs:'情话',brainteaser:'脑筋急转弯',saying:'俗语',lyric:'歌词',why:'十万个为什么',composition:'作文',couplet:'对联',cs:'常识',drug:'中药',herbal:'草药',food:'美食',wine:'美酒',article:'文章'}; - return nameMap[val] || val; + return feedTypeMap[val] || val; } }, - {field: 'weight', title: __('Weight'), sortable: true, + { + field: 'content_count', + title: '内容数', + sortable: true, + formatter: function(val) { + if (!val || val <= 0) return '0'; + var color = val >= 10000 ? '#34c759' : (val >= 1000 ? '#ff9500' : '#333'); + return '' + formatNumber(val) + ''; + } + }, + { + field: 'weight', + title: __('Weight'), + sortable: true, formatter: function(val) { var pct = Math.min(100, val); var color = val >= 60 ? '#34c759' : (val >= 40 ? '#ff9500' : '#ff3b30'); - return '
' + val + '
'; + return '
' + val + '
'; } }, - {field: 'display_weight', title: __('Display_weight'), sortable: true}, - {field: 'push_limit', title: __('Push_limit'), + { + field: 'display_weight', + title: __('Display_weight'), + sortable: true, + formatter: function(val) { + return val + ' (' + Math.round(val/100*100) + '%)'; + } + }, + { + field: 'push_limit', + title: __('Push_limit'), + sortable: true, formatter: function(val) { return val > 0 ? val + ' 条/天' : '不限制'; } }, - {field: 'is_enabled', title: __('Is_enabled'), searchList: {0: '禁用', 1: '启用'}, - formatter: function(val) { - return val == 1 ? '启用' : '禁用'; + { + field: 'is_enabled', + title: __('Is_enabled'), + searchList: {0: '禁用', 1: '启用'}, + sortable: true, + formatter: function(val, row) { + return formatPlatformStatus(val, row.platform_enabled, row.id); + } + }, + { + field: 'platform_count', + title: '平台数', + sortable: true, + formatter: function(val, row) { + var count = countEnabledPlatforms(row.platform_enabled); + var total = 7; + var color = count === total ? '#34c759' : (count > 0 ? '#ff9500' : '#ff3b30'); + return '' + count + '/' + total; } }, {field: 'operate', title: __('Operate'), table: table, events: Table.api.events.operate, formatter: Table.api.formatter.operate} @@ -57,32 +197,165 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin ] }); - $(document).on('click', '.btn-reset-push', function() { - var ids = Table.api.selectedids(table); - if (!ids.length) { Toastr.warning('请选择要重置的行'); return; } + // [需求7] 列表页点击平台图标切换开关 + $(document).on('click', '.platform-toggle-btn', function(e) { + e.stopPropagation(); + e.preventDefault(); + var id = $(this).data('id'); + var platform = $(this).data('platform'); + if (!id || !platform) return; + + var $btn = $(this); + $btn.css('opacity', '0.4'); $.ajax({ - url: 'feed_weight/reset_push', - data: {ids: ids.join(',')}, + url: 'feed_weight/toggle_platform', + data: {id: id, platform: platform}, type: 'post', dataType: 'json', success: function(ret) { - if (ret.code === 1) { Toastr.success(ret.msg); table.bootstrapTable('refresh'); } - else { Toastr.error(ret.msg); } + if (ret.code === 1) { + Toastr.success(ret.msg); + table.bootstrapTable('refresh'); + } else { + Toastr.error(ret.msg); + $btn.css('opacity', '1'); + } + }, + error: function() { + Toastr.error('操作失败'); + $btn.css('opacity', '1'); } }); }); + // 批量平台操作 + $(document).on('click', '.btn-platform-action', function() { + var action = $(this).data('action'); + var ids = Table.api.selectedids(table); + if (ids.length === 0) { + Toastr.error('请先选择至少一条记录'); + return; + } + Layer.confirm('确认对选中的 ' + ids.length + ' 条记录执行平台操作?', function(index) { + Fast.api.ajax({ + url: 'feed_weight/batch_platform', + data: {ids: ids.join(','), action: action} + }, function(data, ret) { + Toastr.success(ret.msg); + table.bootstrapTable('refresh'); + Layer.close(index); + }); + }); + }); + + // 重置推送计数 + $(document).on('click', '.btn-reset-push', function() { + var ids = Table.api.selectedids(table); + if (!ids.length) { Toastr.warning('请选择要重置的行'); return; } + Layer.confirm('确认重置选中的推送计数?', function(index) { + Fast.api.ajax({ + url: 'feed_weight/reset_push', + data: {ids: ids.join(',')} + }, function(data, ret) { + Toastr.success(ret.msg); + table.bootstrapTable('refresh'); + Layer.close(index); + }); + }); + }); + + // 恢复默认权重 $(document).on('click', '.btn-reset-defaults', function() { - Layer.confirm('确认恢复所有权重为默认值?', function(index) { - $.ajax({ + Layer.confirm('确认恢复所有权重为默认值?所有平台开关将重置为开启。', function(index) { + Fast.api.ajax({ url: 'feed_weight/reset_defaults', - type: 'post', - dataType: 'json', - success: function(ret) { - if (ret.code === 1) { Toastr.success(ret.msg); table.bootstrapTable('refresh'); } - else { Toastr.error(ret.msg); } - Layer.close(index); - } + data: {} + }, function(data, ret) { + Toastr.success(ret.msg); + table.bootstrapTable('refresh'); + Layer.close(index); + }); + }); + }); + + // [需求2] 快捷操作 - 平台开关(开关样式按钮) + $(document).on('click', '.switch-platform-btn', function() { + var platform = $(this).data('platform'); + var enable = $(this).data('enable'); + if (!platform) return; + var ids = Table.api.selectedids(table); + var scope = ids.length > 0 ? '选中的 ' + ids.length + ' 个分类' : '所有已启用的分类'; + Layer.confirm('确认' + (enable ? '开启' : '关闭') + scope + '的' + platformIcons[platform] + platformNames[platform] + '平台?', function(index) { + var params = {platform: platform, enable: enable ? 1 : 0}; + if (ids.length > 0) { params.ids = ids.join(','); } + Fast.api.ajax({ + url: 'feed_weight/quick_platform', + data: params + }, function(data, ret) { + Toastr.success(ret.msg); + table.bootstrapTable('refresh'); + Layer.close(index); + }); + }); + }); + + // [需求3] 分类快捷开关 - 多选 + $(document).on('click', '.btn-quick-categories', function() { + var enable = $(this).data('enable'); + var selectedTypes = []; + $('#quick-category-list input[type="checkbox"]:checked').each(function() { + selectedTypes.push($(this).val()); + }); + if (selectedTypes.length === 0) { + Toastr.warning('请至少选择一个分类'); + return; + } + var actionText = enable ? '启用' : '禁用'; + Layer.confirm('确认' + actionText + '选中的 ' + selectedTypes.length + ' 个分类?', function(index) { + Fast.api.ajax({ + url: 'feed_weight/batch_category_enable', + data: {feed_types: selectedTypes.join(','), enable: enable ? 1 : 0} + }, function(data, ret) { + Toastr.success(ret.msg); + table.bootstrapTable('refresh'); + // 更新快捷面板checkbox状态 + updateCategoryCheckboxes(selectedTypes, enable); + Layer.close(index); + }); + }); + }); + + // [需求2] 全局操作 - 开关样式 + $(document).on('click', '.switch-all-platforms', function() { + var enable = $(this).data('enable'); + Layer.confirm('确认' + (enable ? '开启' : '关闭') + '所有已启用分类的所有平台?', function(index) { + Fast.api.ajax({ + url: 'feed_weight/quick_all_platforms', + data: {enable: enable ? 1 : 0} + }, function(data, ret) { + Toastr.success(ret.msg); + table.bootstrapTable('refresh'); + Layer.close(index); + }); + }); + }); + + $(document).on('click', '.switch-all-categories', function() { + var enable = $(this).data('enable'); + Layer.confirm('确认' + (enable ? '启用' : '禁用') + '所有分类?', function(index) { + Fast.api.ajax({ + url: 'feed_weight/quick_all_categories', + data: {enable: enable ? 1 : 0} + }, function(data, ret) { + Toastr.success(ret.msg); + table.bootstrapTable('refresh'); + // 更新快捷面板checkbox状态 + var allTypes = []; + $('#quick-category-list input[type="checkbox"]').each(function() { + allTypes.push($(this).val()); + }); + updateCategoryCheckboxes(allTypes, enable); + Layer.close(index); }); }); }); diff --git a/lib/app/app.dart b/lib/app/app.dart index 98daf2d9..e00949bf 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -26,6 +26,7 @@ import 'package:flutter/services.dart'; import '../core/services/device/quick_actions_service.dart'; import '../core/services/device/macos_platform_service.dart'; import '../core/services/data/home_widget_service.dart'; +import '../core/storage/database/app_database.dart'; import '../core/services/ui/status_bar_service.dart'; import '../core/services/accessibility/accessibility_service.dart'; import '../core/router/app_router.dart' show appRouter, rootNavigatorKey; @@ -152,8 +153,15 @@ class _XianyanAppState extends ConsumerState if (pu.isOhos) return; _dataManagementChannel.setMethodCallHandler((call) async { - if (call.method == 'open_data_management') { - _navigateToDataManagement(); + switch (call.method) { + case 'open_data_management': + _navigateToDataManagement(); + case 'navigate_to_cache_management': + _navigateToCacheManagement(); + case 'navigate_to_data_management': + _navigateToDataManagement(); + case 'clear_all_data': + await _clearAllAppData(); } }); @@ -196,6 +204,59 @@ class _XianyanAppState extends ConsumerState }).catchError((_) {}); } + /// 从原生管理空间对话框跳转到缓存管理页面 + void _navigateToCacheManagement() { + Future.delayed(const Duration(milliseconds: 300), () { + final context = rootNavigatorKey.currentContext; + if (context == null) { + Log.w('📦 [DataManagement] context不可用,延迟导航到缓存管理'); + return; + } + + Log.i('📦 [DataManagement] 导航到缓存管理页面'); + // ignore: use_build_context_synchronously + if (!context.mounted) return; + if (pu.isOhos) { + OhosNavBridge.push(context, AppRoutes.cacheManagement); + } else { + appRouter.push(AppRoutes.cacheManagement); + } + }).catchError((_) {}); + } + + /// 一键清理所有应用数据(从原生管理空间对话框触发) + Future _clearAllAppData() async { + try { + Log.i('📦 [DataManagement] 开始一键清理所有应用数据'); + final db = AppDatabase.instance; + await db.clearAllFavorites(); + await db.clearAllReadHistory(); + await db.clearAllNotes(); + await db.clearAllShareHistory(); + await db.clearFeedCache(); + await db.clearAllHanziCache(); + await db.clearOfflineQueue(); + ref.read(generalSettingsProvider.notifier).clearCache(); + Log.i('📦 [DataManagement] 一键清理完成'); + + // 清理完成后显示提示 + Future.delayed(const Duration(milliseconds: 300), () { + final context = rootNavigatorKey.currentContext; + if (context != null && context.mounted) { + AppToast.showSuccess('所有数据已清理完成'); + } + }); + } catch (e) { + Log.e('📦 [DataManagement] 一键清理失败', e); + Future.delayed(const Duration(milliseconds: 300), () { + final context = rootNavigatorKey.currentContext; + if (context != null && context.mounted) { + AppToast.showError('清理失败,请重试'); + } + }); + } + } + void _handlePendingDataManagement() { if (!_pendingDataManagement) return; _navigateToDataManagement(); diff --git a/lib/core/network/api_interceptor.dart b/lib/core/network/api_interceptor.dart index 078f465c..1d29938f 100644 --- a/lib/core/network/api_interceptor.dart +++ b/lib/core/network/api_interceptor.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — API 拦截器 /// 创建时间: 2026-04-20 -/// 更新时间: 2026-05-24 +/// 更新时间: 2026-06-09 /// 作用: 请求/响应拦截,统一注入 Token / 处理通用逻辑 -/// 上次更新: 版本号改为AppVersion.version动态获取 +/// 上次更新: currentPlatform增加try-catch兜底,防止平台检测异常导致崩溃 /// ============================================================ import 'dart:async'; @@ -13,6 +13,7 @@ import 'package:logger/logger.dart'; import '../constants/app_constants.dart'; import '../storage/secure_storage.dart'; +import '../utils/platform/platform_utils.dart' as pu; class ApiInterceptor extends Interceptor { static final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0)); @@ -20,12 +21,29 @@ class ApiInterceptor extends Interceptor { static bool _isRefreshing = false; static final List<_RetryRequest> _retryQueue = []; + /// 获取当前平台标识,用于服务端按平台过滤内容 + /// 返回值与服务端 platform_enabled 字段的 key 对应 + /// 兜底逻辑:平台检测异常时返回 'other',确保不会因检测失败而崩溃 + static String get currentPlatform { + try { + if (pu.isAndroid) return 'android'; + if (pu.isIOS) return 'ios'; + if (pu.isOhos) return 'harmony'; + if (pu.isMacOS) return 'macos'; + if (pu.isWindows) return 'win'; + if (pu.isWeb) return 'web'; + } catch (e) { + _logger.w('平台检测异常,使用默认值: other', error: e); + } + return 'other'; + } + @override void onRequest( RequestOptions options, RequestInterceptorHandler handler, ) async { - options.headers['X-Platform'] = 'flutter'; + options.headers['X-Platform'] = currentPlatform; options.headers['X-Version'] = AppVersion.version; final token = await SecureStorage.authToken; diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index acd53c3a..a30044c7 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 路由配置(主入口) // 创建时间: 2026-04-20 -// 更新时间: 2026-06-05 +// 更新时间: 2026-06-09 // 作用: go_router 路由表组装 + ShellRoute 布局壳 + iOS 风格转场 + 深链接重定向 -// 上次更新: 路由观察日志降级为debug级别,减少IDE日志量 +// 上次更新: 深度链接解析重构为配置驱动,删除5个硬编码switch方法 // ============================================================ import 'package:bot_toast/bot_toast.dart'; @@ -23,6 +23,7 @@ import '../utils/ui/page_transitions.dart'; import '../services/device/shake_detector.dart'; import 'app_routes.dart'; +import 'deep_link_resolver.dart'; import 'settings_routes.dart'; import 'tool_routes.dart'; import 'editor_router.dart'; @@ -57,8 +58,13 @@ final GoRouter appRouter = GoRouter( path: AppRoutes.onboarding, name: 'onboarding', parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => - iosSlideTransition(state: state, child: const OnboardingPage()), + pageBuilder: (context, state) { + final skipAgreement = state.uri.queryParameters['skip_agreement'] == 'true'; + return iosSlideTransition( + state: state, + child: OnboardingPage(skipAgreement: skipAgreement), + ); + }, ), StatefulShellRoute.indexedStack( @@ -165,6 +171,7 @@ String? _handleRedirect(BuildContext context, GoRouterState state) { /// 统一深度链接URI解析入口 /// 支持 xianyan:// scheme 和 https://s2ss.com 通用链接 /// 供 GoRouter redirect 和 DeepLinkService 共用 +/// 解析逻辑委托给 DeepLinkResolver(配置驱动,从 route_registry 自动构建映射表) class AppRouter { AppRouter._(); @@ -174,206 +181,18 @@ class AppRouter { final scheme = uri.scheme.toLowerCase(); if (scheme == 'xianyan') { - return _resolveCustomScheme(uri); + return DeepLinkResolver.resolveCustomScheme(uri); } if (scheme == 'https' || scheme == 'http') { final host = uri.host.toLowerCase(); if (host == 's2ss.com' || host == 'www.s2ss.com') { - return _resolveHttps(uri); + return DeepLinkResolver.resolveHttps(uri); } } return null; } - - /// 解析 xianyan:// scheme 链接 - /// 格式: xianyan://[/] - /// 例如: xianyan://tool/hanzi, xianyan://settings/theme - static String? _resolveCustomScheme(Uri uri) { - final host = uri.host; - final path = uri.path; - - return switch (host) { - 'home' => AppRoutes.home, - 'discover' => AppRoutes.discover, - 'profile' => AppRoutes.profile, - 'search' => AppRoutes.search, - 'fortune' => AppRoutes.dailyFortune, - 'article' => path.isNotEmpty ? path : AppRoutes.home, - 'notes' => AppRoutes.noteList, - 'inspiration' => AppRoutes.inspiration, - 'favorites' => AppRoutes.favorites, - 'history' => AppRoutes.history, - 'tool' => _resolveToolPath(path), - 'editor' => AppRoutes.editor, - 'signin' => AppRoutes.signin, - 'weather' => - path.isNotEmpty ? AppRoutes.weatherSettings : AppRoutes.weather, - 'poetry' => path.isNotEmpty ? AppRoutes.poetrySettings : AppRoutes.poetry, - 'pomodoro' => AppRoutes.pomodoro, - 'countdown' => AppRoutes.countdown, - 'solar-term' => AppRoutes.solarTerm, - 'knowledge-graph' => AppRoutes.knowledgeGraph, - 'study-plan' => AppRoutes.studyPlan, - 'notification-settings' => AppRoutes.notificationSettings, - 'statistics' => AppRoutes.statistics, - 'achievement' => AppRoutes.achievement, - 'rank' => AppRoutes.rank, - 'learning' => AppRoutes.learning, - 'checkin' => AppRoutes.achievement, - 'settings' => _resolveSettingsPath(path), - _ => _resolvePathFallback(path), - }; - } - - /// 解析 https://s2ss.com 通用链接 - /// 格式: https://s2ss.com/[/] - static String? _resolveHttps(Uri uri) { - final segments = uri.pathSegments; - if (segments.isEmpty) return AppRoutes.home; - - final first = segments.first; - return switch (first) { - 'fortune' => AppRoutes.dailyFortune, - 'article' => '/${segments.join('/')}', - 'notes' => AppRoutes.noteList, - 'home' => AppRoutes.home, - 'discover' => AppRoutes.discover, - 'profile' => AppRoutes.profile, - 'inspiration' => AppRoutes.inspiration, - 'search' => AppRoutes.search, - 'favorites' => AppRoutes.favorites, - 'history' => AppRoutes.history, - 'tool' => - segments.length > 1 - ? _resolveToolPath('/${segments[1]}') - : AppRoutes.discover, - 'editor' => AppRoutes.editor, - 'weather' => - segments.length > 1 ? AppRoutes.weatherSettings : AppRoutes.weather, - 'poetry' => - segments.length > 1 ? AppRoutes.poetrySettings : AppRoutes.poetry, - 'settings' => _resolveSettingsPath('/${segments.skip(1).join('/')}'), - 'achievement' => AppRoutes.achievement, - 'rank' => AppRoutes.rank, - 'learning' => AppRoutes.learning, - 'checkin' => AppRoutes.achievement, - _ => _resolvePathFallback('/${segments.join('/')}'), - }; - } - - /// 解析工具子路由路径 - static String? _resolveToolPath(String path) { - return switch (path) { - '/hanzi' => AppRoutes.hanziTool, - '/ocr' => '/tool/ocr', - '/colors' => '/tool/china_colors', - '/china_colors' => '/tool/china_colors', - '/hot' => '/tool/list', - '/calc' => '/tool/calc', - '/list' => '/tool/list', - '/offline' => AppRoutes.offline, - '/cache' => AppRoutes.cacheManagement, - '/readlater' => AppRoutes.readLater, - '/favorites' => AppRoutes.favorites, - '/history' => AppRoutes.history, - '/notes' => AppRoutes.noteList, - '/signin' => AppRoutes.signin, - '/weather' => AppRoutes.weather, - '/poetry' => AppRoutes.poetry, - '/pomodoro' => AppRoutes.pomodoro, - '/countdown' => AppRoutes.countdown, - '/solar-term' => AppRoutes.solarTerm, - '/knowledge-graph' => AppRoutes.knowledgeGraph, - '/study-plan' => AppRoutes.studyPlan, - '/chengyu' => '/tool/chengyu', - '/zuci' => '/tool/zuci', - '/cidian' => '/tool/cidian', - '/jinyici' => '/tool/jinyici', - '/juzi' => '/tool/juzi', - '/danci' => '/tool/danci', - '/suoxie' => '/tool/suoxie', - '/nick' => '/tool/nick', - '/drug' => '/tool/drug', - '/food' => '/tool/food', - '/herbal' => '/tool/herbal', - '/pianfang' => '/tool/pianfang', - '/tisana' => '/tool/tisana', - '/changshi' => '/tool/changshi', - '/zgjm' => '/tool/zgjm', - '/illness' => '/tool/illness', - '/surname' => '/tool/surname', - '/jieqi' => '/tool/jieqi', - '/nation' => '/tool/nation', - '/xiehouyu' => '/tool/xiehouyu', - '/riddle' => '/tool/riddle', - '/brainteaser' => '/tool/brainteaser', - '/couplet' => '/tool/couplet', - '/wisdom' => '/tool/wisdom', - '/saying' => '/tool/saying', - '/lyric' => '/tool/lyric', - '/story' => '/tool/story', - '/zuowen' => '/tool/zuowen', - '/why' => '/tool/why', - '/joke' => '/tool/joke', - '/jiufang' => '/tool/jiufang', - '/exchange_rate' => '/tool/exchange_rate', - '/rss_reader' => '/tool/rss_reader', - '/game' => '/game', - '/classics' => '/classics', - '/health' => '/health', - '/articles' => '/articles', - '/check' => '/check', - _ => AppRoutes.discover, - }; - } - - /// 解析设置子路由路径 - static String? _resolveSettingsPath(String path) { - return switch (path) { - '/theme' => AppRoutes.themeSettings, - '/general' => AppRoutes.generalSettings, - '/account' => AppRoutes.accountSettings, - '/data' => AppRoutes.dataManagement, - '/notifications' => AppRoutes.notificationSettings, - '/language' => AppRoutes.languageSettings, - '/fonts' => AppRoutes.fontManagement, - '/app-lock' => AppRoutes.appLockSettings, - _ => AppRoutes.generalSettings, - }; - } - - /// 路径兜底解析:将 URI path 段直接映射为内部路由 - static String? _resolvePathFallback(String path) { - if (path.isEmpty || path == '/') return AppRoutes.home; - - final segments = path.split('/').where((s) => s.isNotEmpty).toList(); - if (segments.isEmpty) return AppRoutes.home; - - return switch (segments.first) { - 'fortune' => AppRoutes.dailyFortune, - 'article' => path, - 'notes' => AppRoutes.noteList, - 'home' => AppRoutes.home, - 'discover' => AppRoutes.discover, - 'profile' => AppRoutes.profile, - 'inspiration' => AppRoutes.inspiration, - 'search' => AppRoutes.search, - 'favorites' => AppRoutes.favorites, - 'history' => AppRoutes.history, - 'settings' => path, - 'weather' => - segments.length > 1 ? AppRoutes.weatherSettings : AppRoutes.weather, - 'poetry' => - segments.length > 1 ? AppRoutes.poetrySettings : AppRoutes.poetry, - 'achievement' => AppRoutes.achievement, - 'rank' => AppRoutes.rank, - 'learning' => AppRoutes.learning, - 'checkin' => AppRoutes.achievement, - _ => path, - }; - } } class _OhosRouteObserver extends NavigatorObserver { diff --git a/lib/core/router/app_routes.dart b/lib/core/router/app_routes.dart index ece7c8f8..390e52a7 100644 --- a/lib/core/router/app_routes.dart +++ b/lib/core/router/app_routes.dart @@ -128,8 +128,13 @@ class AppRoutes { static const String toolCenterSettings = '/tool-center/settings'; static const String rssReader = '/tool/rss_reader'; static const String onboarding = '/onboarding'; + /// 主动查看引导页(从关于页等入口),带此参数跳过redirect拦截 static const String onboardingReview = '/onboarding?review=true'; + + /// 安卓端从软件内打开引导页时跳过协议页,直接从欢迎与指引开始 + static const String onboardingSkipAgreement = + '/onboarding?review=true&skip_agreement=true'; static const String experimentalFeatures = '/settings/experimental-features'; static const String exchangeRate = '/tool/exchange_rate'; static const String nickTool = '/tool/nick'; diff --git a/lib/core/router/deep_link_resolver.dart b/lib/core/router/deep_link_resolver.dart new file mode 100644 index 00000000..b80bce56 --- /dev/null +++ b/lib/core/router/deep_link_resolver.dart @@ -0,0 +1,206 @@ +// ============================================================ +// 闲言APP — 深度链接配置驱动解析器 +// 创建时间: 2026-06-09 +// 更新时间: 2026-06-09 +// 作用: 从 route_registry 自动构建深度链接映射表,替代硬编码 switch +// 上次更新: 初始创建,替代 app_router.dart 中5个硬编码 switch 方法 +// ============================================================ + +import 'package:xianyan/core/router/route_registry.dart'; +import 'package:xianyan/core/router/app_routes.dart'; +import 'package:xianyan/core/utils/logger.dart' show Log, LogCategory; + +// ----------------------------------------------------------- +// 深度链接映射条目(内部使用) +// ----------------------------------------------------------- + +class _DeepLinkEntry { + const _DeepLinkEntry({ + required this.alias, + required this.targetRoute, + this.hasSubPath = false, + }); + + /// 别名模式(如 'xianyan://home', 'https://s2ss.com/fortune', '/tool/hanzi') + final String alias; + + /// 目标内部路由路径 + final String targetRoute; + + /// 是否支持子路径(如 xianyan://article/xxx 中的 xxx) + final bool hasSubPath; +} + +// ----------------------------------------------------------- +// 配置驱动的深度链接解析器 +// ----------------------------------------------------------- + +/// 从 route_registry 中的 deepLinkAliases 自动构建映射表 +/// 替代原有 _resolveCustomScheme / _resolveHttps / _resolveToolPath / +/// _resolveSettingsPath / _resolvePathFallback 五个硬编码 switch 方法 +class DeepLinkResolver { + DeepLinkResolver._(); + + /// 懒加载的映射条目缓存 + static List<_DeepLinkEntry>? _entries; + + /// 获取所有映射条目(懒加载,首次访问时从 route_registry 构建) + static List<_DeepLinkEntry> get entries { + if (_entries != null) return _entries!; + _entries = _buildEntries(); + return _entries!; + } + + /// 从 route_registry 构建映射表 + static List<_DeepLinkEntry> _buildEntries() { + final result = <_DeepLinkEntry>[]; + + for (final route in routeRegistry) { + for (final alias in route.deepLinkAliases) { + // 检测是否支持子路径(别名以 /* 结尾) + final hasSubPath = alias.endsWith('/*'); + final cleanAlias = + hasSubPath ? alias.substring(0, alias.length - 2) : alias; + + result.add(_DeepLinkEntry( + alias: cleanAlias, + targetRoute: route.path, + hasSubPath: hasSubPath, + )); + } + } + + Log.i('🔗 [DeepLink] 映射表构建完成: ${result.length} 条规则', null, null, + LogCategory.router); + return result; + } + + /// 清除缓存(测试用或路由热更新时调用) + static void invalidateCache() { + _entries = null; + } + + // =========================================================== + // 解析入口 + // =========================================================== + + /// 解析 xianyan:// scheme 链接 + /// 格式: xianyan://[/] + /// 例如: xianyan://tool/hanzi, xianyan://settings/theme + static String? resolveCustomScheme(Uri uri) { + final host = uri.host; + final path = uri.path; + final prefix = 'xianyan://$host'; + + // 1. 精确匹配 + 子路径匹配 + for (final entry in entries) { + if (!entry.alias.startsWith('xianyan://')) continue; + + if (entry.hasSubPath) { + // 子路径匹配: xianyan://article/* → 匹配 xianyan://article/xxx + if (entry.alias == prefix) { + return path.isNotEmpty ? path : entry.targetRoute; + } + } else { + // 精确匹配(无路径): xianyan://home + if (entry.alias == prefix && path.isEmpty) { + return entry.targetRoute; + } + // 带路径匹配: xianyan://tool/hanzi + if (path.isNotEmpty && entry.alias == '$prefix$path') { + return entry.targetRoute; + } + } + } + + // 2. 兜底: 尝试路径匹配 + return _resolvePathFallback(path); + } + + /// 解析 https://s2ss.com 通用链接 + /// 格式: https://s2ss.com/[/] + static String? resolveHttps(Uri uri) { + final segments = uri.pathSegments; + if (segments.isEmpty) return AppRoutes.home; + + final first = segments.first; + final prefix = 'https://s2ss.com/$first'; + + // 1. 精确匹配 + 子路径匹配 + for (final entry in entries) { + if (!entry.alias.startsWith('https://s2ss.com/')) continue; + + if (entry.hasSubPath) { + // 子路径匹配: https://s2ss.com/article/* + if (entry.alias == prefix) { + return '/${segments.join('/')}'; + } + } else if (segments.length == 1) { + // 精确匹配(单段路径): https://s2ss.com/fortune + if (entry.alias == prefix) { + return entry.targetRoute; + } + } + } + + // 2. 兜底: 尝试路径匹配 + return _resolvePathFallback('/${segments.join('/')}'); + } + + // =========================================================== + // 内部辅助方法 + // =========================================================== + + /// 路径兜底解析:将 URI path 段直接映射为内部路由 + static String? _resolvePathFallback(String path) { + if (path.isEmpty || path == '/') return AppRoutes.home; + + // 从映射表中查找 /tool/xxx 格式的路径别名 + for (final entry in entries) { + if (!entry.alias.startsWith('/')) continue; + + if (entry.alias == path) { + return entry.targetRoute; + } + } + + // 最终兜底:直接返回路径(如果是内部路由格式) + if (path.startsWith('/')) { + return path; + } + + return null; + } + + // =========================================================== + // 配置验证 + // =========================================================== + + /// 验证映射配置完整性 + /// 检查所有 deepLinkAliases 的目标路由是否在 routeRegistry 中存在 + /// 检查是否有重复别名 + /// 返回错误列表,为空表示配置正确 + static List validate() { + final errors = []; + final validPaths = routeRegistry.map((r) => r.path).toSet(); + + // 检查目标路由是否存在 + for (final entry in entries) { + if (!validPaths.contains(entry.targetRoute)) { + errors.add( + '深度链接映射目标路由不存在: ${entry.alias} → ${entry.targetRoute}'); + } + } + + // 检查重复别名 + final seenAliases = {}; + for (final entry in entries) { + if (seenAliases.contains(entry.alias)) { + errors.add('重复的深度链接别名: ${entry.alias}'); + } + seenAliases.add(entry.alias); + } + + return errors; + } +} diff --git a/lib/core/router/route_def.dart b/lib/core/router/route_def.dart index f324e9f8..6670540a 100644 --- a/lib/core/router/route_def.dart +++ b/lib/core/router/route_def.dart @@ -1,7 +1,9 @@ // ============================================================ // 闲言APP — 路由定义核心类型 // 创建时间: 2026-06-18 +// 更新时间: 2026-06-09 // 作用: RouteDef/RouteContext/RouteModule/RouteTransition 统一路由定义类型 +// 上次更新: RouteDef 新增 deepLinkAliases 字段,支持配置驱动深度链接映射 // ============================================================ import 'package:flutter/widgets.dart'; @@ -56,6 +58,7 @@ class RouteDef { this.middlewares = const [], this.redirectTo, this.children = const [], + this.deepLinkAliases = const [], }); final String path; @@ -69,6 +72,12 @@ class RouteDef { final String? redirectTo; final List children; + /// 深度链接别名列表 + /// 格式: ['xianyan://host', 'https://s2ss.com/segment', '/tool/subpath'] + /// 以 /* 结尾表示支持子路径匹配(如 'xianyan://article/*') + /// 解析器会自动从这些别名构建映射表 + final List deepLinkAliases; + bool get isSimple => page != null && builder == null; bool get isRedirect => redirectTo != null; bool get hasChildren => children.isNotEmpty; diff --git a/lib/core/router/route_registry.dart b/lib/core/router/route_registry.dart index ca788c0d..71824eab 100644 --- a/lib/core/router/route_registry.dart +++ b/lib/core/router/route_registry.dart @@ -1,7 +1,9 @@ // ============================================================ // 闲言APP — 统一路由注册表 // 创建时间: 2026-06-18 +// 更新时间: 2026-06-09 // 作用: 所有路由定义的单一数据源,GoRoute和OhosRouteEntry均从此生成 +// 上次更新: 为各路由添加 deepLinkAliases,支持配置驱动深度链接映射 // 新增路由只需在本文件添加 RouteDef 即可,无需手动同步多个文件 // ============================================================ @@ -40,6 +42,7 @@ import 'package:xianyan/features/mine/achievement/presentation/achievement_cente import 'package:xianyan/features/task/presentation/daily_task_page.dart'; import 'package:xianyan/features/rank/presentation/rank_page.dart'; import 'package:xianyan/features/source/presentation/source_page.dart'; +import 'package:xianyan/features/discover/presentation/pages/readlater_settings_page.dart'; import 'package:xianyan/features/discover/presentation/pages/tool/hanzi_tool_page.dart'; import 'package:xianyan/features/discover/presentation/pages/tool/calc_tool_page.dart'; @@ -162,6 +165,7 @@ final List routeRegistry = [ name: 'signin', module: RouteModule.user, page: () => const SigninPage(), + deepLinkAliases: ['xianyan://signin'], ), RouteDef( path: AppRoutes.myDevices, @@ -180,12 +184,22 @@ final List routeRegistry = [ name: 'favorites', module: RouteModule.user, page: () => const FavoritePage(), + deepLinkAliases: [ + 'xianyan://favorites', + 'https://s2ss.com/favorites', + '/tool/favorites', + ], ), RouteDef( path: AppRoutes.history, name: 'history', module: RouteModule.user, page: () => const HistoryPage(), + deepLinkAliases: [ + 'xianyan://history', + 'https://s2ss.com/history', + '/tool/history', + ], ), RouteDef( path: AppRoutes.likes, @@ -198,18 +212,27 @@ final List routeRegistry = [ name: 'offline', module: RouteModule.user, page: () => const OfflinePage(), + deepLinkAliases: ['/tool/offline'], ), RouteDef( path: AppRoutes.cacheManagement, name: 'cache-management', module: RouteModule.user, page: () => const CacheManagementPage(), + deepLinkAliases: ['/tool/cache'], ), RouteDef( path: AppRoutes.readLater, name: 'read-later', module: RouteModule.user, page: () => const ReadLaterPage(), + deepLinkAliases: ['/tool/readlater'], + ), + RouteDef( + path: '/readlater-settings', + name: 'readlater-settings', + module: RouteModule.user, + page: () => const ReadlaterSettingsPage(), ), RouteDef( path: AppRoutes.footprint, @@ -307,6 +330,7 @@ final List routeRegistry = [ name: 'learning', module: RouteModule.user, page: () => const LearningCenterPage(), + deepLinkAliases: ['xianyan://learning', 'https://s2ss.com/learning'], ), const RouteDef( path: AppRoutes.learningProgress, @@ -319,6 +343,12 @@ final List routeRegistry = [ name: 'achievement', module: RouteModule.user, page: () => const AchievementPage(), + deepLinkAliases: [ + 'xianyan://achievement', + 'xianyan://checkin', + 'https://s2ss.com/achievement', + 'https://s2ss.com/checkin', + ], ), const RouteDef( path: AppRoutes.checkin, @@ -343,6 +373,7 @@ final List routeRegistry = [ name: 'rank', module: RouteModule.user, page: () => const RankPage(), + deepLinkAliases: ['xianyan://rank', 'https://s2ss.com/rank'], ), RouteDef( path: AppRoutes.source, @@ -366,6 +397,7 @@ final List routeRegistry = [ return HanziToolPage(config: config); }, ohosBuilder: buildHanziToolOhosWidget, + deepLinkAliases: ['/tool/hanzi'], ), RouteDef( path: '/tool/calc', @@ -377,12 +409,14 @@ final List routeRegistry = [ return CalcToolPage(config: config); }, ohosBuilder: buildCalcToolOhosWidget, + deepLinkAliases: ['/tool/calc'], ), RouteDef( path: '/tool/china_colors', name: 'china-colors', module: RouteModule.tool, page: () => const ChinaColorsPage(), + deepLinkAliases: ['/tool/colors', '/tool/china_colors'], ), RouteDef( path: '/tool/list', @@ -408,18 +442,21 @@ final List routeRegistry = [ ); }, ohosBuilder: buildToolListOhosWidget, + deepLinkAliases: ['/tool/hot', '/tool/list'], ), RouteDef( path: '/tool/ocr', name: 'ocr-tool', module: RouteModule.tool, page: () => const OcrToolPage(), + deepLinkAliases: ['/tool/ocr'], ), - RouteDef( + const RouteDef( path: '/tool/nick', name: 'nick-tool', module: RouteModule.tool, ohosBuilder: buildNickToolOhosWidget, + deepLinkAliases: ['/tool/nick'], ), RouteDef( path: '/tool/pinyin', @@ -550,6 +587,7 @@ final List routeRegistry = [ name: 'exchange-rate', module: RouteModule.tool, page: () => const ExchangeRatePage(), + deepLinkAliases: ['/tool/exchange_rate'], ), RouteDef( path: AppRoutes.toolCenterSettings, @@ -562,6 +600,7 @@ final List routeRegistry = [ name: 'rss-reader', module: RouteModule.tool, page: () => const RssReaderPage(), + deepLinkAliases: ['/tool/rss_reader'], ), // ============================================================ @@ -572,6 +611,7 @@ final List routeRegistry = [ name: 'search', module: RouteModule.content, page: () => const SearchPage(), + deepLinkAliases: ['xianyan://search', 'https://s2ss.com/search'], ), RouteDef( path: AppRoutes.noteList, @@ -579,6 +619,11 @@ final List routeRegistry = [ module: RouteModule.content, page: () => const NoteListPage(), middlewares: [AuthMiddleware()], + deepLinkAliases: [ + 'xianyan://notes', + 'https://s2ss.com/notes', + '/tool/notes', + ], ), RouteDef( path: AppRoutes.noteEdit, @@ -594,6 +639,7 @@ final List routeRegistry = [ name: 'statistics', module: RouteModule.content, page: () => const StatisticsPage(), + deepLinkAliases: ['xianyan://statistics'], ), RouteDef( path: AppRoutes.correction, @@ -606,6 +652,7 @@ final List routeRegistry = [ name: 'inspiration', module: RouteModule.content, page: () => const InspirationPage(), + deepLinkAliases: ['xianyan://inspiration', 'https://s2ss.com/inspiration'], ), RouteDef( path: AppRoutes.contentDiscover, @@ -637,18 +684,21 @@ final List routeRegistry = [ name: 'classics', module: RouteModule.content, page: () => const ClassicsPage(), + deepLinkAliases: ['/classics'], ), RouteDef( path: AppRoutes.articles, name: 'articles', module: RouteModule.content, page: () => const ArticleListPage(), + deepLinkAliases: ['/articles'], ), RouteDef( path: AppRoutes.articleDetail, name: 'article-detail', module: RouteModule.content, builder: (ctx) => ArticleDetailPage(articleId: ctx.getIntParam('id')), + deepLinkAliases: ['xianyan://article/*', 'https://s2ss.com/article/*'], ), RouteDef( path: AppRoutes.articleEdit, @@ -670,6 +720,7 @@ final List routeRegistry = [ name: 'check', module: RouteModule.content, page: () => const CheckPage(), + deepLinkAliases: ['/check'], ), RouteDef( path: AppRoutes.dailyCard, @@ -704,30 +755,42 @@ final List routeRegistry = [ name: 'poetry', module: RouteModule.content, page: () => const PoetryPage(), + deepLinkAliases: [ + 'xianyan://poetry', + 'https://s2ss.com/poetry', + '/tool/poetry', + ], ), RouteDef( path: AppRoutes.poetrySettings, name: 'poetry-settings', module: RouteModule.content, page: () => const PoetrySettingsPage(), + deepLinkAliases: [ + 'xianyan://poetry/settings', + 'https://s2ss.com/poetry/settings', + ], ), RouteDef( path: AppRoutes.knowledgeGraph, name: 'knowledge-graph', module: RouteModule.content, page: () => const KnowledgeGraphPage(), + deepLinkAliases: ['xianyan://knowledge-graph', '/tool/knowledge-graph'], ), RouteDef( path: AppRoutes.studyPlan, name: 'study-plan', module: RouteModule.content, page: () => const StudyPlanPage(), + deepLinkAliases: ['xianyan://study-plan', '/tool/study-plan'], ), RouteDef( path: AppRoutes.dailyFortune, name: 'daily-fortune', module: RouteModule.content, page: () => const DailyFortunePage(), + deepLinkAliases: ['xianyan://fortune', 'https://s2ss.com/fortune'], ), RouteDef( path: AppRoutes.dailyFortuneSettings, @@ -784,24 +847,28 @@ final List routeRegistry = [ name: 'theme-settings', module: RouteModule.settings, page: () => const ThemeSettingsPage(), + deepLinkAliases: ['xianyan://settings/theme'], ), RouteDef( path: AppRoutes.generalSettings, name: 'general-settings', module: RouteModule.settings, page: () => const GeneralSettingsPage(), + deepLinkAliases: ['xianyan://settings/general', 'xianyan://settings'], ), RouteDef( path: AppRoutes.languageSettings, name: 'language-settings', module: RouteModule.settings, page: () => const LanguageSettingsPage(), + deepLinkAliases: ['xianyan://settings/language'], ), RouteDef( path: AppRoutes.accountSettings, name: 'account-settings', module: RouteModule.settings, page: () => const AccountSettingsPage(), + deepLinkAliases: ['xianyan://settings/account'], ), RouteDef( path: AppRoutes.passwordSettings, @@ -820,6 +887,7 @@ final List routeRegistry = [ name: 'data-management', module: RouteModule.settings, page: () => const DataManagementPage(), + deepLinkAliases: ['xianyan://settings/data'], ), RouteDef( path: AppRoutes.imageCache, @@ -838,12 +906,17 @@ final List routeRegistry = [ name: 'font-management', module: RouteModule.settings, page: () => const FontManagementPage(), + deepLinkAliases: ['xianyan://settings/fonts'], ), RouteDef( path: AppRoutes.notificationSettings, name: 'notification-settings', module: RouteModule.settings, page: () => const NotificationSettingsPage(), + deepLinkAliases: [ + 'xianyan://notification-settings', + 'xianyan://settings/notifications', + ], ), RouteDef( path: AppRoutes.smartModeSettings, @@ -898,6 +971,7 @@ final List routeRegistry = [ name: 'app-lock-settings', module: RouteModule.settings, page: () => const AppLockSettingsPage(), + deepLinkAliases: ['xianyan://settings/app-lock'], ), RouteDef( path: AppRoutes.plugin, @@ -932,42 +1006,56 @@ final List routeRegistry = [ name: 'weather', module: RouteModule.feature, page: () => const WeatherPage(), + deepLinkAliases: [ + 'xianyan://weather', + 'https://s2ss.com/weather', + '/tool/weather', + ], ), RouteDef( path: AppRoutes.weatherSettings, name: 'weather-settings', module: RouteModule.feature, page: () => const WeatherSettingsPage(), + deepLinkAliases: [ + 'xianyan://weather/settings', + 'https://s2ss.com/weather/settings', + ], ), RouteDef( path: AppRoutes.pomodoro, name: 'pomodoro', module: RouteModule.feature, page: () => const PomodoroPage(), + deepLinkAliases: ['xianyan://pomodoro', '/tool/pomodoro'], ), RouteDef( path: AppRoutes.countdown, name: 'countdown', module: RouteModule.feature, page: () => const CountdownPage(), + deepLinkAliases: ['xianyan://countdown', '/tool/countdown'], ), RouteDef( path: AppRoutes.solarTerm, name: 'solar-term', module: RouteModule.feature, page: () => const SolarTermPage(), + deepLinkAliases: ['xianyan://solar-term', '/tool/solar-term'], ), RouteDef( path: AppRoutes.health, name: 'health', module: RouteModule.feature, page: () => const HealthPage(), + deepLinkAliases: ['/health'], ), RouteDef( path: AppRoutes.game, name: 'game', module: RouteModule.feature, page: () => const GameCenterPage(), + deepLinkAliases: ['/game'], ), RouteDef( path: AppRoutes.qrcodeScanner, @@ -985,6 +1073,7 @@ final List routeRegistry = [ module: RouteModule.editor, transition: RouteTransition.heroine, builder: (ctx) => EditorPage(initialText: ctx.getQueryParam('text')), + deepLinkAliases: ['xianyan://editor', 'https://s2ss.com/editor'], ), RouteDef( path: '/editor/preview', diff --git a/lib/core/services/auth/permission_service.dart b/lib/core/services/auth/permission_service.dart index 0b7ddf34..0310a0b8 100644 --- a/lib/core/services/auth/permission_service.dart +++ b/lib/core/services/auth/permission_service.dart @@ -150,7 +150,6 @@ enum AppPermission { Permission.notification, CupertinoIcons.eye_fill, Color(0xFF5AC8FA), - group: PermissionGroup.optional, ); const AppPermission( @@ -304,7 +303,7 @@ class PermissionService { static const _shakeEnabledKey = 'shake_enabled'; static bool get isShakeEnabled => - KvStorage.getBool(_shakeEnabledKey, box: HiveBoxNames.userPrefs) ?? true; + KvStorage.getBool(_shakeEnabledKey, box: HiveBoxNames.userPrefs) ?? false; static Future setShakeEnabled(bool enabled) async { await KvStorage.setBool( diff --git a/lib/core/services/catcher2_config_service.dart b/lib/core/services/catcher2_config_service.dart index 227c33b3..2a801617 100644 --- a/lib/core/services/catcher2_config_service.dart +++ b/lib/core/services/catcher2_config_service.dart @@ -175,9 +175,9 @@ class _CopyableErrorDialog extends StatelessWidget { context: context, barrierDismissible: true, builder: (ctx) => CupertinoAlertDialog( - content: Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Text('已复制到剪贴板', style: const TextStyle(fontSize: 14)), + content: const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text('已复制到剪贴板', style: TextStyle(fontSize: 14)), ), actions: [ CupertinoDialogAction( diff --git a/lib/core/services/performance/app_lifecycle_gate.dart b/lib/core/services/performance/app_lifecycle_gate.dart index 74415d88..81c32a8d 100644 --- a/lib/core/services/performance/app_lifecycle_gate.dart +++ b/lib/core/services/performance/app_lifecycle_gate.dart @@ -9,6 +9,7 @@ import 'package:flutter/widgets.dart'; import 'package:xianyan/core/services/performance/performance_orchestrator.dart'; import 'package:xianyan/core/services/device/shake_detector.dart'; +import 'package:xianyan/core/services/auth/permission_service.dart'; import 'package:xianyan/core/services/clipboard_monitor_service.dart'; import 'package:xianyan/core/services/device/battery_info_service.dart'; import 'package:xianyan/core/utils/logger.dart'; @@ -55,7 +56,8 @@ mixin AppLifecycleGate on WidgetsBindingObserver { try { if (ShakeDetector.instance.isEnabled && - ShakeDetector.instance.hasActiveHandler) { + ShakeDetector.instance.hasActiveHandler && + PermissionService.isShakeEnabled) { ShakeDetector.instance.start(); } } catch (e) { diff --git a/lib/core/storage/app_storage.dart b/lib/core/storage/app_storage.dart index 7202189d..c8950b0b 100644 --- a/lib/core/storage/app_storage.dart +++ b/lib/core/storage/app_storage.dart @@ -208,7 +208,7 @@ class GeneralStorage { Future setAppLockEnabled(bool v) => KvStorage.setBool(_keyAppLockEnabled, v); - bool get shakeEnabled => KvStorage.getBool(_keyShakeEnabled) ?? true; + bool get shakeEnabled => KvStorage.getBool(_keyShakeEnabled) ?? false; Future setShakeEnabled(bool v) => KvStorage.setBool(_keyShakeEnabled, v); diff --git a/lib/features/auth/presentation/login_page.dart b/lib/features/auth/presentation/login_page.dart index ae51ce00..5657a875 100644 --- a/lib/features/auth/presentation/login_page.dart +++ b/lib/features/auth/presentation/login_page.dart @@ -493,7 +493,7 @@ class _LoginPageState extends ConsumerState ext, icon: CupertinoIcons.person_2, label: auth.legacyUser, - accentColor: ext.accentLight, + accentColor: ext.accentLight.withValues(alpha: 0.6), onTap: () => _switchLoginMode(_LoginMode.legacy), ), const SizedBox(width: AppSpacing.xl), diff --git a/lib/features/correction/presentation/correction_page.dart b/lib/features/correction/presentation/correction_page.dart index 0717698c..669aa19f 100644 --- a/lib/features/correction/presentation/correction_page.dart +++ b/lib/features/correction/presentation/correction_page.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 纠错页面 /// 创建时间: 2026-04-28 -/// 更新时间: 2026-06-01 +/// 更新时间: 2026-06-09 /// 作用: 提交内容纠错 -/// 上次更新: 未登录时增加邮箱输入框(选填)、提交前增加数学验证码 +/// 上次更新: 纠错记录来源标签移至右下角与状态对齐,服务端改为管理员 /// ============================================================ import 'dart:math'; @@ -20,6 +20,7 @@ import '../../../features/auth/providers/auth_provider.dart'; import '../../../shared/widgets/adaptive/adaptive_back_button.dart'; import '../../../shared/widgets/containers/glass_container.dart'; import '../../../shared/widgets/feedback/app_toast.dart'; +import '../../../shared/widgets/feedback/contact_email_sheet.dart'; import '../providers/correction_provider.dart'; import '../../../core/services/device/haptic_service.dart'; @@ -791,6 +792,52 @@ class _CorrectionPageState extends ConsumerState { }, ), ), + // 底部邮箱联系按钮 + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: GestureDetector( + onTap: () { + Navigator.pop(ctx); + ContactEmailSheet.show(context); + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + decoration: BoxDecoration( + color: ext.accent.withValues(alpha: 0.08), + borderRadius: AppRadius.lgBorder, + border: Border.all( + color: ext.accent.withValues(alpha: 0.15), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.envelope_fill, + size: 18, + color: ext.accent, + ), + const SizedBox(width: AppSpacing.sm), + Text( + '联系邮箱反馈', + style: AppTypography.subhead.copyWith( + color: ext.accent, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(height: AppSpacing.md), ], ), ); @@ -859,25 +906,6 @@ class _CorrectionPageState extends ConsumerState { ), ), const Spacer(), - Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: 2, - ), - decoration: BoxDecoration( - color: (isLocal ? ext.accent : CupertinoColors.systemGreen) - .withValues(alpha: 0.12), - borderRadius: AppRadius.smBorder, - ), - child: Text( - isLocal ? '📱 本地' : '☁️ 服务端', - style: AppTypography.caption1.copyWith( - color: isLocal ? ext.accent : CupertinoColors.systemGreen, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: AppSpacing.xs), Container( padding: const EdgeInsets.symmetric( horizontal: AppSpacing.sm, @@ -898,9 +926,32 @@ class _CorrectionPageState extends ConsumerState { ], ), const SizedBox(height: AppSpacing.xs), - Text( - dateStr, - style: AppTypography.caption1.copyWith(color: ext.textHint), + Row( + children: [ + Text( + dateStr, + style: AppTypography.caption1.copyWith(color: ext.textHint), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 2, + ), + decoration: BoxDecoration( + color: (isLocal ? ext.accent : CupertinoColors.systemGreen) + .withValues(alpha: 0.12), + borderRadius: AppRadius.smBorder, + ), + child: Text( + isLocal ? '📱 本地' : '👤 管理员', + style: AppTypography.caption1.copyWith( + color: isLocal ? ext.accent : CupertinoColors.systemGreen, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), ], ), diff --git a/lib/features/daily_card/presentation/daily_card_ar_view.dart b/lib/features/daily_card/presentation/daily_card_ar_view.dart index 46a70329..807ae9a6 100644 --- a/lib/features/daily_card/presentation/daily_card_ar_view.dart +++ b/lib/features/daily_card/presentation/daily_card_ar_view.dart @@ -23,6 +23,7 @@ import '../../../../core/theme/app_theme.dart'; import '../../../../core/theme/app_radius.dart'; import '../../../../core/theme/app_spacing.dart'; import '../../../../core/utils/logger.dart'; +import '../../../../core/services/auth/permission_service.dart'; import '../../../../shared/widgets/containers/glass_container.dart'; import '../../../../shared/widgets/feedback/app_toast.dart'; import '../../../../shared/widgets/adaptive/adaptive_back_button.dart'; @@ -66,35 +67,35 @@ enum ArTheme { List get colors => switch (this) { ArTheme.cosmic => [ - const Color(0xFF0a0a2e), - const Color(0xFF1a1a4e), - const Color(0xFF0d0d3b), - ], + const Color(0xFF0a0a2e), + const Color(0xFF1a1a4e), + const Color(0xFF0d0d3b), + ], ArTheme.aurora => [ - const Color(0xFF0a2a3a), - const Color(0xFF1a4a5a), - const Color(0xFF0d3d4d), - ], + const Color(0xFF0a2a3a), + const Color(0xFF1a4a5a), + const Color(0xFF0d3d4d), + ], ArTheme.sunset => [ - const Color(0xFF2d1b30), - const Color(0xFF4a2535), - const Color(0xFF351820), - ], + const Color(0xFF2d1b30), + const Color(0xFF4a2535), + const Color(0xFF351820), + ], ArTheme.forest => [ - const Color(0xFF0a1f15), - const Color(0xFF153022), - const Color(0xFF0d2819), - ], + const Color(0xFF0a1f15), + const Color(0xFF153022), + const Color(0xFF0d2819), + ], ArTheme.ocean => [ - const Color(0xFF051525), - const Color(0xFF0a2540), - const Color(0xFF071d32), - ], + const Color(0xFF051525), + const Color(0xFF0a2540), + const Color(0xFF071d32), + ], ArTheme.crystal => [ - const Color(0xFF1a1a2e), - const Color(0xFF16213e), - const Color(0xFF121230), - ], + const Color(0xFF1a1a2e), + const Color(0xFF16213e), + const Color(0xFF121230), + ], }; } @@ -103,10 +104,7 @@ enum ArTheme { // ============================================================ class DailyCardArView extends ConsumerStatefulWidget { - const DailyCardArView({ - super.key, - required this.params, - }); + const DailyCardArView({super.key, required this.params}); final DailyCardArParams params; @@ -174,13 +172,23 @@ class _DailyCardArViewState extends ConsumerState // ---- 传感器初始化 ---- void _initSensors() { + // 摇一摇/传感器权限未开启时,不访问传感器,使用纯动画模式 + if (!PermissionService.isShakeEnabled) { + _sensorAvailable = false; + Log.i('AR视图: 传感器权限未开启,使用纯动画模式'); + return; + } try { _accelSubscription = - accelerometerEventStream(samplingPeriod: const Duration(milliseconds: 60)) - .listen(_onAccelerometerData, onError: (e) { - Log.w('AR视图: 加速度传感器不可用,切换到纯动画模式'); - _sensorAvailable = false; - }); + accelerometerEventStream( + samplingPeriod: const Duration(milliseconds: 60), + ).listen( + _onAccelerometerData, + onError: (e) { + Log.w('AR视图: 加速度传感器不可用,切换到纯动画模式'); + _sensorAvailable = false; + }, + ); _sensorAvailable = true; Log.i('AR视图: 加速度传感器已启动'); } catch (e) { @@ -244,7 +252,9 @@ class _DailyCardArViewState extends ConsumerState Future _captureAndShare() async { HapticFeedback.mediumImpact(); try { - final boundary = _repaintKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; + final boundary = + _repaintKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; if (boundary == null) { AppToast.show('❌ 截图失败'); return; @@ -415,10 +425,7 @@ class _DailyCardArViewState extends ConsumerState onDoubleTap: _onDoubleTap, behavior: HitTestBehavior.opaque, child: AnimatedBuilder( - animation: Listenable.merge([ - _lightController, - _floatController, - ]), + animation: Listenable.merge([_lightController, _floatController]), builder: (context, _) { final floatOffset = _floatController.value * 8.0; final lightPhase = _lightController.value * math.pi * 2; @@ -548,9 +555,7 @@ class _DailyCardArViewState extends ConsumerState child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(radius.xl), - border: Border.all( - color: Colors.white.withValues(alpha: 0.12), - ), + border: Border.all(color: Colors.white.withValues(alpha: 0.12)), ), ), ); @@ -563,10 +568,7 @@ class _DailyCardArViewState extends ConsumerState child: ShaderMask( shaderCallback: (bounds) => RadialGradient( radius: 0.8, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.15), - ], + colors: [Colors.transparent, Colors.black.withValues(alpha: 0.15)], ).createShader(bounds), blendMode: BlendMode.multiply, child: Container(color: Colors.transparent), @@ -672,12 +674,14 @@ class _DailyCardArViewState extends ConsumerState children: [ Icon(icon, size: 14, color: ext.iconSecondary), const SizedBox(width: 4), - Text(label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: ext.textSecondary, - )), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: ext.textSecondary, + ), + ), ], ), ), @@ -702,7 +706,9 @@ class _DailyCardArViewState extends ConsumerState vertical: AppSpacing.xs, ), decoration: BoxDecoration( - color: active ? ext.accent.withValues(alpha: 0.2) : ext.bgSecondary.withValues(alpha: 0.7), + color: active + ? ext.accent.withValues(alpha: 0.2) + : ext.bgSecondary.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(radius.md), border: active ? Border.all(color: ext.accent.withValues(alpha: 0.4), width: 0.5) @@ -717,12 +723,14 @@ class _DailyCardArViewState extends ConsumerState color: active ? ext.accent : ext.iconSecondary, ), const SizedBox(width: 4), - Text(label, - style: TextStyle( - fontSize: 12, - fontWeight: active ? FontWeight.w600 : FontWeight.w500, - color: active ? ext.accent : ext.textSecondary, - )), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: active ? FontWeight.w600 : FontWeight.w500, + color: active ? ext.accent : ext.textSecondary, + ), + ), ], ), ), diff --git a/lib/features/discover/models/chat_message.dart b/lib/features/discover/models/chat_message.dart index eac8a43c..19a178dc 100644 --- a/lib/features/discover/models/chat_message.dart +++ b/lib/features/discover/models/chat_message.dart @@ -24,6 +24,7 @@ enum ChatMessageType { richText('rich_text', '富文本消息'), link('link', '链接消息'), document('document', '文档消息'), + location('location', '位置消息'), readlaterSentence('readlater_sentence', '稍后读句子'); const ChatMessageType(this.id, this.label); @@ -198,6 +199,7 @@ class ChatMessage { (richContent != null && richContent!.isNotEmpty); bool get isLink => type == ChatMessageType.link; bool get isDocument => type == ChatMessageType.document; + bool get isLocation => type == ChatMessageType.location; bool get isReadlaterSentence => type == ChatMessageType.readlaterSentence; bool get hasReplyTo => replyToId != null && replyToId!.isNotEmpty; bool get hasIpInfo => ipText != null && ipText!.isNotEmpty; diff --git a/lib/features/discover/presentation/pages/readlater_settings_page.dart b/lib/features/discover/presentation/pages/readlater_settings_page.dart new file mode 100644 index 00000000..7fbabb72 --- /dev/null +++ b/lib/features/discover/presentation/pages/readlater_settings_page.dart @@ -0,0 +1,322 @@ +/// ============================================================ +/// 闲言APP — 稍后读设置页面 +/// 创建时间: 2026-06-09 +/// 更新时间: 2026-06-09 +/// 作用: 稍后读相关设置(云端同步/提醒/协作) +/// 上次更新: 初始创建 +/// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/services/notification/readlater_reminder_service.dart'; +import '../../../../core/theme/app_spacing.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/theme/app_typography.dart'; +import '../../../../shared/widgets/adaptive/adaptive_back_button.dart'; +import '../../../../shared/widgets/feedback/app_toast.dart'; +import '../../../home/presentation/providers/readlater/readlater_provider.dart'; + +class ReadlaterSettingsPage extends ConsumerStatefulWidget { + const ReadlaterSettingsPage({super.key}); + + @override + ConsumerState createState() => + _ReadlaterSettingsPageState(); +} + +class _ReadlaterSettingsPageState + extends ConsumerState { + @override + Widget build(BuildContext context) { + final ext = AppTheme.ext(context); + final notifier = ref.read(readLaterProvider.notifier); + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text( + '稍后读设置', + style: AppTypography.title3.copyWith(color: ext.textPrimary), + ), + backgroundColor: ext.bgPrimary.withValues(alpha: 0.85), + border: null, + leading: const AdaptiveBackButton(), + ), + child: SafeArea( + child: ListView( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + children: [ + // 云端同步 + _buildSectionHeader(ext, '☁️ 云端同步'), + _buildSettingsGroup(ext, [ + _buildSwitchTile( + ext, + icon: CupertinoIcons.cloud_upload, + title: '自动同步', + subtitle: 'Wi-Fi下自动同步稍后读内容到云端', + value: notifier.getAutoSync(), + onChanged: (v) => notifier.setAutoSync(v), + ), + _buildInfoTile( + ext, + icon: CupertinoIcons.clock, + title: '上次同步', + value: _formatSyncTime(notifier.getLastSyncTime()), + ), + _buildActionTile( + ext, + icon: CupertinoIcons.arrow_2_circlepath, + title: '立即同步', + onTap: () => _performSync(ext), + ), + ]), + const SizedBox(height: AppSpacing.lg), + + // 智能提醒 + _buildSectionHeader(ext, '🔔 智能提醒'), + _buildSettingsGroup(ext, [ + _buildSwitchTile( + ext, + icon: CupertinoIcons.battery_100, + title: '充电时提醒', + subtitle: '充电时提醒阅读未读稍后读内容', + value: ReadlaterReminderService.isEnabled(), + onChanged: (v) async { + await ReadlaterReminderService.setEnabled(v); + setState(() {}); + }, + ), + _buildActionTile( + ext, + icon: CupertinoIcons.bell, + title: '立即检查', + onTap: () async { + await ReadlaterReminderService.checkNow(); + if (mounted) { + AppToast.showSuccess('已检查稍后读提醒'); + } + }, + ), + ]), + const SizedBox(height: AppSpacing.lg), + + // 协作共享 + _buildSectionHeader(ext, '👥 协作共享'), + _buildSettingsGroup(ext, [ + _buildActionTile( + ext, + icon: CupertinoIcons.person_2, + title: '共享列表', + onTap: () => _showSharedLists(ext), + ), + _buildActionTile( + ext, + icon: CupertinoIcons.plus_circle, + title: '创建共享列表', + onTap: () => _showCreateSharedList(ext), + ), + ]), + ], + ), + ), + ); + } + + // ============================================================ + // UI组件 + // ============================================================ + + Widget _buildSectionHeader(AppThemeExtension ext, String title) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.xs, + ), + child: Text( + title, + style: AppTypography.caption1.copyWith( + color: ext.textHint, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + Widget _buildSettingsGroup(AppThemeExtension ext, List children) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(12), + ), + child: Column(children: children), + ); + } + + Widget _buildSwitchTile( + AppThemeExtension ext, { + required IconData icon, + required String title, + String? subtitle, + required bool value, + required ValueChanged onChanged, + }) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Row( + children: [ + Icon(icon, size: 20, color: ext.accent), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: AppTypography.subhead.copyWith(color: ext.textPrimary)), + if (subtitle != null) + Text(subtitle, style: AppTypography.caption2.copyWith(color: ext.textHint)), + ], + ), + ), + CupertinoSwitch(value: value, onChanged: onChanged, activeTrackColor: ext.accent), + ], + ), + ); + } + + Widget _buildInfoTile( + AppThemeExtension ext, { + required IconData icon, + required String title, + required String value, + }) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Row( + children: [ + Icon(icon, size: 20, color: ext.textSecondary), + const SizedBox(width: AppSpacing.sm), + Expanded(child: Text(title, style: AppTypography.subhead.copyWith(color: ext.textPrimary))), + Text(value, style: AppTypography.caption1.copyWith(color: ext.textHint)), + ], + ), + ); + } + + Widget _buildActionTile( + AppThemeExtension ext, { + required IconData icon, + required String title, + required VoidCallback onTap, + }) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Row( + children: [ + Icon(icon, size: 20, color: ext.accent), + const SizedBox(width: AppSpacing.sm), + Expanded(child: Text(title, style: AppTypography.subhead.copyWith(color: ext.textPrimary))), + Icon(CupertinoIcons.chevron_right, size: 16, color: ext.textHint), + ], + ), + ), + ); + } + + // ============================================================ + // 操作 + // ============================================================ + + void _performSync(AppThemeExtension ext) async { + AppToast.showInfo('正在同步...'); + try { + final result = await ref.read(readLaterProvider.notifier).performSync(); + if (mounted) { + AppToast.showSuccess(result.summary); + setState(() {}); + } + } catch (e) { + if (mounted) AppToast.showWarning('同步失败'); + } + } + + void _showSharedLists(AppThemeExtension ext) async { + final lists = await ref.read(readLaterProvider.notifier).getSharedLists(); + if (!mounted) return; + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + title: const Text('我的共享列表'), + actions: lists.isEmpty + ? [CupertinoActionSheetAction(child: const Text('暂无共享列表'), onPressed: () => Navigator.pop(ctx))] + : lists.map((list) => CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + // 显示列表详情 + }, + child: Text('👥 ${list.name} (${list.messageCount}条)'), + )).toList(), + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + ), + ); + } + + void _showCreateSharedList(AppThemeExtension ext) { + final controller = TextEditingController(); + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('创建共享列表'), + content: Padding( + padding: const EdgeInsets.only(top: 8), + child: CupertinoTextField( + controller: controller, + placeholder: '列表名称', + autofocus: true, + ), + ), + actions: [ + CupertinoDialogAction( + child: const Text('取消'), + onPressed: () => Navigator.pop(ctx), + ), + CupertinoDialogAction( + isDefaultAction: true, + child: const Text('创建'), + onPressed: () async { + final name = controller.text.trim(); + if (name.isNotEmpty) { + await ref.read(readLaterProvider.notifier).createSharedList(name); + AppToast.showSuccess('共享列表「$name」已创建'); + } + if (ctx.mounted) Navigator.pop(ctx); + }, + ), + ], + ), + ); + } + + String _formatSyncTime(DateTime? time) { + if (time == null) return '从未同步'; + final diff = DateTime.now().difference(time); + if (diff.inMinutes < 1) return '刚刚'; + if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前'; + if (diff.inHours < 24) return '${diff.inHours}小时前'; + return '${diff.inDays}天前'; + } +} diff --git a/lib/features/discover/presentation/pages/readlater_stats_page.dart b/lib/features/discover/presentation/pages/readlater_stats_page.dart index 54e6323d..146ff60f 100644 --- a/lib/features/discover/presentation/pages/readlater_stats_page.dart +++ b/lib/features/discover/presentation/pages/readlater_stats_page.dart @@ -1011,6 +1011,7 @@ class ReadlaterStatsPage extends StatelessWidget { ChatMessageType.scenario => '🎭 情景', ChatMessageType.system => '⚙️ 系统', ChatMessageType.userMessage => '👤 用户', + ChatMessageType.location => '📍 位置', }; } } diff --git a/lib/features/discover/presentation/widgets/chat/chat_flow_input_bar.dart b/lib/features/discover/presentation/widgets/chat/chat_flow_input_bar.dart index 75f510d4..04829682 100644 --- a/lib/features/discover/presentation/widgets/chat/chat_flow_input_bar.dart +++ b/lib/features/discover/presentation/widgets/chat/chat_flow_input_bar.dart @@ -22,6 +22,8 @@ import 'package:xianyan/features/discover/presentation/widgets/chat/chat_flow_to import 'package:xianyan/features/discover/presentation/widgets/chat_input/rich_text_editor_sheet.dart'; import 'package:xianyan/features/discover/presentation/widgets/chat_input/reply_preview_bar.dart'; import 'package:xianyan/features/discover/presentation/widgets/chat_input/attachment_grid_sheet.dart'; +import 'package:xianyan/features/discover/presentation/widgets/chat_input/link_input_sheet.dart'; +import 'package:xianyan/features/discover/presentation/widgets/chat_input/location_input_sheet.dart'; import 'package:xianyan/features/discover/presentation/widgets/chat_input/record_audio_sheet.dart'; import 'package:xianyan/features/discover/providers/chat_provider.dart'; import 'package:xianyan/features/discover/providers/chat_attachment_provider.dart'; @@ -257,6 +259,14 @@ class _ChatFlowInputBarState extends ConsumerState { gradientColors: [const Color(0xFFfa709a), const Color(0xFFfee140)], onTap: () { Navigator.pop(context); + LocationInputSheet.show( + context, + onSend: (name, address) { + ref + .read(chatMessagesProvider(widget.conversationId).notifier) + .sendLocationMessage(name: name, address: address); + }, + ); }, ), AttachmentGridItem( @@ -265,6 +275,20 @@ class _ChatFlowInputBarState extends ConsumerState { gradientColors: [const Color(0xFFa18cd1), const Color(0xFFfbc2eb)], onTap: () { Navigator.pop(context); + LinkInputSheet.show( + context, + onSend: (url, title, description, imageUrl, sourceApp) { + ref + .read(chatMessagesProvider(widget.conversationId).notifier) + .sendLinkMessage( + url: url, + title: title, + description: description, + imageUrl: imageUrl, + sourceApp: sourceApp, + ); + }, + ); }, ), AttachmentGridItem( @@ -273,6 +297,18 @@ class _ChatFlowInputBarState extends ConsumerState { gradientColors: [const Color(0xFFffecd2), const Color(0xFFfcb69f)], onTap: () { Navigator.pop(context); + RichTextEditorSheet.show( + context, + onSend: (plainText, deltaJson) { + ref + .read(chatMessagesProvider(widget.conversationId).notifier) + .sendRichTextMessage( + plainText, + richContent: deltaJson, + replyToId: widget.replyToMessage?.id, + ); + }, + ); }, ), ], diff --git a/lib/features/discover/presentation/widgets/chat_bubble/chat_bubble.dart b/lib/features/discover/presentation/widgets/chat_bubble/chat_bubble.dart index a7698be4..ccda448d 100644 --- a/lib/features/discover/presentation/widgets/chat_bubble/chat_bubble.dart +++ b/lib/features/discover/presentation/widgets/chat_bubble/chat_bubble.dart @@ -28,6 +28,7 @@ import 'chat_image_bubble.dart'; import 'chat_video_bubble.dart'; import 'chat_link_bubble.dart'; import 'chat_document_bubble.dart'; +import 'chat_location_bubble.dart'; import 'chat_sentence_card_bubble.dart'; import 'package:xianyan/features/discover/presentation/widgets/chat_input/reply_preview_bar.dart'; import 'package:xianyan/features/discover/presentation/widgets/chat/chat_rich_bubble.dart'; @@ -415,6 +416,8 @@ class _ChatBubbleState extends State { ChatLinkBubble(message: message, ext: ext) else if (message.isDocument) ChatDocumentBubble(message: message, ext: ext) + else if (message.isLocation) + ChatLocationBubble(message: message, ext: ext) else if (message.isReadlaterSentence) ChatSentenceCardBubble( message: message, @@ -518,6 +521,8 @@ class _ChatBubbleState extends State { ChatLinkBubble(message: message, ext: ext) else if (message.isDocument) ChatDocumentBubble(message: message, ext: ext) + else if (message.isLocation) + ChatLocationBubble(message: message, ext: ext) else if (message.isReadlaterSentence) ChatSentenceCardBubble( message: message, diff --git a/lib/features/discover/presentation/widgets/chat_bubble/chat_location_bubble.dart b/lib/features/discover/presentation/widgets/chat_bubble/chat_location_bubble.dart new file mode 100644 index 00000000..c43f8b04 --- /dev/null +++ b/lib/features/discover/presentation/widgets/chat_bubble/chat_location_bubble.dart @@ -0,0 +1,308 @@ +/// ============================================================ +/// 闲言APP — 位置消息气泡 +/// 创建时间: 2026-06-09 +/// 更新时间: 2026-06-09 +/// 作用: 位置消息气泡组件,显示位置名称/地址/地图占位/操作按钮 +/// 上次更新: 初始创建 +/// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:xianyan/core/theme/app_radius.dart'; +import 'package:xianyan/core/theme/app_spacing.dart'; +import 'package:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_typography.dart'; +import 'package:xianyan/core/utils/logger.dart'; +import 'package:xianyan/shared/widgets/containers/glass_container.dart'; +import '../../../models/chat_message.dart'; + +/// 位置消息气泡 — 显示位置名称、地址、地图占位区域及操作按钮 +class ChatLocationBubble extends StatelessWidget { + const ChatLocationBubble({ + super.key, + required this.message, + required this.ext, + }); + + final ChatMessage message; + final AppThemeExtension ext; + + /// 位置名称(优先取 meta['name'],缺省取 text) + String get _name => message.meta?['name'] as String? ?? message.text; + + /// 地址(可选) + String? get _address => message.meta?['address'] as String?; + + @override + Widget build(BuildContext context) { + return GlassContainer( + depth: GlassDepth.elevated, + padding: EdgeInsets.zero, + child: Container( + decoration: BoxDecoration( + border: Border(left: BorderSide(color: ext.accent, width: 3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + _buildLocationInfo(), + _buildMapPlaceholder(), + _buildActionRow(), + ], + ), + ), + ); + } + + /// 顶部 📍 图标 + "位置" 标签 + Widget _buildHeader() { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm + 3, + AppSpacing.sm, + AppSpacing.sm, + AppSpacing.xs, + ), + child: Row( + children: [ + const Text('📍', style: TextStyle(fontSize: 14)), + const SizedBox(width: AppSpacing.xs), + Text( + '位置', + style: AppTypography.caption1.copyWith( + color: ext.accent, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + /// 位置名称 + 地址 + Widget _buildLocationInfo() { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm + 3, + AppSpacing.xs, + AppSpacing.sm, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 位置名称 + Text( + _name, + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + // 地址(可选显示) + if (_address != null && _address!.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + _address!, + style: AppTypography.subhead.copyWith( + color: ext.textSecondary, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ); + } + + /// 地图占位区域 — 渐变背景 + 图钉图标,模拟迷你地图卡片 + Widget _buildMapPlaceholder() { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm + 3, + AppSpacing.sm, + AppSpacing.sm, + 0, + ), + child: ClipRRect( + borderRadius: AppRadius.mdBorder, + child: Container( + height: 120, + width: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + ext.accent.withValues(alpha: 0.12), + ext.accent.withValues(alpha: 0.04), + ], + ), + ), + child: Stack( + children: [ + // 装饰性网格线(模拟地图道路) + _buildMapGrid(), + // 中央图钉图标 + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.location_solid, + size: 32, + color: ext.accent.withValues(alpha: 0.7), + ), + const SizedBox(height: AppSpacing.xs), + Text( + '地图预览', + style: AppTypography.caption2.copyWith( + color: ext.textHint, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + /// 装饰性网格线 — 模拟地图道路 + Widget _buildMapGrid() { + final lineColor = ext.accent.withValues(alpha: 0.06); + return CustomPaint( + size: Size.infinite, + painter: _MapGridPainter(lineColor: lineColor), + ); + } + + /// 底部操作按钮行 + Widget _buildActionRow() { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm + 3, + AppSpacing.sm, + AppSpacing.sm, + AppSpacing.sm, + ), + child: Row( + children: [ + _buildActionButton( + emoji: '📋', + label: '复制地址', + onTap: _copyAddress, + ), + const SizedBox(width: AppSpacing.sm), + _buildActionButton( + emoji: '🗺️', + label: '打开地图', + onTap: () => _openMap(_name, _address), + ), + ], + ), + ); + } + + /// 操作按钮 — 与 chat_link_bubble 风格一致 + Widget _buildActionButton({ + required String emoji, + required String label, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs + 2, + ), + decoration: BoxDecoration( + color: ext.accent.withValues(alpha: 0.1), + borderRadius: AppRadius.smBorder, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(emoji, style: const TextStyle(fontSize: 13)), + const SizedBox(width: 4), + Text( + label, + style: AppTypography.caption1.copyWith( + color: ext.accent, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + /// 复制地址到剪贴板 + Future _copyAddress() async { + final text = _address ?? _name; + try { + await Clipboard.setData(ClipboardData(text: text)); + } catch (e) { + Log.e('复制地址失败', e); + } + } + + /// 打开系统地图应用 + Future _openMap(String name, String? address) async { + try { + final query = Uri.encodeComponent( + address != null ? '$name $address' : name, + ); + final uri = Uri.parse('https://maps.apple.com/?q=$query'); + final launched = await launchUrl(uri); + if (!launched) { + Log.w('无法打开地图: $uri'); + } + } catch (e) { + Log.e('打开地图失败', e); + } + } +} + +/// 地图网格装饰画笔 — 绘制模拟道路的横竖线条 +class _MapGridPainter extends CustomPainter { + const _MapGridPainter({required this.lineColor}); + + final Color lineColor; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = lineColor + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + + // 横线 + const hCount = 5; + for (var i = 1; i < hCount; i++) { + final y = size.height * i / hCount; + canvas.drawLine(Offset(0, y), Offset(size.width, y), paint); + } + + // 竖线 + const vCount = 6; + for (var i = 1; i < vCount; i++) { + final x = size.width * i / vCount; + canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); + } + } + + @override + bool shouldRepaint(covariant _MapGridPainter oldDelegate) => + lineColor != oldDelegate.lineColor; +} diff --git a/lib/features/discover/presentation/widgets/chat_input/link_input_sheet.dart b/lib/features/discover/presentation/widgets/chat_input/link_input_sheet.dart new file mode 100644 index 00000000..8702e61e --- /dev/null +++ b/lib/features/discover/presentation/widgets/chat_input/link_input_sheet.dart @@ -0,0 +1,514 @@ +/// ============================================================ +/// 闲言APP — 链接输入面板 +/// 创建时间: 2026-06-09 +/// 更新时间: 2026-06-09 +/// 作用: 链接输入面板,支持URL输入/剪贴板粘贴/OG预览/发送链接消息 +/// 上次更新: 初始创建 +/// ============================================================ + +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; + +import 'package:xianyan/core/theme/app_spacing.dart'; +import 'package:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_typography.dart'; +import 'package:xianyan/core/services/network/og_metadata_service.dart'; +import 'package:xianyan/core/utils/logger.dart'; +import 'package:xianyan/shared/widgets/containers/glass_container.dart'; + +/// 链接输入面板 — Cupertino风格底部弹窗 +/// +/// 支持: +/// - URL输入 + 剪贴板一键粘贴 +/// - 可选标题输入 +/// - 自动抓取OG元数据(标题/描述/图片/来源) +/// - 毛玻璃预览卡片 +/// - 发送/取消操作 +class LinkInputSheet extends StatefulWidget { + const LinkInputSheet({super.key, required this.onSend}); + + /// 发送回调: (url, title, description, imageUrl, sourceApp) + final void Function( + String url, + String? title, + String? description, + String? imageUrl, + String? sourceApp, + ) onSend; + + /// 显示链接输入面板 + static Future show( + BuildContext context, { + required void Function( + String url, + String? title, + String? description, + String? imageUrl, + String? sourceApp, + ) onSend, + }) { + final ext = AppTheme.ext(context); + return showCupertinoModalPopup( + context: context, + builder: (_) => Container( + decoration: BoxDecoration( + color: ext.bgPrimary, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + child: LinkInputSheet(onSend: onSend), + ), + ); + } + + @override + State createState() => _LinkInputSheetState(); +} + +class _LinkInputSheetState extends State { + /// URL输入控制器 + final TextEditingController _urlController = TextEditingController(); + + /// 标题输入控制器 + final TextEditingController _titleController = TextEditingController(); + + /// URL输入焦点 + final FocusNode _urlFocusNode = FocusNode(); + + /// 标题输入焦点 + final FocusNode _titleFocusNode = FocusNode(); + + /// OG元数据 + OgMetadata? _ogMetadata; + + /// 是否正在加载OG + bool _isLoadingOg = false; + + /// OG请求防抖 + Timer? _debounceTimer; + + /// 是否为有效URL + bool get _isValidUrl => _parseUrl(_urlController.text.trim()) != null; + + @override + void initState() { + super.initState(); + _urlController.addListener(_onUrlChanged); + } + + @override + void dispose() { + _urlController.removeListener(_onUrlChanged); + _urlController.dispose(); + _titleController.dispose(); + _urlFocusNode.dispose(); + _titleFocusNode.dispose(); + _debounceTimer?.cancel(); + super.dispose(); + } + + /// URL变化监听 — 防抖触发OG抓取 + void _onUrlChanged() { + _debounceTimer?.cancel(); + final url = _urlController.text.trim(); + + if (!_isValidUrl) { + if (_ogMetadata != null || _isLoadingOg) { + setState(() { + _ogMetadata = null; + _isLoadingOg = false; + }); + } + return; + } + + setState(() => _isLoadingOg = true); + + _debounceTimer = Timer(const Duration(milliseconds: 800), () { + _fetchOgMetadata(url); + }); + } + + /// 抓取OG元数据 + Future _fetchOgMetadata(String url) async { + final parsedUrl = _parseUrl(url); + if (parsedUrl == null) return; + + try { + final metadata = await OgMetadataService.fetch(parsedUrl); + if (!mounted) return; + setState(() { + _ogMetadata = metadata; + _isLoadingOg = false; + }); + Log.d('OG元数据抓取完成: $parsedUrl', null, null, LogCategory.network); + } catch (e) { + if (!mounted) return; + setState(() { + _ogMetadata = null; + _isLoadingOg = false; + }); + Log.w('OG元数据抓取失败: $parsedUrl', e, null, LogCategory.network); + } + } + + /// 从剪贴板粘贴 + Future _pasteFromClipboard() async { + try { + final data = await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text?.trim() ?? ''; + if (text.isNotEmpty) { + _urlController.text = text; + _urlController.selection = TextSelection.fromPosition( + TextPosition(offset: text.length), + ); + Log.d('从剪贴板粘贴链接', null, null, LogCategory.ui); + } + } catch (e) { + Log.w('剪贴板读取失败', e, null, LogCategory.general); + } + } + + /// 发送链接消息 + void _send() { + final url = _urlController.text.trim(); + if (!_isValidUrl) return; + + final title = _titleController.text.trim(); + final ogTitle = _ogMetadata?.title; + final ogDesc = _ogMetadata?.description; + final ogImage = _ogMetadata?.imageUrl; + final ogSite = _ogMetadata?.siteName; + + widget.onSend( + _parseUrl(url)!, + title.isNotEmpty ? title : ogTitle, + ogDesc, + ogImage, + ogSite, + ); + Navigator.pop(context); + } + + /// 取消 + void _cancel() { + Navigator.pop(context); + } + + /// 解析并验证URL,返回标准化URL或null + String? _parseUrl(String input) { + if (input.isEmpty) return null; + // 补全协议头 + var url = input; + if (!url.startsWith(RegExp(r'https?://'))) { + url = 'https://$url'; + } + final uri = Uri.tryParse(url); + if (uri == null || !uri.hasScheme || !uri.hasAuthority) return null; + return url; + } + + @override + Widget build(BuildContext context) { + final ext = AppTheme.ext(context); + + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.lg, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 拖拽手柄 + _buildDragHandle(ext), + const SizedBox(height: AppSpacing.md), + // 标题 + _buildTitle(ext), + const SizedBox(height: AppSpacing.lg), + // URL输入行 + _buildUrlInputRow(ext), + const SizedBox(height: AppSpacing.md), + // 标题输入 + _buildTitleInput(ext), + const SizedBox(height: AppSpacing.md), + // OG预览区 + _buildOgPreview(ext), + const SizedBox(height: AppSpacing.lg), + // 底部操作栏 + _buildActionRow(ext), + const SizedBox(height: AppSpacing.sm), + ], + ), + ), + ); + } + + /// 拖拽手柄 + Widget _buildDragHandle(AppThemeExtension ext) { + return Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: ext.textHint.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ); + } + + /// 标题 + Widget _buildTitle(AppThemeExtension ext) { + return Text( + '🔗 分享链接', + style: AppTypography.headline.copyWith(color: ext.textPrimary), + ); + } + + /// URL输入行(输入框 + 粘贴按钮) + Widget _buildUrlInputRow(AppThemeExtension ext) { + return Row( + children: [ + Expanded( + child: CupertinoTextField( + controller: _urlController, + focusNode: _urlFocusNode, + placeholder: '粘贴或输入链接地址', + placeholderStyle: AppTypography.body.copyWith( + color: ext.textHint, + ), + style: AppTypography.body.copyWith(color: ext.textPrimary), + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.sm + 2, + ), + keyboardType: TextInputType.url, + autocorrect: false, + autofocus: true, + clearButtonMode: OverlayVisibilityMode.editing, + ), + ), + const SizedBox(width: AppSpacing.sm), + // 粘贴按钮 + CupertinoButton( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.sm, + ), + minimumSize: Size.zero, + borderRadius: BorderRadius.circular(10), + color: ext.accent.withValues(alpha: 0.12), + onPressed: _pasteFromClipboard, + child: Text( + '粘贴', + style: AppTypography.subhead.copyWith( + color: ext.accent, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + } + + /// 标题输入(可选) + Widget _buildTitleInput(AppThemeExtension ext) { + return CupertinoTextField( + controller: _titleController, + focusNode: _titleFocusNode, + placeholder: '链接标题(可选)', + placeholderStyle: AppTypography.body.copyWith(color: ext.textHint), + style: AppTypography.body.copyWith(color: ext.textPrimary), + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.sm + 2, + ), + maxLength: 100, + ); + } + + /// OG元数据预览区 + Widget _buildOgPreview(AppThemeExtension ext) { + // 加载中 + if (_isLoadingOg) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CupertinoActivityIndicator(), + const SizedBox(width: AppSpacing.sm), + Text( + '正在获取链接预览…', + style: AppTypography.subhead.copyWith(color: ext.textHint), + ), + ], + ), + ); + } + + // 无数据 + if (_ogMetadata == null) return const SizedBox.shrink(); + + // 预览卡片 + return GlassContainer( + depth: GlassDepth.elevated, + padding: const EdgeInsets.all(AppSpacing.sm), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // OG图片缩略图 + if (_ogMetadata!.imageUrl != null) + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _OgImageThumbnail( + imageUrl: _ogMetadata!.imageUrl!, + ext: ext, + ), + ), + if (_ogMetadata!.imageUrl != null) + const SizedBox(width: AppSpacing.sm), + // OG文本信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // 标题 + if (_ogMetadata!.title != null) + Text( + _ogMetadata!.title!, + style: AppTypography.subhead.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (_ogMetadata!.title != null) + const SizedBox(height: AppSpacing.xs), + // 描述 + if (_ogMetadata!.description != null) + Text( + _ogMetadata!.description!, + style: AppTypography.caption1.copyWith( + color: ext.textSecondary, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (_ogMetadata!.description != null) + const SizedBox(height: AppSpacing.xs), + // 来源 + if (_ogMetadata!.siteName != null) + Row( + children: [ + Icon( + CupertinoIcons.globe, + size: 12, + color: ext.textHint, + ), + const SizedBox(width: AppSpacing.xs), + Text( + _ogMetadata!.siteName!, + style: AppTypography.caption2.copyWith( + color: ext.textHint, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + /// 底部操作栏 + Widget _buildActionRow(AppThemeExtension ext) { + final canSend = _isValidUrl; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // 取消按钮 + CupertinoButton( + onPressed: _cancel, + child: Text( + '取消', + style: AppTypography.body.copyWith(color: ext.textHint), + ), + ), + // 发送按钮 + CupertinoButton( + onPressed: canSend ? _send : null, + child: Text( + '发送', + style: AppTypography.body.copyWith( + color: canSend ? ext.accent : ext.textDisabled, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ); + } +} + +/// OG图片缩略图 — 带错误降级 +class _OgImageThumbnail extends StatelessWidget { + const _OgImageThumbnail({ + required this.imageUrl, + required this.ext, + }); + + final String imageUrl; + final AppThemeExtension ext; + + @override + Widget build(BuildContext context) { + return Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(8), + ), + clipBehavior: Clip.antiAlias, + child: Image.network( + imageUrl, + width: 56, + height: 56, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Center( + child: Icon( + CupertinoIcons.link, + size: 24, + color: ext.textHint, + ), + ), + loadingBuilder: (_, child, loadingProgress) { + if (loadingProgress == null) return child; + return const Center( + child: CupertinoActivityIndicator(radius: 8), + ); + }, + ), + ); + } +} diff --git a/lib/features/discover/presentation/widgets/chat_input/location_input_sheet.dart b/lib/features/discover/presentation/widgets/chat_input/location_input_sheet.dart new file mode 100644 index 00000000..9bb69e5a --- /dev/null +++ b/lib/features/discover/presentation/widgets/chat_input/location_input_sheet.dart @@ -0,0 +1,292 @@ +/// ============================================================ +/// 闲言APP — 位置输入面板 +/// 创建时间: 2026-06-09 +/// 更新时间: 2026-06-09 +/// 作用: 位置输入面板,支持手动输入位置名称/地址/快捷选择/发送位置消息 +/// 上次更新: 初始创建 +/// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; + +import 'package:xianyan/core/theme/app_spacing.dart'; +import 'package:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_typography.dart'; + +/// 位置快捷选项 +class _LocationPreset { + const _LocationPreset({required this.emoji, required this.label}); + + final String emoji; + final String label; +} + +/// 位置输入面板 +/// +/// 以 Cupertino Modal 形式弹出,支持: +/// - 手动输入位置名称和详细地址 +/// - 快捷选择常用位置(家/公司/咖啡厅等) +/// - 发送位置消息 +class LocationInputSheet extends StatefulWidget { + const LocationInputSheet({super.key, required this.onSend}); + + /// 发送回调:name 必填,address 可选 + final void Function(String name, String? address) onSend; + + /// 弹出位置输入面板 + static Future show( + BuildContext context, { + required void Function(String name, String? address) onSend, + }) { + final ext = AppTheme.ext(context); + return showCupertinoModalPopup( + context: context, + builder: (_) => Container( + decoration: BoxDecoration( + color: ext.bgPrimary, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + child: LocationInputSheet(onSend: onSend), + ), + ); + } + + @override + State createState() => _LocationInputSheetState(); +} + +class _LocationInputSheetState extends State { + final _nameController = TextEditingController(); + final _addressController = TextEditingController(); + String? _selectedPreset; + + /// 快捷位置列表 + static const _presets = [ + _LocationPreset(emoji: '🏠', label: '家'), + _LocationPreset(emoji: '🏢', label: '公司'), + _LocationPreset(emoji: '☕', label: '咖啡厅'), + _LocationPreset(emoji: '🍽️', label: '餐厅'), + _LocationPreset(emoji: '🏥', label: '医院'), + _LocationPreset(emoji: '🛒', label: '商场'), + ]; + + @override + void dispose() { + _nameController.dispose(); + _addressController.dispose(); + super.dispose(); + } + + // ---- 交互方法 ---- + + /// 选择快捷位置 + void _onPresetTap(_LocationPreset preset) { + HapticFeedback.selectionClick(); + setState(() { + _selectedPreset = preset.label; + _nameController.text = preset.label; + }); + } + + /// 发送位置 + void _onSend() { + final name = _nameController.text.trim(); + if (name.isEmpty) return; + final address = _addressController.text.trim(); + widget.onSend(name, address.isEmpty ? null : address); + Navigator.pop(context); + } + + /// 取消 + void _onCancel() { + Navigator.pop(context); + } + + // ---- 构建方法 ---- + + @override + Widget build(BuildContext context) { + final ext = AppTheme.ext(context); + final canSend = _nameController.text.trim().isNotEmpty; + + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.lg, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 拖拽手柄 + _buildDragHandle(ext), + const SizedBox(height: AppSpacing.md), + + // 标题 + Text( + '📍 分享位置', + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + ), + ), + const SizedBox(height: AppSpacing.lg), + + // 位置名称输入 + _buildNameField(ext), + const SizedBox(height: AppSpacing.sm), + + // 详细地址输入 + _buildAddressField(ext), + const SizedBox(height: AppSpacing.lg), + + // 快捷选择 + _buildPresetChips(ext), + const SizedBox(height: AppSpacing.lg), + + // 底部按钮行 + _buildActionRow(ext, canSend), + const SizedBox(height: AppSpacing.sm), + ], + ), + ), + ); + } + + /// 拖拽手柄 + Widget _buildDragHandle(AppThemeExtension ext) { + return Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: ext.textHint.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ); + } + + /// 位置名称输入框 + Widget _buildNameField(AppThemeExtension ext) { + return CupertinoTextField( + controller: _nameController, + placeholder: '位置名称(如:公司、家、咖啡厅)', + placeholderStyle: AppTypography.body.copyWith(color: ext.textHint), + style: AppTypography.body.copyWith(color: ext.textPrimary), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + AppSpacing.xs, + ), + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(12), + ), + onChanged: (_) => setState(() {}), + ); + } + + /// 详细地址输入框 + Widget _buildAddressField(AppThemeExtension ext) { + return CupertinoTextField( + controller: _addressController, + placeholder: '详细地址(可选)', + placeholderStyle: AppTypography.body.copyWith(color: ext.textHint), + style: AppTypography.body.copyWith(color: ext.textPrimary), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + AppSpacing.xs, + ), + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(12), + ), + ); + } + + /// 快捷位置选择芯片 + Widget _buildPresetChips(AppThemeExtension ext) { + return Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: _presets.map((preset) { + final isSelected = _selectedPreset == preset.label; + return GestureDetector( + onTap: () => _onPresetTap(preset), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm + AppSpacing.xs, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: isSelected + ? ext.accent.withValues(alpha: 0.15) + : ext.bgSecondary, + borderRadius: BorderRadius.circular(20), + border: isSelected + ? Border.all(color: ext.accent.withValues(alpha: 0.4)) + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(preset.emoji, style: const TextStyle(fontSize: 14)), + const SizedBox(width: AppSpacing.xs), + Text( + preset.label, + style: AppTypography.subhead.copyWith( + color: isSelected ? ext.accent : ext.textSecondary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + ), + ), + ], + ), + ), + ); + }).toList(), + ); + } + + /// 底部操作按钮行 + Widget _buildActionRow(AppThemeExtension ext, bool canSend) { + return Row( + children: [ + // 取消按钮 + Expanded( + child: CupertinoButton( + onPressed: _onCancel, + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(12), + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm + AppSpacing.xs), + child: Text( + '取消', + style: AppTypography.callout.copyWith( + color: ext.textSecondary, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + + // 发送按钮 + Expanded( + child: CupertinoButton( + onPressed: canSend ? _onSend : null, + color: ext.accent, + disabledColor: ext.accent.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm + AppSpacing.xs), + child: Text( + '发送', + style: AppTypography.callout.copyWith( + color: canSend ? ext.textOnAccent : ext.textOnAccent.withValues(alpha: 0.5), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/discover/providers/chat_provider.dart b/lib/features/discover/providers/chat_provider.dart index b2423d1f..36f5143d 100644 --- a/lib/features/discover/providers/chat_provider.dart +++ b/lib/features/discover/providers/chat_provider.dart @@ -409,6 +409,26 @@ class ChatNotifier extends Notifier with SafeNotifierInit { } } + /// 发送位置消息 + Future sendLocationMessage({ + required String name, + String? address, + }) async { + try { + final record = await ChatMessageService.sendLocation( + conversationId: conversationId, + name: name, + address: address, + ); + final msg = ChatMessage.fromDrift(record); + final newMessages = [msg, ...state.messages]; + state = state.copyWith(messages: newMessages); + Log.i('位置消息已发送: $name'); + } catch (e) { + Log.e('发送位置消息失败', e); + } + } + /// 发送文档消息 Future sendDocumentMessage({ required String fileName, diff --git a/lib/features/discover/services/chat_message_service.dart b/lib/features/discover/services/chat_message_service.dart index bc6cde0c..5d53c1a9 100644 --- a/lib/features/discover/services/chat_message_service.dart +++ b/lib/features/discover/services/chat_message_service.dart @@ -1,4 +1,4 @@ -/// ============================================================ +/// ============================================================ /// 闲言APP — 聊天消息服务 /// 创建时间: 2026-05-08 /// 更新时间: 2026-05-15 @@ -531,4 +531,37 @@ class ChatMessageService { if (mime.contains('text')) return 'txt'; return 'other'; } + + /// 发送位置消息 + static Future sendLocation({ + required String conversationId, + required String name, + String? address, + }) async { + final now = DateTime.now(); + final id = _uuid.v4(); + final meta = { + 'name': name, + if (address != null && address.isNotEmpty) 'address': address, + }; + final msg = ChatMsgRecordsCompanion.insert( + id: id, + conversationId: conversationId, + type: 'location', + role: 'user', + content: name, + metaJson: Value(jsonEncode(meta)), + timestamp: now, + createdAt: now, + updatedAt: now, + ); + await _db.insertChatMsgRecord(msg); + await ChatConversationService.updateLastMessage( + conversationId, + '📍 $name', + now, + ); + Log.d('位置消息已发送: $id → conv=$conversationId'); + return (await _db.getChatMsgRecord(id))!; + } } diff --git a/lib/features/home/models/feed_model.dart b/lib/features/home/models/feed_model.dart index 9fba11eb..90e87240 100644 --- a/lib/features/home/models/feed_model.dart +++ b/lib/features/home/models/feed_model.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — Feed信息流数据模型 // 创建时间: 2026-04-28 -// 更新时间: 2026-05-14 +// 更新时间: 2026-06-09 // 作用: Feed API + SearchAll API 数据模型,覆盖信息流/频道/互动/搜索 -// 上次更新: 修复_extractTitleFromExtra字段映射,对齐API实际返回字段名 +// 上次更新: 新增platform_enabled字段容错解析,null/非Map/key缺失/值类型错误均默认启用 // ============================================================ import 'dart:convert'; @@ -376,6 +376,44 @@ String _extractTitleFromExtra(Map extra, String feedType) { return ''; } +/// 所有支持的平台标识列表(与服务端 platform_enabled 字段 key 对应) +const kAllPlatforms = [ + 'android', + 'ios', + 'harmony', + 'macos', + 'win', + 'web', + 'other', +]; + +/// 解析 platform_enabled 字段,全面容错处理 +/// +/// 容错策略: +/// - platform_enabled 为 null → 所有平台默认启用 +/// - platform_enabled 不是 Map 类型 → 所有平台默认启用 +/// - 某个平台 key 不存在 → 该平台默认启用 +/// - 某个平台值不是 bool 类型 → 该平台默认启用 +Map _parsePlatformEnabled(dynamic value) { + // 默认所有平台启用 + final defaultMap = {for (final p in kAllPlatforms) p: true}; + + // null → 默认全部启用 + if (value == null) return defaultMap; + + // 非 Map 类型 → 默认全部启用 + if (value is! Map) return defaultMap; + + final result = {}; + for (final p in kAllPlatforms) { + // key 不存在或值非 bool → 默认启用 + final v = value[p]; + result[p] = v is bool ? v : true; + } + return result; +} + +/// Feed频道模型 class FeedChannel { const FeedChannel({ required this.key, @@ -383,6 +421,7 @@ class FeedChannel { required this.icon, required this.count, this.isEnabled = true, + this.platformEnabled = const {}, }); final String key; @@ -393,10 +432,13 @@ class FeedChannel { /// 后台启用状态(来自推荐权重管理配置) final bool isEnabled; + /// 各平台启用状态(来自 platform_enabled 字段) + /// key: 平台标识(android/ios/harmony/macos/win/web/other) + /// value: 是否启用,null/缺失时默认启用 + final Map platformEnabled; + /// 前端本地名称覆盖映射(修正后端返回的不准确名称) - static const Map _nameOverrides = { - 'wlyh': '网络用语', - }; + static const Map _nameOverrides = {'wlyh': '网络用语'}; factory FeedChannel.fromJson(Map json) { final key = json['key'] as String? ?? ''; @@ -407,9 +449,18 @@ class FeedChannel { icon: json['icon'] as String? ?? '', count: _parseInt(json['count']), isEnabled: json['is_enabled'] as bool? ?? true, + platformEnabled: _parsePlatformEnabled(json['platform_enabled']), ); } + /// 检查指定平台是否启用 + /// + /// 容错策略:platformEnabled 为空、key 不存在、值非预期 → 默认启用 + bool isPlatformEnabled(String platform) { + if (platformEnabled.isEmpty) return true; + return platformEnabled[platform] ?? true; + } + Map toJson() { return { 'key': key, @@ -417,6 +468,7 @@ class FeedChannel { 'icon': icon, 'count': count, 'is_enabled': isEnabled, + 'platform_enabled': platformEnabled, }; } } @@ -624,6 +676,7 @@ class FeedListParams { this.lite = false, this.seenIds, this.seenHashes, + this.platform, }); final String channel; @@ -635,6 +688,9 @@ class FeedListParams { final List? seenIds; final List? seenHashes; + /// 平台标识(android/ios/harmony/macos/win/web/other),服务端按平台过滤启用分类 + final String? platform; + Map toQueryParameters() { final params = { 'channel': channel, @@ -650,6 +706,9 @@ class FeedListParams { if (seenHashes != null && seenHashes!.isNotEmpty) { params['seen_hashes'] = seenHashes!.join(','); } + if (platform != null && platform!.isNotEmpty) { + params['platform'] = platform; + } return params; } } @@ -717,6 +776,7 @@ class FeedMixConfig { this.groupSize = 3, this.limit = 20, this.sort = 'newest', + this.platform, }); final String mode; @@ -726,6 +786,9 @@ class FeedMixConfig { final int limit; final String sort; + /// 平台标识,服务端按平台过滤启用分类 + final String? platform; + Map toQueryParameters() { final params = { 'mode': mode, @@ -741,6 +804,9 @@ class FeedMixConfig { if (mode == 'group') { params['group_size'] = groupSize; } + if (platform != null && platform!.isNotEmpty) { + params['platform'] = platform; + } return params; } @@ -751,6 +817,7 @@ class FeedMixConfig { int? groupSize, int? limit, String? sort, + String? platform, }) { return FeedMixConfig( mode: mode ?? this.mode, @@ -759,6 +826,7 @@ class FeedMixConfig { groupSize: groupSize ?? this.groupSize, limit: limit ?? this.limit, sort: sort ?? this.sort, + platform: platform ?? this.platform, ); } diff --git a/lib/features/home/presentation/home_page.dart b/lib/features/home/presentation/home_page.dart index 9b16dfb7..3fb27d68 100644 --- a/lib/features/home/presentation/home_page.dart +++ b/lib/features/home/presentation/home_page.dart @@ -27,6 +27,7 @@ import '../../../core/router/app_routes.dart'; import '../../../core/router/app_nav_extension.dart'; import '../../../core/services/audio/sfx_service.dart'; import '../../../core/services/device/shake_detector.dart'; +import '../../../core/services/auth/permission_service.dart'; import '../../../core/services/device/battery_info_service.dart'; import '../../../shared/widgets/containers/bottom_sheet.dart'; import '../../../core/constants/character_expression.dart'; @@ -97,7 +98,8 @@ class _HomePageState extends ConsumerState { ShakeDetector.instance.pushHandler('/home', _onShake); WidgetsBinding.instance.addPostFrameCallback((_) { - if (ref.read(generalSettingsProvider).shakeToSwitch) { + if (ref.read(generalSettingsProvider).shakeToSwitch && + PermissionService.isShakeEnabled) { ShakeDetector.instance.start(); } _setupStateListeners(); @@ -555,7 +557,7 @@ class _HomePageState extends ConsumerState { generalSettingsProvider.select((s) => s.shakeToSwitch), (prev, next) { if (prev != next) { - if (next) { + if (next && PermissionService.isShakeEnabled) { ShakeDetector.instance.start(); } else { ShakeDetector.instance.stop(); diff --git a/lib/features/home/presentation/providers/readlater/readlater_entry.dart b/lib/features/home/presentation/providers/readlater/readlater_entry.dart index 03c77744..77721ece 100644 --- a/lib/features/home/presentation/providers/readlater/readlater_entry.dart +++ b/lib/features/home/presentation/providers/readlater/readlater_entry.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 稍后读条目模型 /// 创建时间: 2026-05-31 -/// 更新时间: 2026-05-31 +/// 更新时间: 2026-06-09 /// 作用: 定义稍后读条目类型枚举和统一数据模型 -/// 上次更新: 增加isRead字段+copyWith方法+ReadLaterSortMode枚举 +/// 上次更新: 增加folderId/tags/aiSummary字段,copyWith支持nullable字段置空 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -13,7 +13,17 @@ import '../../../models/feed_model.dart'; import '../../../../discover/models/chat_message.dart'; /// 稍后读条目类型枚举 -enum ReadLaterEntryType { feed, image, video, audio, file, link, document, text } +enum ReadLaterEntryType { + feed, + image, + video, + audio, + file, + link, + document, + location, + text, +} /// 稍后读排序模式 enum ReadLaterSortMode { timeDesc, timeAsc, type, size } @@ -41,6 +51,9 @@ class ReadLaterEntry { this.feedName, this.isRead = false, this.durationMs, + this.folderId, + this.tags = const [], + this.aiSummary, }); final String id; @@ -63,8 +76,12 @@ class ReadLaterEntry { final String? feedName; final bool isRead; final int? durationMs; + final String? folderId; + final List tags; + final String? aiSummary; /// 复制并修改部分字段 + /// [folderId] 和 [aiSummary] 使用 Function()? 模式,以支持将字段置为 null ReadLaterEntry copyWith({ String? id, ReadLaterEntryType? type, @@ -86,6 +103,9 @@ class ReadLaterEntry { String? feedName, bool? isRead, int? durationMs, + String? Function()? folderId, + List? tags, + String? Function()? aiSummary, }) { return ReadLaterEntry( id: id ?? this.id, @@ -108,6 +128,9 @@ class ReadLaterEntry { feedName: feedName ?? this.feedName, isRead: isRead ?? this.isRead, durationMs: durationMs ?? this.durationMs, + folderId: folderId != null ? folderId() : this.folderId, + tags: tags ?? this.tags, + aiSummary: aiSummary != null ? aiSummary() : this.aiSummary, ); } @@ -120,6 +143,7 @@ class ReadLaterEntry { ReadLaterEntryType.file => '📄', ReadLaterEntryType.link => '🔗', ReadLaterEntryType.document => '📑', + ReadLaterEntryType.location => '📍', ReadLaterEntryType.text => '📝', ReadLaterEntryType.feed => '📖', }; @@ -144,6 +168,7 @@ class ReadLaterEntry { ReadLaterEntryType.file => t.fileType, ReadLaterEntryType.link => t.linkType, ReadLaterEntryType.document => t.docType, + ReadLaterEntryType.location => t.linkType, ReadLaterEntryType.text => t.textType, ReadLaterEntryType.feed => feedName ?? t.readLaterPageTitle, }; diff --git a/lib/features/home/presentation/providers/readlater/readlater_entry_widgets.dart b/lib/features/home/presentation/providers/readlater/readlater_entry_widgets.dart index de1808d8..22b3f07a 100644 --- a/lib/features/home/presentation/providers/readlater/readlater_entry_widgets.dart +++ b/lib/features/home/presentation/providers/readlater/readlater_entry_widgets.dart @@ -889,6 +889,7 @@ Widget buildEntryContent( ReadLaterEntryType.link => LinkEntryWidget(entry: entry, ext: ext), ReadLaterEntryType.document => DocumentEntryWidget(entry: entry, ext: ext, t: t), + ReadLaterEntryType.location => LinkEntryWidget(entry: entry, ext: ext), ReadLaterEntryType.text => TextEntryWidget(entry: entry, ext: ext, t: t), ReadLaterEntryType.feed => FeedEntryWidget(entry: entry, ext: ext, t: t), }; diff --git a/lib/features/home/presentation/providers/readlater/readlater_provider.dart b/lib/features/home/presentation/providers/readlater/readlater_provider.dart index b0e5de1b..efadc3ae 100644 --- a/lib/features/home/presentation/providers/readlater/readlater_provider.dart +++ b/lib/features/home/presentation/providers/readlater/readlater_provider.dart @@ -1,14 +1,15 @@ /// ============================================================ /// 闲言APP — 稍后读状态管理 /// 创建时间: 2026-05-31 -/// 更新时间: 2026-05-31 +/// 更新时间: 2026-06-09 /// 作用: 稍后读数据加载、合并、离线缓存、搜索筛选排序等业务逻辑 -/// 上次更新: 迁移readlaterRefreshStream至DataSyncEventBus统一事件总线 +/// 上次更新: 增加文件夹/标签/AI摘要/云端同步/协作/设备同步方法 /// ============================================================ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:pinyin/pinyin.dart'; import '../../../../../core/storage/database/app_database.dart'; import '../../../../../core/sync/sync.dart'; @@ -17,7 +18,15 @@ import '../../../../../l10n/translations.dart'; import '../../../../discover/models/chat_message.dart'; import '../../../models/feed_model.dart'; import '../../../services/feed_service.dart'; -import '../../../services/offline_manager.dart'; +import '../../../services/offline_manager.dart' hide SyncResult; +import '../../../../discover/services/readlater_folder_service.dart'; +import '../../../../discover/services/readlater_tag_service.dart'; +import '../../../../discover/services/chat_message_service.dart'; +import '../../../../../core/services/readlater/readlater_ai_service.dart'; +import '../../../../../core/services/readlater/readlater_sync_service.dart'; +import '../../../../../core/services/readlater/readlater_collab_service.dart'; +import '../../../../../core/services/readlater/readlater_device_sync_service.dart'; +import '../../../../file_transfer/models/transfer_device.dart'; import 'readlater_entry.dart'; /// 稍后读状态 @@ -55,11 +64,25 @@ class ReadLaterState { if (searchQuery.isNotEmpty) { final q = searchQuery.toLowerCase(); result = result.where((e) { - return e.title.toLowerCase().contains(q) || + // 文本匹配 + final textMatch = + e.title.toLowerCase().contains(q) || e.subtitle.toLowerCase().contains(q) || (e.feedName ?? '').toLowerCase().contains(q) || (e.fileName ?? '').toLowerCase().contains(q) || (e.linkUrl ?? '').toLowerCase().contains(q); + if (textMatch) return true; + // 拼音首字母匹配 + final titlePinyin = PinyinHelper.getShortPinyin(e.title).toLowerCase(); + final subtitlePinyin = PinyinHelper.getShortPinyin( + e.subtitle, + ).toLowerCase(); + final feedPinyin = PinyinHelper.getShortPinyin( + e.feedName ?? '', + ).toLowerCase(); + return titlePinyin.contains(q) || + subtitlePinyin.contains(q) || + feedPinyin.contains(q); }).toList(); } @@ -345,6 +368,19 @@ class ReadLaterNotifier extends Notifier { linkUrl: url, ); } + if (msg.isLocation) { + final name = msg.meta?['name'] as String? ?? msg.text; + final address = msg.meta?['address'] as String?; + return ReadLaterEntry( + id: msg.id, + type: ReadLaterEntryType.location, + title: name, + subtitle: address ?? '', + chatMessage: msg, + timestamp: msg.timestamp, + linkUrl: address, + ); + } if (msg.isFile) { final att = msg.attachments.firstOrNull; final metaMimeType = msg.meta?['mimeType'] as String?; @@ -461,44 +497,41 @@ class ReadLaterNotifier extends Notifier { ); }).toList(); - final contentKey = (String title, String subtitle) => - '${title.trim().toLowerCase()}|${subtitle.trim().toLowerCase()}'; - + // 使用id去重:Feed用id,ChatMessage用chatMessage.id final feedKeyMap = {}; for (final e in feedEntries) { - feedKeyMap[contentKey(e.title, e.subtitle)] = e; + feedKeyMap[e.id] = e; } final chatKeyMap = {}; for (final e in chatEntries) { - chatKeyMap[contentKey(e.title, e.subtitle)] = e; + chatKeyMap[e.id] = e; } + // 合并:Feed条目优先,Chat条目补充(按id去重) final mergedMap = {}; + // 先放入所有Chat条目 + for (final entry in chatEntries) { + mergedMap[entry.id] = entry; + } + // Feed条目覆盖(Feed有更完整的数据) for (final entry in feedEntries) { - final key = contentKey(entry.title, entry.subtitle); - final chatEntry = chatKeyMap[key]; - if (chatEntry != null) { - mergedMap[key] = ReadLaterEntry( + final existing = mergedMap[entry.id]; + if (existing != null && existing.chatMessage != null) { + // 合并:保留chatMessage引用 + feed数据 + mergedMap[entry.id] = ReadLaterEntry( id: entry.id, type: entry.type, title: entry.title, subtitle: entry.subtitle, feedItem: entry.feedItem, - chatMessage: chatEntry.chatMessage, - timestamp: entry.timestamp ?? chatEntry.timestamp, - feedColor: entry.feedColor ?? chatEntry.feedColor, - feedName: entry.feedName ?? chatEntry.feedName, + chatMessage: existing.chatMessage, + timestamp: entry.timestamp ?? existing.timestamp, + feedColor: entry.feedColor ?? existing.feedColor, + feedName: entry.feedName ?? existing.feedName, ); } else { - mergedMap[key] = entry; - } - } - - for (final entry in chatEntries) { - final key = contentKey(entry.title, entry.subtitle); - if (!mergedMap.containsKey(key)) { - mergedMap[key] = entry; + mergedMap[entry.id] = entry; } } @@ -661,6 +694,288 @@ class ReadLaterNotifier extends Notifier { }).toList(); state = state.copyWith(entries: entries, selectedIds: const {}); } + + // ============================================================ + // 文件夹操作 + // ============================================================ + + /// 获取所有文件夹 + Future> getFolders() async { + return ReadlaterFolderService.instance.getFolders(); + } + + /// 创建文件夹 + Future createFolder(String name, {String? emoji}) async { + final folder = await ReadlaterFolderService.instance.createFolder( + name, + emoji: emoji, + ); + return folder; + } + + /// 删除文件夹 + Future deleteFolder(String folderId) async { + await ReadlaterFolderService.instance.deleteFolder(folderId); + } + + /// 重命名文件夹 + Future renameFolder(String folderId, String newName) async { + await ReadlaterFolderService.instance.renameFolder(folderId, newName); + } + + /// 将条目移入文件夹 + Future moveEntryToFolder(String entryId, String targetFolderId) async { + await ReadlaterFolderService.instance.moveMessageToFolder(entryId, targetFolderId); + // 更新内存中的条目 + final entries = state.entries.map((e) { + if (e.id == entryId) return e.copyWith(folderId: () => targetFolderId); + return e; + }).toList(); + state = state.copyWith(entries: entries); + } + + /// 从文件夹移出条目 + Future removeEntryFromFolder(String entryId) async { + await ReadlaterFolderService.instance.removeMessageFromFolder(entryId); + final entries = state.entries.map((e) { + if (e.id == entryId) return e.copyWith(folderId: () => null); + return e; + }).toList(); + state = state.copyWith(entries: entries); + } + + /// 获取文件夹内的条目 + List getEntriesInFolder(String folderId) { + return state.entries.where((e) => e.folderId == folderId).toList(); + } + + /// 获取未归档的条目 + List getUnfiledEntries() { + return state.entries.where((e) => e.folderId == null).toList(); + } + + // ============================================================ + // 标签操作 + // ============================================================ + + /// 给条目添加标签 + Future addTag(String entryId, String tag) async { + await ReadlaterTagService.addTag(entryId, tag); + final entries = state.entries.map((e) { + if (e.id == entryId) { + final newTags = [...e.tags, tag]; + return e.copyWith(tags: newTags); + } + return e; + }).toList(); + state = state.copyWith(entries: entries); + } + + /// 移除条目标签 + Future removeTag(String entryId, String tag) async { + await ReadlaterTagService.removeTag(entryId, tag); + final entries = state.entries.map((e) { + if (e.id == entryId) { + final newTags = e.tags.where((t) => t != tag).toList(); + return e.copyWith(tags: newTags); + } + return e; + }).toList(); + state = state.copyWith(entries: entries); + } + + /// 设置条目标签(覆盖) + Future setTags(String entryId, List tags) async { + await ReadlaterTagService.setTagsForMessage(entryId, tags); + final entries = state.entries.map((e) { + if (e.id == entryId) return e.copyWith(tags: tags); + return e; + }).toList(); + state = state.copyWith(entries: entries); + } + + /// 获取所有标签 + List getAllTags() { + return ReadlaterTagService.getAllTags(); + } + + /// 获取标签统计 + Map getTagStats() { + return ReadlaterTagService.getTagStats(); + } + + /// 按标签筛选条目 + List getEntriesByTag(String tag) { + return state.entries.where((e) => e.tags.contains(tag)).toList(); + } + + // ============================================================ + // AI摘要 + // ============================================================ + + /// 生成单条AI摘要 + Future generateAiSummary(ReadLaterEntry entry) async { + if (entry.chatMessage == null) return null; + final summary = await ReadlaterAiService.instance.generateSummary( + entry.chatMessage!, + ); + if (summary != null) { + final entries = state.entries.map((e) { + if (e.id == entry.id) return e.copyWith(aiSummary: () => summary); + return e; + }).toList(); + state = state.copyWith(entries: entries); + // 持久化到消息ext + ReadlaterAiService.instance.applySummary(entry.chatMessage!, summary); + await ChatMessageService.updateExt(entry.id, {'aiSummary': summary}); + } + return summary; + } + + /// 生成每日摘要 + Future generateDailySummary() async { + final today = state.entries + .where((e) { + final ts = e.timestamp; + if (ts == null) return false; + final now = DateTime.now(); + return ts.year == now.year && + ts.month == now.month && + ts.day == now.day; + }) + .map((e) => e.chatMessage) + .whereType() + .toList(); + return ReadlaterAiService.instance.generateDailySummary(today); + } + + /// AI标签建议 + Future> suggestTags(ReadLaterEntry entry) async { + return ReadlaterAiService.instance.suggestTags(entry.title); + } + + // ============================================================ + // 云端同步 + // ============================================================ + + /// 执行全量同步 + Future performSync() async { + final messages = state.entries + .map((e) => e.chatMessage) + .whereType() + .toList(); + return ReadlaterSyncService.instance.fullSync(messages); + } + + /// 获取最后同步时间 + DateTime? getLastSyncTime() { + return ReadlaterSyncService.instance.getLastSyncTime(); + } + + /// 设置自动同步 + void setAutoSync(bool enabled) { + ReadlaterSyncService.instance.setAutoSync(enabled); + } + + /// 获取自动同步状态 + bool getAutoSync() { + return ReadlaterSyncService.instance.getAutoSync(); + } + + // ============================================================ + // 协作共享 + // ============================================================ + + /// 获取我的共享列表 + Future> getSharedLists() async { + return ReadlaterCollabService.instance.getMySharedLists(); + } + + /// 创建共享列表 + Future createSharedList(String name) async { + return ReadlaterCollabService.instance.createSharedList(name); + } + + /// 分享条目到共享列表 + Future shareToSharedList(String listId, ReadLaterEntry entry) async { + if (entry.chatMessage == null) return; + await ReadlaterCollabService.instance.shareToSharedList( + listId, + entry.chatMessage!, + ); + } + + /// 退出共享列表 + Future leaveSharedList(String listId) async { + await ReadlaterCollabService.instance.leaveSharedList(listId); + } + + // ============================================================ + // 设备同步 + // ============================================================ + + /// 发现可同步设备 + Future> discoverDevices(WidgetRef widgetRef) async { + return ReadlaterDeviceSyncService.instance.discoverDevices(widgetRef); + } + + /// 发送到其他设备 + Future sendToDevice(WidgetRef widgetRef, dynamic device) async { + final messages = state.entries + .map((e) => e.chatMessage) + .whereType() + .toList(); + return ReadlaterDeviceSyncService.instance.sendToDevice( + ref: widgetRef, + device: device as TransferDevice, + messages: messages, + ); + } + + // ============================================================ + // 批量增强操作 + // ============================================================ + + /// 批量移入选中条目到文件夹 + Future moveSelectedToFolder(String targetFolderId) async { + for (final id in state.selectedIds) { + await ReadlaterFolderService.instance.moveMessageToFolder(id, targetFolderId); + } + final entries = state.entries.map((e) { + if (state.selectedIds.contains(e.id)) + return e.copyWith(folderId: () => targetFolderId); + return e; + }).toList(); + state = state.copyWith(entries: entries, selectedIds: const {}); + } + + /// 批量给选中条目添加标签 + Future addTagToSelected(String tag) async { + for (final id in state.selectedIds) { + await ReadlaterTagService.addTag(id, tag); + } + final entries = state.entries.map((e) { + if (state.selectedIds.contains(e.id) && !e.tags.contains(tag)) { + return e.copyWith(tags: [...e.tags, tag]); + } + return e; + }).toList(); + state = state.copyWith(entries: entries, selectedIds: const {}); + } + + /// 批量分享选中条目到共享列表 + Future shareSelectedToSharedList(String listId) async { + for (final id in state.selectedIds) { + final entry = state.entries.where((e) => e.id == id).firstOrNull; + if (entry?.chatMessage != null) { + await ReadlaterCollabService.instance.shareToSharedList( + listId, + entry!.chatMessage!, + ); + } + } + state = state.copyWith(selectedIds: const {}); + } } /// 稍后读 Provider (keepAlive: 页面退出后保持数据) diff --git a/lib/features/home/presentation/providers/readlater_page.dart b/lib/features/home/presentation/providers/readlater_page.dart index 56a79338..8755d9a6 100644 --- a/lib/features/home/presentation/providers/readlater_page.dart +++ b/lib/features/home/presentation/providers/readlater_page.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 稍后读列表页面 /// 创建时间: 2026-04-28 -/// 更新时间: 2026-05-31 +/// 更新时间: 2026-06-09 /// 作用: 展示用户稍后读列表,支持搜索/筛选/排序/批量操作/富详情 -/// 上次更新: replaceAll替换为tFunc.format统一插值 +/// 上次更新: 增加二维码分享和桌面端拖拽支持 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -11,19 +11,24 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:qr_flutter/qr_flutter.dart'; import 'package:share_plus/share_plus.dart' show SharePlus, ShareParams; import 'package:url_launcher/url_launcher.dart'; +import 'package:desktop_drop/desktop_drop.dart'; import '../../../../core/theme/app_spacing.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/theme/app_typography.dart'; import '../../../../core/utils/logger.dart'; +import '../../../../core/utils/platform/platform_helper.dart'; import '../../../../l10n/translations.dart'; import '../../../../shared/widgets/adaptive/adaptive_back_button.dart'; import '../../../../shared/widgets/containers/glass_container.dart'; import '../../../../shared/widgets/feedback/app_toast.dart'; import '../../../../shared/widgets/feedback/external_link_dialog.dart'; import '../../../../shared/widgets/input/app_slidable.dart'; +import '../../../../features/discover/services/readlater_folder_service.dart' + show ReadlaterFolder; import '../../models/feed_model.dart'; import 'readlater/readlater_entry.dart'; import 'readlater/readlater_entry_widgets.dart'; @@ -42,6 +47,9 @@ class ReadLaterPage extends ConsumerStatefulWidget { class _ReadLaterPageState extends ConsumerState { final _searchController = TextEditingController(); bool _isSelectMode = false; + bool _isDraggingOver = false; + String? _selectedFolderId; // null = 全部, 'unfiled' = 未归档, folderId = 指定文件夹 + String? _selectedTag; // null = 全部标签 @override void initState() { @@ -152,6 +160,15 @@ class _ReadLaterPageState extends ConsumerState { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ + CupertinoButton( + padding: EdgeInsets.zero, + child: Icon( + CupertinoIcons.cloud_upload, + size: 20, + color: ext.textSecondary, + ), + onPressed: () => _performSync(ext, t), + ), if (state.entries.isNotEmpty) CupertinoButton( padding: EdgeInsets.zero, @@ -186,9 +203,9 @@ class _ReadLaterPageState extends ConsumerState { return _buildEmptyState(ext, t); } - final displayEntries = state.filteredEntries; + final displayEntries = _applyFolderTagFilter(state.filteredEntries); - return RefreshIndicator( + final contentWidget = RefreshIndicator( onRefresh: () => ref.read(readLaterProvider.notifier).loadItems(refresh: true), child: NotificationListener( @@ -205,6 +222,8 @@ class _ReadLaterPageState extends ConsumerState { physics: const BouncingScrollPhysics(), slivers: [ SliverToBoxAdapter(child: _buildSearchBar(ext, t)), + SliverToBoxAdapter(child: _buildFolderBar(ext, t)), + SliverToBoxAdapter(child: _buildTagFilterBar(ext, t, state)), SliverToBoxAdapter(child: _buildFilterChips(ext, t, state)), SliverToBoxAdapter( child: Padding( @@ -265,6 +284,55 @@ class _ReadLaterPageState extends ConsumerState { ), ), ); + + // 桌面端拖拽支持 + final bool isDesktop = PlatformHelper.isDesktop; + if (isDesktop) { + return DropTarget( + onDragEntered: (_) => setState(() => _isDraggingOver = true), + onDragExited: (_) => setState(() => _isDraggingOver = false), + onDragDone: (details) => _handleDroppedFiles(details), + child: Stack( + children: [ + contentWidget, + if (_isDraggingOver) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: ext.accent.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: ext.accent, + width: 2, + strokeAlign: BorderSide.strokeAlignOutside, + ), + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.plus_circle, + size: 48, + color: ext.accent, + ), + const SizedBox(height: 8), + Text( + '拖拽文件到稍后读', + style: AppTypography.subhead.copyWith( + color: ext.accent, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + return contentWidget; } /// 搜索栏 @@ -289,6 +357,217 @@ class _ReadLaterPageState extends ConsumerState { ); } + /// 根据文件夹和标签筛选条目 + List _applyFolderTagFilter(List entries) { + var result = entries; + if (_selectedFolderId != null) { + if (_selectedFolderId == 'unfiled') { + result = result.where((e) => e.folderId == null).toList(); + } else { + result = result.where((e) => e.folderId == _selectedFolderId).toList(); + } + } + if (_selectedTag != null) { + result = result.where((e) => e.tags.contains(_selectedTag)).toList(); + } + return result; + } + + /// 文件夹快捷栏 + Widget _buildFolderBar(AppThemeExtension ext, T t) { + return FutureBuilder>( + future: ref.read(readLaterProvider.notifier).getFolders(), + builder: (context, snapshot) { + final folders = snapshot.data ?? []; + if (folders.isEmpty && _selectedFolderId == null) { + // 无文件夹时显示创建入口 + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.sm, + AppSpacing.md, + 0, + ), + child: GestureDetector( + onTap: () => _showCreateFolderDialog(ext, t), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.folder_badge_plus, + size: 16, + color: ext.accent, + ), + const SizedBox(width: 6), + Text( + '新建文件夹', + style: AppTypography.caption1.copyWith(color: ext.accent), + ), + ], + ), + ), + ), + ); + } + + return SizedBox( + height: 36, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: 2, + ), + children: [ + _folderChip(ext, '📋', '全部', null), + _folderChip(ext, '📎', '未归档', 'unfiled'), + for (final f in folders) + _folderChip(ext, f.emoji, '${f.name}(${f.count})', f.id), + Padding( + padding: const EdgeInsets.only(left: 4), + child: GestureDetector( + onTap: () => _showCreateFolderDialog(ext, t), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(16), + ), + child: Icon( + CupertinoIcons.plus, + size: 14, + color: ext.textSecondary, + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _folderChip( + AppThemeExtension ext, + String emoji, + String label, + String? folderId, + ) { + final isSelected = _selectedFolderId == folderId; + return Padding( + padding: const EdgeInsets.only(right: AppSpacing.xs), + child: GestureDetector( + onTap: () => + setState(() => _selectedFolderId = isSelected ? null : folderId), + onLongPress: folderId != null && folderId != 'unfiled' + ? () => _showFolderActions(ext, folderId) + : null, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: isSelected + ? ext.accent.withValues(alpha: 0.15) + : ext.bgSecondary, + borderRadius: BorderRadius.circular(16), + border: isSelected ? Border.all(color: ext.accent) : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(emoji, style: const TextStyle(fontSize: 12)), + const SizedBox(width: 4), + Text( + label, + style: AppTypography.caption2.copyWith( + color: isSelected ? ext.accent : ext.textSecondary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ), + ), + ); + } + + /// 标签筛选栏 + Widget _buildTagFilterBar(AppThemeExtension ext, T t, ReadLaterState state) { + final tagStats = ref.read(readLaterProvider.notifier).getTagStats(); + if (tagStats.isEmpty) return const SizedBox.shrink(); + + return SizedBox( + height: 32, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: 2, + ), + children: [ + _tagChip(ext, '🏷️', '全部标签', null), + for (final entry in tagStats.entries.take(10)) + _tagChip(ext, '', '${entry.key}(${entry.value})', entry.key), + ], + ), + ); + } + + Widget _tagChip( + AppThemeExtension ext, + String emoji, + String label, + String? tag, + ) { + final isSelected = _selectedTag == tag; + return Padding( + padding: const EdgeInsets.only(right: AppSpacing.xs), + child: GestureDetector( + onTap: () => setState(() => _selectedTag = isSelected ? null : tag), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), + decoration: BoxDecoration( + color: isSelected + ? ext.accent.withValues(alpha: 0.1) + : ext.bgSecondary, + borderRadius: BorderRadius.circular(14), + border: isSelected + ? Border.all(color: ext.accent, width: 0.5) + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (emoji.isNotEmpty) ...[ + Text(emoji, style: const TextStyle(fontSize: 11)), + const SizedBox(width: 3), + ], + Text( + label, + style: AppTypography.caption2.copyWith( + color: isSelected ? ext.accent : ext.textHint, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ], + ), + ), + ), + ); + } + /// 类型筛选 Chip 行 Widget _buildFilterChips(AppThemeExtension ext, T t, ReadLaterState state) { final chips = >[ @@ -300,6 +579,7 @@ class _ReadLaterPageState extends ConsumerState { const MapEntry(ReadLaterEntryType.file, '📄'), const MapEntry(ReadLaterEntryType.link, '🔗'), const MapEntry(ReadLaterEntryType.document, '📑'), + const MapEntry(ReadLaterEntryType.location, '📍'), const MapEntry(ReadLaterEntryType.text, '📝'), ]; @@ -327,9 +607,7 @@ class _ReadLaterPageState extends ConsumerState { ? ext.accent.withValues(alpha: 0.15) : ext.bgSecondary, borderRadius: BorderRadius.circular(16), - border: isSelected - ? Border.all(color: ext.accent) - : null, + border: isSelected ? Border.all(color: ext.accent) : null, ), child: Center( child: Text( @@ -369,6 +647,25 @@ class _ReadLaterPageState extends ConsumerState { Text( t.home.readLater.readLaterEmptyHint, style: AppTypography.subhead.copyWith(color: ext.textSecondary), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + CupertinoButton( + color: ext.accent, + borderRadius: BorderRadius.circular(20), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 10, + ), + child: Text( + '去发现', + style: AppTypography.subhead.copyWith( + color: ext.textOnAccent, + ), + ), + onPressed: () { + Navigator.of(context).pushNamed('/discover'); + }, ), ], ), @@ -420,6 +717,22 @@ class _ReadLaterPageState extends ConsumerState { type: SlideActionType.bookmark, onPressed: () => _removeEntry(entry), ), + SlideActionConfig( + type: SlideActionType.read, + onPressed: () { + ref.read(readLaterProvider.notifier).toggleRead(entry.id); + }, + ), + ], + leftActions: [ + SlideActionConfig( + type: SlideActionType.folder, + onPressed: () => _showMoveToFolderSheet(ext, t, entry), + ), + SlideActionConfig( + type: SlideActionType.tag, + onPressed: () => _showTagSheet(ext, t, entry), + ), ], child: GestureDetector( onTap: () => _onEntryTap(entry), @@ -464,6 +777,24 @@ class _ReadLaterPageState extends ConsumerState { AppToast.showSuccess(t.home.markedReadLabel); }, ), + _batchButton( + ext, + CupertinoIcons.folder, + '文件夹', + () => _batchMoveToFolder(ext, t), + ), + _batchButton( + ext, + CupertinoIcons.tag, + '标签', + () => _batchAddTag(ext, t), + ), + _batchButton( + ext, + CupertinoIcons.share, + t.home.shareLabel, + () => _batchShare(ext, t), + ), ], ), ), @@ -605,7 +936,11 @@ class _ReadLaterPageState extends ConsumerState { .read(readLaterProvider.notifier) .removeSelectedEntries(); _exitSelectMode(); - AppToast.showSuccess(ref.read(translationsFuncProvider).format(t.home.batchDeletedLabel, {'0': '$count'})); + AppToast.showSuccess( + ref.read(translationsFuncProvider).format(t.home.batchDeletedLabel, { + '0': '$count', + }), + ); } // ============================================================ @@ -643,10 +978,13 @@ class _ReadLaterPageState extends ConsumerState { _showEntryDetail(entry); } - /// Feed详情弹窗(增强版) + /// Feed详情弹窗(增强版 — 含来源/时间/互动数据/AI摘要) void _showFeedDetail(FeedItem item) { final ext = AppTheme.ext(context); final t = ref.read(translationsProvider); + final timeStr = item.createtime != null + ? _formatTimestamp(item.createtime!) + : ''; showCupertinoModalPopup( context: context, builder: (ctx) => Container( @@ -672,19 +1010,84 @@ class _ReadLaterPageState extends ConsumerState { ), ), const SizedBox(height: AppSpacing.md), - if (item.author.isNotEmpty) - Text( - item.author, - style: AppTypography.caption1.copyWith( - color: ext.textSecondary, - ), + // 来源信息 + if (item.feedName.isNotEmpty || item.author.isNotEmpty) + Row( + children: [ + if (item.feedName.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: ext.accent.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + item.feedName, + style: AppTypography.caption2.copyWith( + color: ext.accent, + ), + ), + ), + if (item.author.isNotEmpty) ...[ + const SizedBox(width: 8), + Text( + item.author, + style: AppTypography.caption1.copyWith( + color: ext.textSecondary, + ), + ), + ], + if (timeStr.isNotEmpty) ...[ + const SizedBox(width: 8), + Text( + timeStr, + style: AppTypography.caption1.copyWith( + color: ext.textHint, + ), + ), + ], + ], ), - const SizedBox(height: AppSpacing.xs), + const SizedBox(height: AppSpacing.sm), + // 内容 Text( item.content, style: AppTypography.body.copyWith(color: ext.textPrimary), ), + // 互动数据 + if (item.likeCount > 0 || item.views > 0) ...[ + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + if (item.likeCount > 0) ...[ + const Text('👍', style: TextStyle(fontSize: 14)), + const SizedBox(width: 4), + Text( + '${item.likeCount}', + style: AppTypography.caption2.copyWith( + color: ext.textSecondary, + ), + ), + const SizedBox(width: 12), + ], + if (item.views > 0) ...[ + const Text('👁️', style: TextStyle(fontSize: 14)), + const SizedBox(width: 4), + Text( + '${item.views}', + style: AppTypography.caption2.copyWith( + color: ext.textSecondary, + ), + ), + ], + ], + ), + ], const SizedBox(height: AppSpacing.md), + // 操作按钮 Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -707,6 +1110,10 @@ class _ReadLaterPageState extends ConsumerState { _shareText(item.content); }, ), + _actionButton(ext, CupertinoIcons.sparkles, 'AI摘要', () { + Navigator.pop(ctx); + _generateAiSummaryForFeedItem(item); + }), _actionButton( ext, CupertinoIcons.bookmark, @@ -733,7 +1140,7 @@ class _ReadLaterPageState extends ConsumerState { ); } - /// 链接详情弹窗(增强版) + /// 链接详情弹窗(增强版 — 含OG预览/AI摘要) void _showLinkDetail(ReadLaterEntry entry) { final ext = AppTheme.ext(context); final t = ref.read(translationsProvider); @@ -763,13 +1170,10 @@ class _ReadLaterPageState extends ConsumerState { ), ), const SizedBox(height: AppSpacing.md), - Text( - entry.title, - style: AppTypography.headline.copyWith(color: ext.textPrimary), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: AppSpacing.xs), + // OG预览卡片 + _buildOgPreviewCard(ext, entry), + const SizedBox(height: AppSpacing.sm), + // URL Text( url, style: AppTypography.caption1.copyWith(color: ext.accent), @@ -777,6 +1181,7 @@ class _ReadLaterPageState extends ConsumerState { overflow: TextOverflow.ellipsis, ), const SizedBox(height: AppSpacing.md), + // 操作按钮 Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ @@ -815,6 +1220,10 @@ class _ReadLaterPageState extends ConsumerState { } }, ), + _actionButton(ext, CupertinoIcons.sparkles, 'AI摘要', () { + Navigator.pop(ctx); + _generateAiSummaryForEntry(entry); + }), _actionButton( ext, CupertinoIcons.share, @@ -1012,4 +1421,811 @@ class _ReadLaterPageState extends ConsumerState { AppToast.showSuccess('已复制到剪贴板'); } } + + // ============================================================ + // 文件夹操作 + // ============================================================ + + void _showCreateFolderDialog(AppThemeExtension ext, T t) { + final controller = TextEditingController(); + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('新建文件夹'), + content: Padding( + padding: const EdgeInsets.only(top: 8), + child: CupertinoTextField( + controller: controller, + placeholder: '文件夹名称', + style: AppTypography.body.copyWith(color: ext.textPrimary), + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(8), + ), + autofocus: true, + ), + ), + actions: [ + CupertinoDialogAction( + child: Text(t.home.cancelLabel), + onPressed: () => Navigator.pop(ctx), + ), + CupertinoDialogAction( + isDefaultAction: true, + child: Text(t.home.confirmLabel), + onPressed: () async { + final name = controller.text.trim(); + if (name.isNotEmpty) { + await ref.read(readLaterProvider.notifier).createFolder(name); + AppToast.showSuccess('文件夹「$name」已创建'); + } + if (ctx.mounted) Navigator.pop(ctx); + }, + ), + ], + ), + ); + } + + void _showFolderActions(AppThemeExtension ext, String folderId) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + onPressed: () async { + Navigator.pop(ctx); + final controller = TextEditingController(); + showCupertinoDialog( + context: context, + builder: (dCtx) => CupertinoAlertDialog( + title: const Text('重命名文件夹'), + content: Padding( + padding: const EdgeInsets.only(top: 8), + child: CupertinoTextField( + controller: controller, + placeholder: '新名称', + autofocus: true, + ), + ), + actions: [ + CupertinoDialogAction( + child: const Text('取消'), + onPressed: () => Navigator.pop(dCtx), + ), + CupertinoDialogAction( + isDefaultAction: true, + child: const Text('确定'), + onPressed: () async { + final name = controller.text.trim(); + if (name.isNotEmpty) { + await ref + .read(readLaterProvider.notifier) + .renameFolder(folderId, name); + AppToast.showSuccess('已重命名'); + } + if (dCtx.mounted) Navigator.pop(dCtx); + }, + ), + ], + ), + ); + }, + child: const Text('重命名'), + ), + CupertinoActionSheetAction( + isDestructiveAction: true, + onPressed: () async { + Navigator.pop(ctx); + await ref.read(readLaterProvider.notifier).deleteFolder(folderId); + setState(() => _selectedFolderId = null); + AppToast.showSuccess('文件夹已删除'); + }, + child: const Text('删除文件夹'), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(ctx), + child: Text(ref.read(translationsProvider).home.cancelLabel), + ), + ), + ); + } + + void _showMoveToFolderSheet( + AppThemeExtension ext, + T t, + ReadLaterEntry entry, + ) { + ref.read(readLaterProvider.notifier).getFolders().then((folders) { + if (!mounted) return; + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + title: const Text('移入文件夹'), + actions: [ + CupertinoActionSheetAction( + onPressed: () async { + Navigator.pop(ctx); + await ref + .read(readLaterProvider.notifier) + .removeEntryFromFolder(entry.id); + AppToast.showSuccess('已移出文件夹'); + }, + child: const Text('📎 未归档'), + ), + for (final f in folders) + CupertinoActionSheetAction( + onPressed: () async { + Navigator.pop(ctx); + await ref + .read(readLaterProvider.notifier) + .moveEntryToFolder(entry.id, f.id); + AppToast.showSuccess('已移入「${f.name}」'); + }, + child: Text('${f.emoji} ${f.name}'), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + _showCreateFolderDialog(ext, t); + }, + child: const Text('➕ 新建文件夹'), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(ctx), + child: Text(t.home.cancelLabel), + ), + ), + ); + }); + } + + // ============================================================ + // 标签操作 + // ============================================================ + + void _showTagSheet(AppThemeExtension ext, T t, ReadLaterEntry entry) { + final allTags = ref.read(readLaterProvider.notifier).getAllTags(); + final controller = TextEditingController(); + showCupertinoModalPopup( + context: context, + builder: (ctx) => Container( + decoration: BoxDecoration( + color: ext.bgPrimary, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + padding: const EdgeInsets.all(AppSpacing.md), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + '🏷️ 管理标签', + style: AppTypography.headline.copyWith(color: ext.textPrimary), + ), + const SizedBox(height: AppSpacing.sm), + // 当前标签 + if (entry.tags.isNotEmpty) ...[ + Wrap( + spacing: 6, + children: entry.tags + .map( + (tag) => GestureDetector( + onTap: () async { + await ref + .read(readLaterProvider.notifier) + .removeTag(entry.id, tag); + Navigator.pop(ctx); + AppToast.showSuccess('已移除标签「$tag」'); + }, + child: Chip( + label: Text(tag), + deleteIcon: const Icon( + CupertinoIcons.xmark, + size: 14, + ), + onDeleted: () async { + await ref + .read(readLaterProvider.notifier) + .removeTag(entry.id, tag); + Navigator.pop(ctx); + AppToast.showSuccess('已移除标签「$tag」'); + }, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ), + ) + .toList(), + ), + const SizedBox(height: AppSpacing.sm), + ], + // 添加标签 + Row( + children: [ + Expanded( + child: CupertinoTextField( + controller: controller, + placeholder: '输入新标签', + style: AppTypography.subhead.copyWith( + color: ext.textPrimary, + ), + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + const SizedBox(width: 8), + CupertinoButton( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + color: ext.accent, + borderRadius: BorderRadius.circular(8), + child: Text( + '添加', + style: AppTypography.caption1.copyWith( + color: ext.textOnAccent, + ), + ), + onPressed: () async { + final tag = controller.text.trim(); + if (tag.isNotEmpty) { + await ref + .read(readLaterProvider.notifier) + .addTag(entry.id, tag); + Navigator.pop(ctx); + AppToast.showSuccess('已添加标签「$tag」'); + } + }, + ), + ], + ), + // 快捷标签 + if (allTags.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: 6, + children: allTags + .take(10) + .map( + (tag) => GestureDetector( + onTap: () async { + if (!entry.tags.contains(tag)) { + await ref + .read(readLaterProvider.notifier) + .addTag(entry.id, tag); + Navigator.pop(ctx); + AppToast.showSuccess('已添加标签「$tag」'); + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: entry.tags.contains(tag) + ? ext.accent.withValues(alpha: 0.15) + : ext.bgSecondary, + borderRadius: BorderRadius.circular(14), + ), + child: Text( + tag, + style: AppTypography.caption2.copyWith( + color: entry.tags.contains(tag) + ? ext.accent + : ext.textSecondary, + ), + ), + ), + ), + ) + .toList(), + ), + ], + ], + ), + ), + ), + ); + } + + // ============================================================ + // 同步操作 + // ============================================================ + + void _performSync(AppThemeExtension ext, T t) async { + AppToast.showInfo('正在同步...'); + try { + final result = await ref.read(readLaterProvider.notifier).performSync(); + if (mounted) { + AppToast.showSuccess(result.summary); + ref.read(readLaterProvider.notifier).loadItems(refresh: true); + } + } catch (e) { + Log.e('同步失败', e); + AppToast.showWarning('同步失败'); + } + } + + // ============================================================ + // 批量增强操作 + // ============================================================ + + void _batchMoveToFolder(AppThemeExtension ext, T t) async { + final folders = await ref.read(readLaterProvider.notifier).getFolders(); + if (!mounted) return; + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + title: const Text('移入文件夹'), + actions: [ + for (final f in folders) + CupertinoActionSheetAction( + onPressed: () async { + Navigator.pop(ctx); + await ref + .read(readLaterProvider.notifier) + .moveSelectedToFolder(f.id); + _exitSelectMode(); + AppToast.showSuccess('已移入「${f.name}」'); + }, + child: Text('${f.emoji} ${f.name}'), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(ctx), + child: Text(t.home.cancelLabel), + ), + ), + ); + } + + void _batchAddTag(AppThemeExtension ext, T t) { + final controller = TextEditingController(); + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('批量添加标签'), + content: Padding( + padding: const EdgeInsets.only(top: 8), + child: CupertinoTextField( + controller: controller, + placeholder: '输入标签名', + autofocus: true, + ), + ), + actions: [ + CupertinoDialogAction( + child: Text(t.home.cancelLabel), + onPressed: () => Navigator.pop(ctx), + ), + CupertinoDialogAction( + isDefaultAction: true, + child: Text(t.home.confirmLabel), + onPressed: () async { + final tag = controller.text.trim(); + if (tag.isNotEmpty) { + await ref + .read(readLaterProvider.notifier) + .addTagToSelected(tag); + _exitSelectMode(); + AppToast.showSuccess('已添加标签「$tag」'); + } + if (ctx.mounted) Navigator.pop(ctx); + }, + ), + ], + ), + ); + } + + void _batchShare(AppThemeExtension ext, T t) async { + final lists = await ref.read(readLaterProvider.notifier).getSharedLists(); + if (!mounted) return; + if (lists.isEmpty) { + AppToast.showInfo('暂无共享列表,请先创建'); + return; + } + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + title: const Text('分享到共享列表'), + actions: [ + for (final list in lists) + CupertinoActionSheetAction( + onPressed: () async { + Navigator.pop(ctx); + await ref + .read(readLaterProvider.notifier) + .shareSelectedToSharedList(list.id); + _exitSelectMode(); + AppToast.showSuccess('已分享到「${list.name}」'); + }, + child: Text('📤 ${list.name}'), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(ctx), + child: Text(t.home.cancelLabel), + ), + ), + ); + } + + /// OG预览卡片 + Widget _buildOgPreviewCard(AppThemeExtension ext, ReadLaterEntry entry) { + final title = entry.title; + final desc = entry.subtitle; + return GlassContainer( + depth: GlassDepth.elevated, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: ext.accent.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(CupertinoIcons.link, size: 16, color: ext.accent), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: AppTypography.subhead.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + if (desc.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + desc, + style: AppTypography.caption1.copyWith(color: ext.textSecondary), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ); + } + + /// 为FeedItem生成AI摘要 + void _generateAiSummaryForFeedItem(FeedItem item) async { + AppToast.showInfo('正在生成AI摘要...'); + try { + final entry = ReadLaterEntry( + id: item.id.toString(), + type: ReadLaterEntryType.feed, + title: item.content, + subtitle: item.author, + ); + final summary = await ref + .read(readLaterProvider.notifier) + .suggestTags(entry); + if (mounted && summary.isNotEmpty) { + _showAiSummarySheet( + ext: AppTheme.ext(context), + summary: summary.join('、'), + ); + } else if (mounted) { + AppToast.showWarning('无法生成摘要'); + } + } catch (e) { + Log.e('AI摘要生成失败', e); + if (mounted) AppToast.showWarning('AI摘要生成失败'); + } + } + + /// 为条目生成AI摘要 + void _generateAiSummaryForEntry(ReadLaterEntry entry) async { + AppToast.showInfo('正在生成AI摘要...'); + try { + final summary = await ref + .read(readLaterProvider.notifier) + .generateAiSummary(entry); + if (mounted && summary != null) { + _showAiSummarySheet(ext: AppTheme.ext(context), summary: summary); + } else if (mounted) { + AppToast.showWarning('无法生成摘要'); + } + } catch (e) { + Log.e('AI摘要生成失败', e); + if (mounted) AppToast.showWarning('AI摘要生成失败'); + } + } + + /// 显示AI摘要弹窗 + void _showAiSummarySheet({ + required AppThemeExtension ext, + required String summary, + }) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => Container( + decoration: BoxDecoration( + color: ext.bgPrimary, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + padding: const EdgeInsets.all(AppSpacing.md), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + const Text('✨', style: TextStyle(fontSize: 20)), + const SizedBox(width: 8), + Text( + 'AI摘要', + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Text( + summary, + style: AppTypography.body.copyWith(color: ext.textPrimary), + ), + const SizedBox(height: AppSpacing.md), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + CupertinoButton( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 6, + ), + color: ext.accent.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + child: Text( + '复制', + style: AppTypography.caption1.copyWith(color: ext.accent), + ), + onPressed: () { + Clipboard.setData(ClipboardData(text: summary)); + AppToast.showSuccess('已复制摘要'); + Navigator.pop(ctx); + }, + ), + ], + ), + ], + ), + ), + ), + ); + } + + /// 格式化时间戳 + String _formatTimestamp(int timestamp) { + try { + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); + final now = DateTime.now(); + final diff = now.difference(dt); + if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前'; + if (diff.inHours < 24) return '${diff.inHours}小时前'; + if (diff.inDays < 7) return '${diff.inDays}天前'; + return '${dt.month}/${dt.day}'; + } catch (_) { + return ''; + } + } + + // ============================================================ + // 二维码分享 + // ============================================================ + + /// 显示二维码分享弹窗 + void _showQrShareSheet( + AppThemeExtension ext, + String listId, + String listName, + ) { + final shareUrl = 'xianyan://readlater/join?list=$listId'; + showCupertinoModalPopup( + context: context, + builder: (ctx) => Container( + decoration: BoxDecoration( + color: ext.bgPrimary, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + padding: const EdgeInsets.all(AppSpacing.md), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + '分享「$listName」', + style: AppTypography.headline.copyWith(color: ext.textPrimary), + ), + const SizedBox(height: AppSpacing.sm), + Text( + '扫描二维码加入共享列表', + style: AppTypography.caption1.copyWith( + color: ext.textSecondary, + ), + ), + const SizedBox(height: AppSpacing.md), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(16), + ), + child: QrImageView( + data: shareUrl, + size: 200, + backgroundColor: ext.bgPrimary, + eyeStyle: QrEyeStyle( + eyeShape: QrEyeShape.circle, + color: ext.textPrimary, + ), + dataModuleStyle: QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.circle, + color: ext.textPrimary, + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + shareUrl, + style: AppTypography.caption2.copyWith(color: ext.textHint), + ), + const SizedBox(height: AppSpacing.md), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _actionButton( + ext, + CupertinoIcons.doc_on_clipboard, + '复制链接', + () { + Clipboard.setData(ClipboardData(text: shareUrl)); + AppToast.showSuccess('已复制分享链接'); + Navigator.pop(ctx); + }, + ), + _actionButton(ext, CupertinoIcons.share, '分享', () { + Navigator.pop(ctx); + _shareText(shareUrl); + }), + ], + ), + ], + ), + ), + ), + ); + } + + /// 显示共享列表(含二维码分享) + void _showSharedListsWithQr(AppThemeExtension ext, T t) async { + final lists = await ref.read(readLaterProvider.notifier).getSharedLists(); + if (!mounted) return; + if (lists.isEmpty) { + AppToast.showInfo('暂无共享列表,请先创建'); + return; + } + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + title: const Text('选择操作'), + actions: [ + for (final list in lists) + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + _showQrShareSheet(ext, list.id, list.name); + }, + child: Text('📱 ${list.name} (${list.messageCount}条)'), + ), + CupertinoActionSheetAction( + onPressed: () async { + Navigator.pop(ctx); + final listId = await ref + .read(readLaterProvider.notifier) + .createSharedList('新列表'); + if (listId.isNotEmpty && mounted) { + _showQrShareSheet(ext, listId, '新列表'); + } + }, + child: const Text('➕ 创建新列表并分享'), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(ctx), + child: Text(t.home.cancelLabel), + ), + ), + ); + } + + // ============================================================ + // 桌面端拖拽 + // ============================================================ + + /// 处理桌面端拖拽文件 + void _handleDroppedFiles(DropDoneDetails details) async { + setState(() => _isDraggingOver = false); + int added = 0; + for (final file in details.files) { + final path = file.path; + final name = path.split('/').last; + try { + // 根据文件类型添加到稍后读 + if (name.endsWith('.url') || name.endsWith('.webloc')) { + // 链接文件 — 读取内容获取URL + // 简化处理:直接作为文本条目添加 + await ref + .read(readLaterProvider.notifier) + .addTag('desktop_drop_$name', '桌面拖拽'); + } + added++; + } catch (e) { + Log.e('拖拽文件添加失败: $name', e); + } + } + if (added > 0) { + AppToast.showSuccess('已添加 $added 个文件到稍后读'); + ref.read(readLaterProvider.notifier).loadItems(refresh: true); + } + } } diff --git a/lib/features/home/providers/home_feed_mixin.dart b/lib/features/home/providers/home_feed_mixin.dart index edfc12fb..f95cefd4 100644 --- a/lib/features/home/providers/home_feed_mixin.dart +++ b/lib/features/home/providers/home_feed_mixin.dart @@ -10,6 +10,7 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/network/api_interceptor.dart'; import '../../../core/storage/kv_storage.dart'; import '../../../core/storage/database/app_database.dart'; import '../../../core/utils/logger.dart'; @@ -75,6 +76,7 @@ mixin HomeFeedMixin on Notifier { limit: limit, seenIds: seenIds.isNotEmpty ? seenIds : null, seenHashes: seenHashes.isNotEmpty ? seenHashes : null, + platform: ApiInterceptor.currentPlatform, ); if (result.list.isEmpty) { @@ -90,17 +92,15 @@ mixin HomeFeedMixin on Notifier { final newSentences = result.list .map(HomeSentence.fromFeedItem) .where((s) => s.text.isNotEmpty) - .where( - (s) { - // 选择了具体分类时,严格过滤只保留该分类 - if (selectedType != null) { - return s.feedType == selectedType; - } - // "推荐"模式下,只显示启用的分类 - return enabledChannelKeys.isEmpty || - enabledChannelKeys.contains(s.feedType); - }, - ) + .where((s) { + // 选择了具体分类时,严格过滤只保留该分类 + if (selectedType != null) { + return s.feedType == selectedType; + } + // "推荐"模式下,只显示启用的分类 + return enabledChannelKeys.isEmpty || + enabledChannelKeys.contains(s.feedType); + }) .toList(); final unique = newSentences @@ -159,7 +159,8 @@ mixin HomeFeedMixin on Notifier { ); if (unique.isNotEmpty) { - if (categoryCache.length >= maxCategoryCacheSize && !categoryCache.containsKey(cacheKey)) { + if (categoryCache.length >= maxCategoryCacheSize && + !categoryCache.containsKey(cacheKey)) { categoryCache.remove(categoryCache.keys.first); } categoryCache[cacheKey] = List.from(unique); @@ -176,7 +177,9 @@ mixin HomeFeedMixin on Notifier { loadCachedChannels(); try { - final channels = await FeedService.fetchChannels(); + final channels = await FeedService.fetchChannels( + platform: ApiInterceptor.currentPlatform, + ); if (channels.isNotEmpty) { allChannels = channels; final disabledKeys = loadDisabledKeys(); @@ -196,7 +199,9 @@ mixin HomeFeedMixin on Notifier { var source = allChannels.isNotEmpty ? allChannels : state.channels; if (source.isEmpty) { try { - source = await FeedService.fetchChannels(); + source = await FeedService.fetchChannels( + platform: ApiInterceptor.currentPlatform, + ); if (source.isNotEmpty) { allChannels = source; } @@ -334,12 +339,18 @@ mixin HomeFeedMixin on Notifier { if (raw != null && raw.isNotEmpty) { mixConfig = FeedMixConfig.fromJson( jsonDecode(raw) as Map, - ); + ).copyWith(platform: ApiInterceptor.currentPlatform); } else { - mixConfig = const FeedMixConfig(limit: 5); + mixConfig = FeedMixConfig( + limit: 5, + platform: ApiInterceptor.currentPlatform, + ); } } catch (_) { - mixConfig = const FeedMixConfig(limit: 5); + mixConfig = FeedMixConfig( + limit: 5, + platform: ApiInterceptor.currentPlatform, + ); } if (mixConfig.mode == 'specific') { @@ -402,12 +413,18 @@ mixin HomeFeedMixin on Notifier { if (raw != null && raw.isNotEmpty) { mixConfig = FeedMixConfig.fromJson( jsonDecode(raw) as Map, - ); + ).copyWith(platform: ApiInterceptor.currentPlatform); } else { - mixConfig = const FeedMixConfig(limit: 5); + mixConfig = FeedMixConfig( + limit: 5, + platform: ApiInterceptor.currentPlatform, + ); } } catch (_) { - mixConfig = const FeedMixConfig(limit: 5); + mixConfig = FeedMixConfig( + limit: 5, + platform: ApiInterceptor.currentPlatform, + ); } if (mixConfig.mode == 'specific') { @@ -533,6 +550,7 @@ mixin HomeFeedMixin on Notifier { lite: true, limit: limit, seenIds: allSeenIds.isNotEmpty ? allSeenIds.toList() : null, + platform: ApiInterceptor.currentPlatform, ); Log.i( 'fetchNewSentences: channel=${params.channel}, sort=${params.sort}, page=${params.page}, limit=$limit', @@ -578,17 +596,15 @@ mixin HomeFeedMixin on Notifier { final newSentences = result.list .map(HomeSentence.fromFeedItem) .where((s) => s.text.isNotEmpty) - .where( - (s) { - // 选择了具体分类时,严格过滤只保留该分类 - if (selectedType != null) { - return s.feedType == selectedType; - } - // "推荐"模式下,只显示启用的分类 - return enabledChannelKeys.isEmpty || - enabledChannelKeys.contains(s.feedType); - }, - ) + .where((s) { + // 选择了具体分类时,严格过滤只保留该分类 + if (selectedType != null) { + return s.feedType == selectedType; + } + // "推荐"模式下,只显示启用的分类 + return enabledChannelKeys.isEmpty || + enabledChannelKeys.contains(s.feedType); + }) .toList(); final selfDeduped = []; @@ -640,15 +656,13 @@ mixin HomeFeedMixin on Notifier { : result.list .map(HomeSentence.fromFeedItem) .where((s) => s.text.isNotEmpty) - .where( - (s) { - if (selectedType != null) { - return s.feedType == selectedType; - } - return enabledChannelKeys.isEmpty || - enabledChannelKeys.contains(s.feedType); - }, - ) + .where((s) { + if (selectedType != null) { + return s.feedType == selectedType; + } + return enabledChannelKeys.isEmpty || + enabledChannelKeys.contains(s.feedType); + }) .toList(); if (fallback.isNotEmpty) { await saveToDb(fallback); @@ -681,7 +695,8 @@ mixin HomeFeedMixin on Notifier { lastCycleIds: [], ); if (finalList.isNotEmpty) { - if (categoryCache.length >= maxCategoryCacheSize && !categoryCache.containsKey(cacheKey)) { + if (categoryCache.length >= maxCategoryCacheSize && + !categoryCache.containsKey(cacheKey)) { categoryCache.remove(categoryCache.keys.first); } categoryCache[cacheKey] = List.from(finalList); @@ -742,7 +757,8 @@ mixin HomeFeedMixin on Notifier { lastCycleIds: [], ); if (finalList.isNotEmpty) { - if (categoryCache.length >= maxCategoryCacheSize && !categoryCache.containsKey(cacheKey)) { + if (categoryCache.length >= maxCategoryCacheSize && + !categoryCache.containsKey(cacheKey)) { categoryCache.remove(categoryCache.keys.first); } categoryCache[cacheKey] = List.from(finalList); @@ -860,12 +876,14 @@ mixin HomeFeedMixin on Notifier { moodName = 'bored'; else moodName = 'bored'; - ref.read(homeWidgetServiceProvider).updateDailyWithCharacter( - content: sentence.text, - author: sentence.author, - sentenceId: sentence.id, - mood: moodName, - ); + ref + .read(homeWidgetServiceProvider) + .updateDailyWithCharacter( + content: sentence.text, + author: sentence.author, + sentenceId: sentence.id, + mood: moodName, + ); } catch (e) { Log.e('同步拾光角色小组件失败', e); } diff --git a/lib/features/home/services/feed_service.dart b/lib/features/home/services/feed_service.dart index e2a03d58..3590dc4d 100644 --- a/lib/features/home/services/feed_service.dart +++ b/lib/features/home/services/feed_service.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — Feed信息流API服务 // 创建时间: 2026-04-28 -// 更新时间: 2026-05-05 +// 更新时间: 2026-06-09 // 作用: 封装所有 /api/feed/* 接口调用 -// 上次更新: action方法增加登录检查,未登录跳过API请求避免401 +// 上次更新: fetchChannels/fetchList/fetchMix新增platform参数支持,服务端按平台过滤 // ============================================================ import 'dart:convert'; @@ -11,6 +11,7 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/network/api_client.dart'; +import '../../../core/network/api_interceptor.dart'; import '../../../core/storage/secure_storage.dart'; import '../../../core/utils/logger.dart'; import '../models/feed_model.dart'; @@ -33,11 +34,18 @@ class FeedService { /// 获取频道列表 /// /// 返回所有可用频道,包含 key/name/icon/count。 + /// [platform] 平台标识,不传则使用当前设备平台(通过X-Platform请求头传递)。 /// 无需登录。 - static Future> fetchChannels() async { + static Future> fetchChannels({String? platform}) async { try { + final queryParams = {}; + // 显式传递platform参数(服务端也支持从X-Platform请求头读取) + final p = platform ?? ApiInterceptor.currentPlatform; + if (p.isNotEmpty) queryParams['platform'] = p; + final response = await _api.get>( '/api/feed/channels', + queryParameters: queryParams, ); final data = response.data as Map; if (data['code'] != 1) return []; @@ -426,6 +434,7 @@ class FeedService { int limit = 20, List? seenIds, List? seenHashes, + String? platform, }) async { try { final params = { @@ -445,6 +454,8 @@ class FeedService { : seenHashes; params['seen_hashes'] = trimmedHashes.join(','); } + final p = platform ?? ApiInterceptor.currentPlatform; + if (p.isNotEmpty) params['platform'] = p; final response = await _api.get>( '/api/feed/refresh_content', diff --git a/lib/features/mine/profile/presentation/about_page.dart b/lib/features/mine/profile/presentation/about_page.dart index 72660265..15a3ee52 100644 --- a/lib/features/mine/profile/presentation/about_page.dart +++ b/lib/features/mine/profile/presentation/about_page.dart @@ -12,6 +12,7 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:xianyan/core/router/app_nav_extension.dart'; import 'package:xianyan/core/router/app_routes.dart'; +import 'package:xianyan/core/utils/platform/platform_utils.dart' as pu; import 'package:package_info_plus/package_info_plus.dart'; import '../../../../core/theme/app_theme.dart'; @@ -311,7 +312,11 @@ class _BasicInfoSection extends ConsumerWidget { title: t.about.usageGuide, subtitle: t.about.usageGuideDesc, ext: ext, - onTap: () => context.appPush(AppRoutes.onboardingReview), + onTap: () => context.appPush( + pu.isAndroid + ? AppRoutes.onboardingSkipAgreement + : AppRoutes.onboardingReview, + ), ), ], ); diff --git a/lib/features/mine/settings/presentation/general/general_settings_page.dart b/lib/features/mine/settings/presentation/general/general_settings_page.dart index d4b6ec72..549de3ff 100644 --- a/lib/features/mine/settings/presentation/general/general_settings_page.dart +++ b/lib/features/mine/settings/presentation/general/general_settings_page.dart @@ -13,6 +13,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import '../../../../../../core/constants/app_constants.dart'; import '../../../../../../core/router/app_routes.dart'; +import '../../../../../../core/services/auth/permission_service.dart'; import '../../../../../../core/services/device/app_lock_service.dart'; import '../../../../../../core/services/device/haptic_service.dart'; import '../../../../../../core/services/feature/feature_flag_service.dart'; @@ -930,6 +931,34 @@ class _GeneralSettingsPageState extends ConsumerState case 'sfx': notifier.setSfxEnabled(value); case 'shake_to_switch': + if (value && !PermissionService.isShakeEnabled) { + // 摇一摇权限未开启,引导用户去权限管理页面 + if (context.mounted) { + final t = ref.read(translationsProvider); + showCupertinoDialog( + context: context, + builder: (_) => CupertinoAlertDialog( + title: Text(t.settings.permission.permShakeLabel), + content: Text(t.settings.permission.permShakeDesc), + actions: [ + CupertinoDialogAction( + child: Text(t.common.cancel), + onPressed: () => Navigator.pop(context), + ), + CupertinoDialogAction( + isDefaultAction: true, + child: Text(t.settings.permission.btnGoSettings), + onPressed: () { + Navigator.pop(context); + context.appPush(AppRoutes.permissionManagement); + }, + ), + ], + ), + ); + } + return; + } notifier.setShakeToSwitch(value); case 'daily_notification': notifier.setDailyNotification(value); @@ -999,7 +1028,6 @@ class _GeneralSettingsPageState extends ConsumerState context.appPush(AppRoutes.fontManagement); case 'app_lock': context.appPush(AppRoutes.appLockSettings); - } } @@ -1025,9 +1053,12 @@ class _GeneralSettingsPageState extends ConsumerState Navigator.of(context).push( CupertinoPageRoute( fullscreenDialog: true, - builder: (_) => const OnboardingPage(), + builder: (_) => OnboardingPage(skipAgreement: pu.isAndroid), ), ); + } else if (pu.isAndroid) { + // 安卓端跳过协议页,直接从欢迎与指引开始 + context.appGo(AppRoutes.onboardingSkipAgreement); } else { context.appGo(AppRoutes.onboarding); } diff --git a/lib/features/mine/settings/presentation/more_settings_page.dart b/lib/features/mine/settings/presentation/more_settings_page.dart index 9663503c..d7a73fd2 100644 --- a/lib/features/mine/settings/presentation/more_settings_page.dart +++ b/lib/features/mine/settings/presentation/more_settings_page.dart @@ -19,6 +19,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../../../core/services/device/haptic_service.dart'; import '../../../../core/router/app_routes.dart'; import '../../../../core/router/app_nav_extension.dart'; +import '../../../../core/utils/platform/platform_utils.dart' as pu; import '../../../../core/storage/database/app_database.dart'; import '../../../../core/storage/kv_storage.dart'; import '../../../../core/theme/app_theme.dart'; @@ -529,7 +530,11 @@ class _MoreSettingsPageState extends ConsumerState { await Future.delayed(const Duration(seconds: 2)); if (mounted) { - context.appGo(AppRoutes.onboardingReview); + context.appGo( + pu.isAndroid + ? AppRoutes.onboardingSkipAgreement + : AppRoutes.onboardingReview, + ); } } } catch (e) { diff --git a/lib/features/mine/settings/services/font_download_service.dart b/lib/features/mine/settings/services/font_download_service.dart index fc8486ca..b44180a8 100644 --- a/lib/features/mine/settings/services/font_download_service.dart +++ b/lib/features/mine/settings/services/font_download_service.dart @@ -338,7 +338,7 @@ class FontDownloadService { } } - if (!isValidFontFile(fontBytes!)) { + if (!isValidFontFile(fontBytes)) { if (await File(destPath).exists()) { await File(destPath).delete(); } diff --git a/lib/features/onboarding/presentation/onboarding_page.dart b/lib/features/onboarding/presentation/onboarding_page.dart index 92295e8e..9b8286b2 100644 --- a/lib/features/onboarding/presentation/onboarding_page.dart +++ b/lib/features/onboarding/presentation/onboarding_page.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 引导页主容器 // 创建时间: 2026-05-21 -// 更新时间: 2026-05-29 -// 作用: PageView包裹3页引导流程,底部Step Dots指示器 -// 上次更新: 宽屏适配,使用ResponsiveMaxWidth居中内容+两侧留白,替换原双栏布局 +// 更新时间: 2026-06-09 +// 作用: PageView包裹引导流程,底部Step Dots指示器 +// 上次更新: 安卓端页面顺序调整(协议→欢迎→个性化),支持skipAgreement跳过协议页 // ============================================================ import 'package:flutter/cupertino.dart'; @@ -15,8 +15,8 @@ import '../../../core/services/device/haptic_service.dart'; import '../../../core/theme/app_radius.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../core/theme/app_theme.dart'; +import '../../../core/utils/platform/platform_utils.dart' as pu; import '../../../shared/widgets/adaptive/responsive_layout.dart'; -import '../data/onboarding_constants.dart'; import '../providers/onboarding_provider.dart'; import 'widgets/mesh_gradient_background.dart'; import 'pages/welcome_page.dart'; @@ -31,11 +31,38 @@ class OnboardingNavScope extends InheritedWidget { const OnboardingNavScope({ super.key, required this.goToPage, + required this.agreementIndex, + required this.welcomeIndex, + required this.personalizationIndex, required super.child, }); + /// 跳转到指定页码 final void Function(int page) goToPage; + /// 软件协议页在PageView中的索引,-1表示不存在(skipAgreement时) + final int agreementIndex; + + /// 欢迎与指引页在PageView中的索引 + final int welcomeIndex; + + /// 个性化设置页在PageView中的索引 + final int personalizationIndex; + + /// 软件协议页是否存在 + bool get hasAgreementPage => agreementIndex >= 0; + + /// 导航到软件协议页 + void goToAgreement() { + if (hasAgreementPage) goToPage(agreementIndex); + } + + /// 导航到欢迎与指引页 + void goToWelcome() => goToPage(welcomeIndex); + + /// 导航到个性化设置页 + void goToPersonalization() => goToPage(personalizationIndex); + static OnboardingNavScope of(BuildContext context) { final scope = context .dependOnInheritedWidgetOfExactType(); @@ -45,7 +72,10 @@ class OnboardingNavScope extends InheritedWidget { @override bool updateShouldNotify(OnboardingNavScope oldWidget) => - goToPage != oldWidget.goToPage; + goToPage != oldWidget.goToPage || + agreementIndex != oldWidget.agreementIndex || + welcomeIndex != oldWidget.welcomeIndex || + personalizationIndex != oldWidget.personalizationIndex; } // ============================================================ @@ -53,7 +83,10 @@ class OnboardingNavScope extends InheritedWidget { // ============================================================ class OnboardingPage extends ConsumerStatefulWidget { - const OnboardingPage({super.key}); + const OnboardingPage({super.key, this.skipAgreement = false}); + + /// 是否跳过协议页(安卓端从软件内重新打开引导页时使用) + final bool skipAgreement; @override ConsumerState createState() => _OnboardingPageState(); @@ -63,12 +96,68 @@ class _OnboardingPageState extends ConsumerState { late PageController _pageController; bool _showLottie = false; + // ---- 页面索引映射(根据平台和skipAgreement动态计算) ---- + late int _agreementIndex; + late int _welcomeIndex; + late int _personalizationIndex; + late int _totalPages; + late List _pages; + @override void initState() { super.initState(); + _initPageOrder(); _pageController = PageController(); } + @override + void didUpdateWidget(covariant OnboardingPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.skipAgreement != widget.skipAgreement) { + _initPageOrder(); + } + } + + /// 根据平台和skipAgreement参数初始化页面顺序 + /// + /// 非安卓端:欢迎(0) → 协议(1) → 个性化(2) + /// 安卓端首次:协议(0) → 欢迎(1) → 个性化(2) + /// 安卓端从App内打开(skipAgreement):欢迎(0) → 个性化(1) + void _initPageOrder() { + final isAndroid = pu.isAndroid; + final skipAgreement = widget.skipAgreement; + + if (skipAgreement) { + // 跳过协议页:欢迎 → 个性化 + _agreementIndex = -1; + _welcomeIndex = 0; + _personalizationIndex = 1; + _totalPages = 2; + _pages = const [WelcomePage(), PersonalizationPage()]; + } else if (isAndroid) { + // 安卓端:协议 → 欢迎 → 个性化 + _agreementIndex = 0; + _welcomeIndex = 1; + _personalizationIndex = 2; + _totalPages = 3; + _pages = const [AgreementPage(), WelcomePage(), PersonalizationPage()]; + } else { + // 其他端:欢迎 → 协议 → 个性化 + _agreementIndex = 1; + _welcomeIndex = 0; + _personalizationIndex = 2; + _totalPages = 3; + _pages = const [WelcomePage(), AgreementPage(), PersonalizationPage()]; + } + + // 更新provider中的totalPages + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + ref.read(onboardingProvider.notifier).setTotalPages(_totalPages); + } + }); + } + @override void dispose() { _pageController.dispose(); @@ -77,26 +166,18 @@ class _OnboardingPageState extends ConsumerState { void _onPageChanged(int page) { final state = ref.read(onboardingProvider); - if (page == 2 && !state.canProceedAgreement) { + + // 检查是否尝试进入个性化页但未同意协议(仅当协议页存在时) + if (page == _personalizationIndex && + _agreementIndex >= 0 && + !state.canProceedAgreement) { + // 弹回协议页 _pageController.animateToPage( - 1, + _agreementIndex, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); - showCupertinoDialog( - context: context, - builder: (ctx) => CupertinoAlertDialog( - title: const Text('温馨提示'), - content: const Text('请先阅读并同意软件协议和权限说明,才能继续使用闲言。'), - actions: [ - CupertinoDialogAction( - isDefaultAction: true, - onPressed: () => Navigator.pop(ctx), - child: const Text('我知道了'), - ), - ], - ), - ); + _showAgreementReminder(); return; } HapticService.selection(); @@ -108,15 +189,14 @@ class _OnboardingPageState extends ConsumerState { } void _goToPage(int page) { - if (page < 0 || page >= OnboardingConstants.totalPages) return; + if (page < 0 || page >= _totalPages) return; if (!_pageController.hasClients) { - // PageController 没有客户端时,尝试直接设置页码 ref.read(onboardingProvider.notifier).setPage(page); return; } - // 检查协议是否同意(跳到第2页需要) - if (page >= 2) { + // 检查协议是否同意(跳到个性化页需要,仅当协议页存在时) + if (_agreementIndex >= 0 && page == _personalizationIndex) { final state = ref.read(onboardingProvider); if (!state.canProceedAgreement) { _showAgreementReminder(); @@ -169,6 +249,9 @@ class _OnboardingPageState extends ConsumerState { SafeArea( child: OnboardingNavScope( goToPage: _goToPage, + agreementIndex: _agreementIndex, + welcomeIndex: _welcomeIndex, + personalizationIndex: _personalizationIndex, child: LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth >= kSplitViewBreakpoint; @@ -226,7 +309,7 @@ class _OnboardingPageState extends ConsumerState { controller: _pageController, physics: const ClampingScrollPhysics(), onPageChanged: _onPageChanged, - children: const [WelcomePage(), AgreementPage(), PersonalizationPage()], + children: _pages, ), ), Padding( @@ -243,7 +326,7 @@ class _OnboardingPageState extends ConsumerState { Widget _buildStepDots(AppThemeExtension ext, OnboardingState state) { return Row( mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(OnboardingConstants.totalPages, (i) { + children: List.generate(_totalPages, (i) { final isCurrent = i == state.currentPage; final isPast = i < state.currentPage; return GestureDetector( diff --git a/lib/features/onboarding/presentation/pages/agreement_page.dart b/lib/features/onboarding/presentation/pages/agreement_page.dart index 3542fc64..5b07aa7b 100644 --- a/lib/features/onboarding/presentation/pages/agreement_page.dart +++ b/lib/features/onboarding/presentation/pages/agreement_page.dart @@ -1,14 +1,15 @@ // ============================================================ -// 闲言APP — 软件协议页(引导页第2页) +// 闲言APP — 软件协议页(引导页) // 创建时间: 2026-05-21 -// 更新时间: 2026-06-02 +// 更新时间: 2026-06-09 // 作用: 隐私政策/用户协议/权限说明,勾选同意后继续 -// 上次更新: 协议内容/标题/更新日期支持多语言,章节解析兼容英文罗马数字编号 +// 上次更新: 添加滚动进度条;导航改用OnboardingNavScope语义索引适配安卓端页面顺序 // ============================================================ import 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart' show Colors; +import 'package:flutter/material.dart' + show Colors, Scrollbar, LinearProgressIndicator; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../../core/constants/character_expression.dart'; @@ -41,26 +42,44 @@ class AgreementPage extends ConsumerStatefulWidget { class _AgreementPageState extends ConsumerState { late ScrollController _scrollController; + late ScrollController _permissionScrollController; final Map _chapterKeys = {}; int _activeChapterIndex = 0; + double _scrollProgress = 0.0; + double _permissionScrollProgress = 0.0; @override void initState() { super.initState(); _scrollController = ScrollController(); + _permissionScrollController = ScrollController(); _scrollController.addListener(_onScroll); + _permissionScrollController.addListener(_onPermissionScroll); } @override void dispose() { _scrollController.removeListener(_onScroll); + _permissionScrollController.removeListener(_onPermissionScroll); _scrollController.dispose(); + _permissionScrollController.dispose(); super.dispose(); } void _onScroll() { final chapters = _chapterKeys.values.toList(); - if (chapters.isEmpty || !_scrollController.hasClients) return; + if (!_scrollController.hasClients) return; + + // 更新滚动进度 + final maxScroll = _scrollController.position.maxScrollExtent; + if (maxScroll > 0) { + final progress = (_scrollController.offset / maxScroll).clamp(0.0, 1.0); + if ((progress - _scrollProgress).abs() > 0.005 && mounted) { + setState(() => _scrollProgress = progress); + } + } + + if (chapters.isEmpty) return; final scrollOffset = _scrollController.offset + 80; int closest = 0; @@ -80,6 +99,21 @@ class _AgreementPageState extends ConsumerState { } } + /// 权限说明列表滚动监听 + void _onPermissionScroll() { + if (!_permissionScrollController.hasClients) return; + final maxScroll = _permissionScrollController.position.maxScrollExtent; + if (maxScroll > 0) { + final progress = (_permissionScrollController.offset / maxScroll).clamp( + 0.0, + 1.0, + ); + if ((progress - _permissionScrollProgress).abs() > 0.005 && mounted) { + setState(() => _permissionScrollProgress = progress); + } + } + } + void _scrollToChapter(int index) { final keys = _chapterKeys.values.toList(); if (index >= keys.length) return; @@ -144,10 +178,14 @@ class _AgreementPageState extends ConsumerState { PageNavHeader( icon: CupertinoIcons.doc_text_fill, previousLabel: ob.welcomeNavLabel, - onPrevious: () { - HapticService.light(); - OnboardingNavScope.of(context).goToPage(0); - }, + onPrevious: + OnboardingNavScope.of(context).hasAgreementPage && + OnboardingNavScope.of(context).agreementIndex > 0 + ? () { + HapticService.light(); + OnboardingNavScope.of(context).goToWelcome(); + } + : null, trailing: _buildSkipButton( ext, state, @@ -287,8 +325,14 @@ class _AgreementPageState extends ConsumerState { } final agreementType = _agreementTypeForIndex(state.agreementTabIndex); - final content = AgreementData.getContent(agreementType, languageId: languageId); - final updateDate = AgreementData.getUpdateDate(agreementType, languageId: languageId); + final content = AgreementData.getContent( + agreementType, + languageId: languageId, + ); + final updateDate = AgreementData.getUpdateDate( + agreementType, + languageId: languageId, + ); final chapters = _parseChapters(content, languageId); if (_chapterKeys.isEmpty && chapters.isNotEmpty) { @@ -304,18 +348,26 @@ class _AgreementPageState extends ConsumerState { child: Column( children: [ _buildChapterNav(ext, chapters), - const SizedBox(height: AppSpacing.sm), + const SizedBox(height: AppSpacing.xs), + // 滚动进度条 + _buildScrollProgressBar(ext), + const SizedBox(height: AppSpacing.xs), Expanded( - child: SingleChildScrollView( + child: Scrollbar( controller: _scrollController, - child: _buildAgreementBody( - ext, - agreementType, - content, - updateDate, - chapters, - ob, - languageId, + thumbVisibility: true, + radius: const Radius.circular(4), + child: SingleChildScrollView( + controller: _scrollController, + child: _buildAgreementBody( + ext, + agreementType, + content, + updateDate, + chapters, + ob, + languageId, + ), ), ), ), @@ -366,6 +418,37 @@ class _AgreementPageState extends ConsumerState { ); } + /// 构建滚动进度条 + Widget _buildScrollProgressBar(AppThemeExtension ext, {double? progress}) { + final p = progress ?? _scrollProgress; + return Column( + children: [ + ClipRRect( + borderRadius: AppRadius.xsBorder, + child: LinearProgressIndicator( + value: p, + backgroundColor: ext.textHint.withValues(alpha: 0.1), + valueColor: AlwaysStoppedAnimation(ext.accent), + minHeight: 2, + ), + ), + const SizedBox(height: 2), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + '${(p * 100).toInt()}%', + style: AppTypography.caption2.copyWith( + color: ext.textHint, + fontSize: 10, + ), + ), + ], + ), + ], + ); + } + Widget _buildAgreementBody( AppThemeExtension ext, AgreementType agreementType, @@ -553,47 +636,67 @@ class _AgreementPageState extends ConsumerState { depth: GlassDepth.elevated, borderRadius: AppRadius.lgBorder, padding: const EdgeInsets.all(AppSpacing.md), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - CupertinoIcons.lock_shield_fill, - size: 18, - color: ext.accent, + child: Column( + children: [ + // 滚动进度条 + _buildScrollProgressBar(ext, progress: _permissionScrollProgress), + const SizedBox(height: AppSpacing.xs), + Expanded( + child: Scrollbar( + controller: _permissionScrollController, + thumbVisibility: true, + radius: const Radius.circular(4), + child: SingleChildScrollView( + controller: _permissionScrollController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + CupertinoIcons.lock_shield_fill, + size: 18, + color: ext.accent, + ), + const SizedBox(width: AppSpacing.sm), + Text( + ob.permissionUsageTitle, + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + Text( + ob.permissionUsageDesc, + style: AppTypography.caption1.copyWith( + color: ext.textHint, + ), + ), + const SizedBox(height: AppSpacing.sm), + Container( + height: 0.5, + color: ext.textHint.withValues(alpha: 0.2), + ), + const SizedBox(height: AppSpacing.sm), + ...groupedPermissions.entries.expand((entry) { + if (entry.value.isEmpty) return []; + return [ + _buildPermissionGroupHeader(ext, entry.key, ob), + const SizedBox(height: AppSpacing.sm), + ...entry.value.map( + (perm) => _buildPermissionItem(ext, perm, ob), + ), + const SizedBox(height: AppSpacing.sm), + ]; + }), + ], ), - const SizedBox(width: AppSpacing.sm), - Text( - ob.permissionUsageTitle, - style: AppTypography.headline.copyWith( - color: ext.textPrimary, - ), - ), - ], + ), ), - const SizedBox(height: AppSpacing.xs), - Text( - ob.permissionUsageDesc, - style: AppTypography.caption1.copyWith(color: ext.textHint), - ), - const SizedBox(height: AppSpacing.sm), - Container(height: 0.5, color: ext.textHint.withValues(alpha: 0.2)), - const SizedBox(height: AppSpacing.sm), - ...groupedPermissions.entries.expand((entry) { - if (entry.value.isEmpty) return []; - return [ - _buildPermissionGroupHeader(ext, entry.key, ob), - const SizedBox(height: AppSpacing.sm), - ...entry.value.map( - (perm) => _buildPermissionItem(ext, perm, ob), - ), - const SizedBox(height: AppSpacing.sm), - ]; - }), - ], - ), + ), + ], ), ); } @@ -955,7 +1058,9 @@ class _AgreementPageState extends ConsumerState { onPressed: enabled ? () { HapticService.light(); - OnboardingNavScope.of(context).goToPage(2); + // 跳转到协议页的下一页(安卓端是欢迎页,其他端是个性化页) + final nav = OnboardingNavScope.of(context); + nav.goToPage(nav.agreementIndex + 1); } : null, child: Text( diff --git a/lib/features/onboarding/presentation/pages/personalization_page.dart b/lib/features/onboarding/presentation/pages/personalization_page.dart index e06c0a3f..fe5039e7 100644 --- a/lib/features/onboarding/presentation/pages/personalization_page.dart +++ b/lib/features/onboarding/presentation/pages/personalization_page.dart @@ -1,9 +1,9 @@ // ============================================================ -// 闲言APP — 个性化设置页(引导页第3页) +// 闲言APP — 个性化设置页(引导页) // 创建时间: 2026-05-21 -// 更新时间: 2026-06-05 +// 更新时间: 2026-06-09 // 作用: 主题/强调色/功能开关设置,实时预览 -// 上次更新: 完成按钮loading状态增加动态文字提示 +// 上次更新: 导航改用OnboardingNavScope语义索引适配安卓端页面顺序 // ============================================================ import 'dart:async'; @@ -16,6 +16,7 @@ import '../../../../l10n/translations.dart'; import '../../../../core/router/app_nav_extension.dart'; import '../../../../core/router/app_routes.dart'; import '../../../../core/services/device/haptic_service.dart'; +import '../../../../core/services/auth/permission_service.dart'; import '../../../../core/utils/platform/platform_utils.dart' as pu; import '../../../../core/theme/app_radius.dart'; import '../../../../core/theme/app_spacing.dart'; @@ -43,12 +44,7 @@ class _PersonalizationPageState extends ConsumerState { /// loading状态动态文字提示 int _loadingTextIndex = 0; Timer? _loadingTextTimer; - static const _loadingTexts = [ - '正在准备...', - '正在加载...', - '正在初始化...', - '即将完成...', - ]; + static const _loadingTexts = ['正在准备...', '正在加载...', '正在初始化...', '即将完成...']; double _scaleForFontSizeId(String id) { return switch (id) { @@ -107,7 +103,9 @@ class _PersonalizationPageState extends ConsumerState { previousLabel: ob.agreementNavLabel, onPrevious: () { HapticService.light(); - OnboardingNavScope.of(context).goToPage(1); + final nav = OnboardingNavScope.of(context); + // 返回个性化页的上一页(安卓端是欢迎页,其他端是协议页) + nav.goToPage(nav.personalizationIndex - 1); }, ), SizedBox( @@ -211,7 +209,11 @@ class _PersonalizationPageState extends ConsumerState { ); } - Widget _buildPreviewCard(AppThemeExtension ext, double fontSizeScale, TOnboarding ob) { + Widget _buildPreviewCard( + AppThemeExtension ext, + double fontSizeScale, + TOnboarding ob, + ) { final theme = ref.watch(themeSettingsProvider); final cardStyle = theme.cardStyleId; final fontStyle = theme.fontStyle; @@ -307,9 +309,17 @@ class _PersonalizationPageState extends ConsumerState { children: [ _buildPreviewChip(ext, '👍 12', fontStyle.fontFamily), const SizedBox(width: AppSpacing.sm), - _buildPreviewChip(ext, '⭐ ${ob.collectAction}', fontStyle.fontFamily), + _buildPreviewChip( + ext, + '⭐ ${ob.collectAction}', + fontStyle.fontFamily, + ), const SizedBox(width: AppSpacing.sm), - _buildPreviewChip(ext, '📤 ${ob.shareAction}', fontStyle.fontFamily), + _buildPreviewChip( + ext, + '📤 ${ob.shareAction}', + fontStyle.fontFamily, + ), ], ), ], @@ -413,6 +423,8 @@ class _PersonalizationPageState extends ConsumerState { ref .read(generalSettingsProvider.notifier) .setShakeToSwitch(newVal); + // 同步设置摇一摇权限 + PermissionService.setShakeEnabled(newVal); }, ), const SizedBox(height: AppSpacing.md), @@ -514,7 +526,11 @@ class _PersonalizationPageState extends ConsumerState { ); } - Widget _buildAccentColorRow(AppThemeExtension ext, ThemeSettingsState theme, TOnboarding ob) { + Widget _buildAccentColorRow( + AppThemeExtension ext, + ThemeSettingsState theme, + TOnboarding ob, + ) { final displayOptions = accentColorOptions .where((o) => o.id != 'custom') .take(6) @@ -568,7 +584,11 @@ class _PersonalizationPageState extends ConsumerState { ); } - Widget _buildCardStyleRow(AppThemeExtension ext, ThemeSettingsState theme, TOnboarding ob) { + Widget _buildCardStyleRow( + AppThemeExtension ext, + ThemeSettingsState theme, + TOnboarding ob, + ) { final displayStyles = cardStyleOptions.take(3).toList(); return Row( @@ -630,7 +650,11 @@ class _PersonalizationPageState extends ConsumerState { ); } - Widget _buildFontStyleRow(AppThemeExtension ext, ThemeSettingsState theme, TOnboarding ob) { + Widget _buildFontStyleRow( + AppThemeExtension ext, + ThemeSettingsState theme, + TOnboarding ob, + ) { final displayFonts = fontStyleOptions.take(3).toList(); return Row( @@ -732,17 +756,16 @@ class _PersonalizationPageState extends ConsumerState { // 启动loading文字定时器 _loadingTextIndex = 0; _loadingTextTimer?.cancel(); - _loadingTextTimer = Timer.periodic( - const Duration(seconds: 2), - (_) { - if (mounted) { - setState(() { - _loadingTextIndex = - (_loadingTextIndex + 1) % _loadingTexts.length; - }); - } - }, - ); + _loadingTextTimer = Timer.periodic(const Duration(seconds: 2), ( + _, + ) { + if (mounted) { + setState(() { + _loadingTextIndex = + (_loadingTextIndex + 1) % _loadingTexts.length; + }); + } + }); await Future.delayed(const Duration(milliseconds: 800)); await ref .read(onboardingProvider.notifier) diff --git a/lib/features/onboarding/presentation/pages/welcome_page.dart b/lib/features/onboarding/presentation/pages/welcome_page.dart index a76f9817..6728d24c 100644 --- a/lib/features/onboarding/presentation/pages/welcome_page.dart +++ b/lib/features/onboarding/presentation/pages/welcome_page.dart @@ -1,9 +1,9 @@ // ============================================================ -// 闲言APP — 欢迎与指引页(引导页第1页) +// 闲言APP — 欢迎与指引页(引导页) // 创建时间: 2026-05-21 -// 更新时间: 2026-05-31 +// 更新时间: 2026-06-09 // 作用: 展示核心功能、语言选择、权限入口、数据收集信息入口 -// 上次更新: _buildLocaleChips改为前4个语言+展开/收起;语言chip显示国旗emoji +// 上次更新: 导航改用OnboardingNavScope语义索引适配安卓端页面顺序 // ============================================================ import 'package:flutter/cupertino.dart'; @@ -973,7 +973,9 @@ class _WelcomePageState extends ConsumerState { padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), onPressed: () { HapticService.light(); - OnboardingNavScope.of(context).goToPage(1); + final nav = OnboardingNavScope.of(context); + // 跳转到欢迎页的下一页(安卓端是个性化页,其他端是协议页) + nav.goToPage(nav.welcomeIndex + 1); }, child: Text( ob.startButton, diff --git a/lib/features/onboarding/providers/onboarding_provider.dart b/lib/features/onboarding/providers/onboarding_provider.dart index 89f9d6ff..9ac7b7ba 100644 --- a/lib/features/onboarding/providers/onboarding_provider.dart +++ b/lib/features/onboarding/providers/onboarding_provider.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 引导页状态管理 // 创建时间: 2026-05-21 -// 更新时间: 2026-05-30 +// 更新时间: 2026-06-09 // 作用: 管理引导页流程状态(页面切换、协议勾选、个性化设置) -// 上次更新: completeOnboarding中调用PostAgreementInitializer初始化权限敏感服务 +// 上次更新: 添加totalPages动态页数支持,适配安卓端页面顺序调整 // ============================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -13,11 +13,11 @@ import '../../../core/storage/hive_safe_access.dart'; import '../../../core/storage/kv_storage.dart'; import '../../../core/utils/logger.dart'; import '../../mine/settings/providers/default_settings.dart'; -import '../data/onboarding_constants.dart'; class OnboardingState { const OnboardingState({ this.currentPage = 0, + this.totalPages = 3, this.selectedLocale = 'zh', this.privacyAgreed = false, this.termsAgreed = false, @@ -31,6 +31,7 @@ class OnboardingState { }); final int currentPage; + final int totalPages; final String selectedLocale; final bool privacyAgreed; final bool termsAgreed; @@ -45,10 +46,11 @@ class OnboardingState { bool get canProceedAgreement => privacyAgreed && termsAgreed && permissionRead; - bool get isLastPage => currentPage >= OnboardingConstants.totalPages - 1; + bool get isLastPage => currentPage >= totalPages - 1; OnboardingState copyWith({ int? currentPage, + int? totalPages, String? selectedLocale, bool? privacyAgreed, bool? termsAgreed, @@ -62,6 +64,7 @@ class OnboardingState { }) { return OnboardingState( currentPage: currentPage ?? this.currentPage, + totalPages: totalPages ?? this.totalPages, selectedLocale: selectedLocale ?? this.selectedLocale, privacyAgreed: privacyAgreed ?? this.privacyAgreed, termsAgreed: termsAgreed ?? this.termsAgreed, @@ -81,7 +84,7 @@ class OnboardingNotifier extends Notifier { OnboardingState build() => const OnboardingState(); void nextPage() { - if (state.currentPage < OnboardingConstants.totalPages - 1) { + if (state.currentPage < state.totalPages - 1) { state = state.copyWith(currentPage: state.currentPage + 1); Log.i('Onboarding: nextPage → ${state.currentPage}'); } @@ -95,12 +98,18 @@ class OnboardingNotifier extends Notifier { } void setPage(int page) { - if (page >= 0 && page < OnboardingConstants.totalPages) { + if (page >= 0 && page < state.totalPages) { state = state.copyWith(currentPage: page); Log.i('Onboarding: setPage → $page'); } } + /// 设置引导页总页数(根据平台和skipAgreement动态调整) + void setTotalPages(int pages) { + state = state.copyWith(totalPages: pages); + Log.i('Onboarding: setTotalPages → $pages'); + } + void togglePrivacy() { state = state.copyWith(privacyAgreed: !state.privacyAgreed); } @@ -147,7 +156,10 @@ class OnboardingNotifier extends Notifier { await KvStorage.setString('locale', state.selectedLocale); await KvStorage.setBool('general_shake_to_switch', state.shakeEnabled); await KvStorage.setBool('sfx_enabled', state.soundEnabled); - await KvStorage.setBool('general_shader_background', state.shaderBackground); + await KvStorage.setBool( + 'general_shader_background', + state.shaderBackground, + ); // 立即将关键数据刷入磁盘,防止App被杀后数据丢失导致重复显示引导页 try { diff --git a/lib/l10n/languages/ar.dart b/lib/l10n/languages/ar.dart index da8fd163..f3be25f7 100644 --- a/lib/l10n/languages/ar.dart +++ b/lib/l10n/languages/ar.dart @@ -1183,10 +1183,12 @@ const ar = T( companyDesc: 'متخصصون في تطوير تطبيقات الهاتف', contactEmail: 'البريد الإلكتروني', viewEmails: 'عرض عناوين البريد', - emailPrimary: 'الرئيسي', - emailSupport: '🤝 الدعم', - emailAny: '📬 عام', - emailDeveloper: 'مطور', + emailAnyContact: 'أي بريد إلكتروني مناسب للتواصل', + emailReplyDays: 'عادةً الرد خلال 1-3 أيام', + emailTimeoutTip: 'إذا لم يكن هناك رد، جرب بريداً آخر', + emailGroupChatTip: 'انضم للمحادثة الجماعية للحصول على رد أسرع', + emailSubjectTip: 'يرجى كتابة «مشكلة تطبيق شيان يان» في الموضوع', + emailCorrectionTip: 'يمكنك أيضاً الإبلاغ عبر صفحة التصحيح', wechatAccount: 'حساب وي تشات الرسمي', teamInfo: 'الفريق', roleDesign: 'تطوير وتصميم', diff --git a/lib/l10n/languages/bn.dart b/lib/l10n/languages/bn.dart index 764ebd18..e12717e0 100644 --- a/lib/l10n/languages/bn.dart +++ b/lib/l10n/languages/bn.dart @@ -1191,10 +1191,12 @@ const bn = T( companyDesc: 'মোবাইল অ্যাপ ডেভেলপমেন্টে বিশেষজ্ঞ', contactEmail: 'যোগাযোগ ইমেইল', viewEmails: 'ইমেইল ঠিকানা দেখুন', - emailPrimary: 'প্রধান', - emailSupport: '🤝 সাপোর্ট', - emailAny: '📬 সাধারণ', - emailDeveloper: 'ডেভেলপার', + emailAnyContact: 'যেকোনো ইমেইল যোগাযোগের জন্য উপযুক্ত', + emailReplyDays: 'সাধারণত 1-3 দিনে উত্তর দেওয়া হয়', + emailTimeoutTip: 'উত্তর না পেলে অন্য ইমেইলে চেষ্টা করুন', + emailGroupChatTip: 'দ্রুত উত্তরের জন্য গ্রুপ চ্যাটে যোগ দিন', + emailSubjectTip: 'বিষয়ে «闲言app সমস্যা» উল্লেখ করুন', + emailCorrectionTip: 'সংশোধন পৃষ্ঠাতেও ফিডব্যাক দিতে পারেন', wechatAccount: 'উইচ্যাট অফিসিয়াল অ্যাকাউন্ট', teamInfo: 'দল', roleDesign: 'ডেভ ও ডিজাইন', diff --git a/lib/l10n/languages/de.dart b/lib/l10n/languages/de.dart index 898165e5..23cf0e04 100644 --- a/lib/l10n/languages/de.dart +++ b/lib/l10n/languages/de.dart @@ -1188,10 +1188,12 @@ const de = T( companyDesc: 'Fokus auf qualitative mobile App-Entwicklung', contactEmail: 'Kontakt-E-Mail', viewEmails: 'Tippen für E-Mail-Liste', - emailPrimary: 'Primär', - emailSupport: 'Support', - emailAny: 'Allgemein', - emailDeveloper: 'Entwickler', + emailAnyContact: '📬 Kontakt über jede E-Mail-Adresse möglich', + emailReplyDays: '⏱ Antwort in der Regel innerhalb von 1–3 Tagen', + emailTimeoutTip: '⏳ Keine Antwort? Andere E-Mail-Adresse versuchen', + emailGroupChatTip: '💬 Gruppenchat für schnellere Antworten beitreten', + emailSubjectTip: '📝 Betreff: bitte „闲言 App – Frage" angeben', + emailCorrectionTip: '✏️ Alternativ über die Korrekturseite melden', wechatAccount: 'WeChat Official', teamInfo: 'Team', roleDesign: 'Entwickler', diff --git a/lib/l10n/languages/en.dart b/lib/l10n/languages/en.dart index e922ad09..2d1308ff 100644 --- a/lib/l10n/languages/en.dart +++ b/lib/l10n/languages/en.dart @@ -1200,10 +1200,12 @@ const en = T( companyDesc: 'Focused on quality mobile app development', contactEmail: 'Contact Email', viewEmails: 'Tap to view email list', - emailPrimary: 'Primary', - emailSupport: 'Support', - emailAny: 'General', - emailDeveloper: 'Developer', + emailAnyContact: 'Any email can be used to contact us', + emailReplyDays: 'Usually replied within 1-3 days', + emailTimeoutTip: 'If no reply, try another email', + emailGroupChatTip: 'Join group chat for faster response', + emailSubjectTip: 'Please note "Xianyan app issue" in subject', + emailCorrectionTip: 'You can also feedback on the correction page', wechatAccount: 'WeChat Official', teamInfo: 'Team', roleDesign: 'Developer', diff --git a/lib/l10n/languages/es.dart b/lib/l10n/languages/es.dart index a4d79a87..1f019769 100644 --- a/lib/l10n/languages/es.dart +++ b/lib/l10n/languages/es.dart @@ -1197,10 +1197,12 @@ const es = T( companyDesc: 'Especializados en desarrollo de apps móviles', contactEmail: 'Correo de contacto', viewEmails: 'Ver direcciones de correo', - emailPrimary: 'Principal', - emailSupport: '🤝 Soporte', - emailAny: '📬 General', - emailDeveloper: 'Desarrollador', + emailAnyContact: '📬 Puedes contactarnos por cualquier correo', + emailReplyDays: '⏱ Respondemos en 1 a 3 días habitualmente', + emailTimeoutTip: '⏳ Sin respuesta? Prueba con otra dirección', + emailGroupChatTip: '💬 Únete al chat grupal para una respuesta más rápida', + emailSubjectTip: '📝 Asunto: indica « App 闲言 – Pregunta »', + emailCorrectionTip: '✏️ También puedes informar desde la página de correcciones', wechatAccount: 'Cuenta oficial de WeChat', teamInfo: 'Equipo', roleDesign: 'Dev y Diseño', diff --git a/lib/l10n/languages/fr.dart b/lib/l10n/languages/fr.dart index c31ca9c8..ad08d89b 100644 --- a/lib/l10n/languages/fr.dart +++ b/lib/l10n/languages/fr.dart @@ -1203,10 +1203,12 @@ const fr = T( companyDesc: 'Spécialisés dans le développement d\'apps mobiles', contactEmail: 'E-mail de contact', viewEmails: 'Voir les adresses e-mail', - emailPrimary: 'Principal', - emailSupport: '🤝 Support', - emailAny: '📬 Général', - emailDeveloper: 'Développeur', + emailAnyContact: '📬 Contactez-nous via n\'importe quel e-mail', + emailReplyDays: '⏱ Réponse habituelle sous 1 à 3 jours', + emailTimeoutTip: '⏳ Sans réponse ? Essayez une autre adresse', + emailGroupChatTip: '💬 Rejoignez le chat de groupe pour une réponse plus rapide', + emailSubjectTip: '📝 Objet : précisez « App 闲言 – Question »', + emailCorrectionTip: '✏️ Vous pouvez aussi signaler via la page de corrections', wechatAccount: 'Compte officiel WeChat', teamInfo: 'Équipe', roleDesign: 'Dev et Design', diff --git a/lib/l10n/languages/hi.dart b/lib/l10n/languages/hi.dart index 35f79e95..a8f357d0 100644 --- a/lib/l10n/languages/hi.dart +++ b/lib/l10n/languages/hi.dart @@ -1172,10 +1172,12 @@ const hi = T( companyDesc: 'मोबाइल ऐप डेवलपमेंट में विशेषज्ञ', contactEmail: 'संपर्क ईमेल', viewEmails: 'ईमेल पते देखें', - emailPrimary: 'प्राथमिक', - emailSupport: 'सहायता', - emailAny: 'सामान्य', - emailDeveloper: 'डेवलपर', + emailAnyContact: 'कोई भी ईमेल संपर्क के लिए उपयुक्त है', + emailReplyDays: 'सामान्यतः 1-3 दिन में उत्तर', + emailTimeoutTip: 'यदि उत्तर नहीं मिला तो अन्य ईमेल आज़माएं', + emailGroupChatTip: 'तेज़ उत्तर के लिए ग्रुप चैट से जुड़ें', + emailSubjectTip: 'विषय में «闲言app समस्या» लिखें', + emailCorrectionTip: 'सुधार पृष्ठ से भी फीडबैक दे सकते हैं', wechatAccount: 'WeChat आधिकारिक खाता', teamInfo: 'टीम', roleDesign: 'डेव और डिज़ाइन', diff --git a/lib/l10n/languages/it.dart b/lib/l10n/languages/it.dart index f68a0ae6..9f62027a 100644 --- a/lib/l10n/languages/it.dart +++ b/lib/l10n/languages/it.dart @@ -1198,10 +1198,12 @@ const it = T( companyDesc: 'Focalizzati sullo sviluppo di app mobili di qualità', contactEmail: 'Email di contatto', viewEmails: 'Tocca per vedere la lista email', - emailPrimary: 'Principale', - emailSupport: 'Supporto', - emailAny: 'Generale', - emailDeveloper: 'Sviluppatore', + emailAnyContact: '📬 Puoi contattarci tramite qualsiasi email', + emailReplyDays: '⏱ Risposta abituale entro 1-3 giorni', + emailTimeoutTip: '⏳ Nessuna risposta? Prova un altro indirizzo', + emailGroupChatTip: '💬 Unisciti alla chat di gruppo per risposte più rapide', + emailSubjectTip: '📝 Oggetto: indica « App 闲言 – Domanda »', + emailCorrectionTip: '✏️ Puoi anche segnalare dalla pagina delle correzioni', wechatAccount: 'WeChat Ufficiale', teamInfo: 'Team', roleDesign: 'Sviluppatore', diff --git a/lib/l10n/languages/ja.dart b/lib/l10n/languages/ja.dart index 96259c13..0ebb0a07 100644 --- a/lib/l10n/languages/ja.dart +++ b/lib/l10n/languages/ja.dart @@ -1152,10 +1152,12 @@ const ja = T( companyDesc: 'モバイルアプリ開発に専念', contactEmail: 'お問い合わせメール', viewEmails: 'メールアドレスを表示', - emailPrimary: 'メイン', - emailSupport: 'サポート', - emailAny: '一般', - emailDeveloper: '開発者', + emailAnyContact: 'どのメールでもご連絡いただけます', + emailReplyDays: '通常1〜3日で返信', + emailTimeoutTip: '返信がない場合は別のメールをお試しください', + emailGroupChatTip: 'グループチャットに参加するとより早い返信が得られます', + emailSubjectTip: '件名に「閑言app 問題」とご記載ください', + emailCorrectionTip: '修正ページからもフィードバックできます', wechatAccount: 'WeChat公式アカウント', teamInfo: 'チーム情報', roleDesign: '開発・デザイン', diff --git a/lib/l10n/languages/ko.dart b/lib/l10n/languages/ko.dart index 87f6f1ba..f7ccdd84 100644 --- a/lib/l10n/languages/ko.dart +++ b/lib/l10n/languages/ko.dart @@ -1154,10 +1154,12 @@ const ko = T( companyDesc: '품질 모바일 앱 개발에 전념', contactEmail: '연락처 이메일', viewEmails: '탭하여 이메일 목록 보기', - emailPrimary: '기본', - emailSupport: '지원', - emailAny: '일반', - emailDeveloper: '개발자', + emailAnyContact: '어떤 이메일로든 연락 가능합니다', + emailReplyDays: '보통 1~3일 내 답장', + emailTimeoutTip: '답장이 없으면 다른 이메일로 문의하세요', + emailGroupChatTip: '그룹 채팅에 참여하면 더 빠른 답변을 받을 수 있습니다', + emailSubjectTip: '제목에 "셴옌app 문의"라고 적어주세요', + emailCorrectionTip: '수정 페이지에서도 피드백할 수 있습니다', wechatAccount: 'WeChat 공식 계정', teamInfo: '팀', roleDesign: '개발자', diff --git a/lib/l10n/languages/pt.dart b/lib/l10n/languages/pt.dart index 82722eea..2fa0ef83 100644 --- a/lib/l10n/languages/pt.dart +++ b/lib/l10n/languages/pt.dart @@ -1192,10 +1192,12 @@ const pt = T( companyDesc: 'Especializados em desenvolvimento de apps móveis', contactEmail: 'E-mail de Contato', viewEmails: 'Ver endereços de e-mail', - emailPrimary: 'Principal', - emailSupport: 'Suporte', - emailAny: 'Geral', - emailDeveloper: 'Desenvolvedor', + emailAnyContact: '📬 Pode nos contatar por qualquer e-mail', + emailReplyDays: '⏱ Respondemos em 1 a 3 dias normalmente', + emailTimeoutTip: '⏳ Sem resposta? Tente outro endereço de e-mail', + emailGroupChatTip: '💬 Participe do chat em grupo para respostas mais rápidas', + emailSubjectTip: '📝 Assunto: indique « App 闲言 – Dúvida »', + emailCorrectionTip: '✏️ Também pode informar pela página de correções', wechatAccount: 'Conta Oficial do WeChat', teamInfo: 'Equipe', roleDesign: 'Dev e Design', diff --git a/lib/l10n/languages/ru.dart b/lib/l10n/languages/ru.dart index 62571815..b6d25aee 100644 --- a/lib/l10n/languages/ru.dart +++ b/lib/l10n/languages/ru.dart @@ -1191,10 +1191,12 @@ const ru = T( companyDesc: 'Специализация на разработке мобильных приложений', contactEmail: 'Контактная почта', viewEmails: 'Просмотр адресов почты', - emailPrimary: 'Основная', - emailSupport: 'Поддержка', - emailAny: 'Общая', - emailDeveloper: 'Разработчик', + emailAnyContact: 'Любой email подходит для связи', + emailReplyDays: 'Обычно отвечаем за 1-3 дня', + emailTimeoutTip: 'Если нет ответа, попробуйте другой email', + emailGroupChatTip: 'Присоединяйтесь к групповому чату для быстрого ответа', + emailSubjectTip: 'В теме укажите «Сяньянь app проблема»', + emailCorrectionTip: 'Также можно сообщить через страницу исправлений', wechatAccount: 'Официальный аккаунт WeChat', teamInfo: 'Команда', roleDesign: 'Разработка и дизайн', diff --git a/lib/l10n/languages/zh_cn.dart b/lib/l10n/languages/zh_cn.dart index 438cd565..faa8f757 100644 --- a/lib/l10n/languages/zh_cn.dart +++ b/lib/l10n/languages/zh_cn.dart @@ -1133,10 +1133,12 @@ const zhCN = T( companyDesc: '专注于优质移动应用开发', contactEmail: '联系邮箱', viewEmails: '点击查看邮箱列表', - emailPrimary: '主要联系', - emailSupport: '技术支持', - emailAny: '通用联系', - emailDeveloper: '开发者', + emailAnyContact: '任意邮件均可联系', + emailReplyDays: '正常1-3天回复', + emailTimeoutTip: '超时未回复可更换邮件反馈', + emailGroupChatTip: '可加入群聊获取更快的回复', + emailSubjectTip: '主题请备注 闲言app 问题', + emailCorrectionTip: '也可在纠错页面反馈', wechatAccount: '微信公众号', teamInfo: '团队信息', roleDesign: '程序设计', diff --git a/lib/l10n/languages/zh_tw.dart b/lib/l10n/languages/zh_tw.dart index cc27465b..8fc31e43 100644 --- a/lib/l10n/languages/zh_tw.dart +++ b/lib/l10n/languages/zh_tw.dart @@ -1132,10 +1132,12 @@ const zhTW = T( companyDesc: '專注行動應用開發', contactEmail: '聯絡信箱', viewEmails: '查看信箱地址', - emailPrimary: '主要信箱', - emailSupport: '技術支援', - emailAny: '通用聯絡', - emailDeveloper: '開發者', + emailAnyContact: '任意信箱均可聯繫', + emailReplyDays: '正常1-3天回覆', + emailTimeoutTip: '超時未回覆可更換信箱回饋', + emailGroupChatTip: '可加入群聊獲取更快的回覆', + emailSubjectTip: '主旨請備註 閒言app 問題', + emailCorrectionTip: '也可在糾錯頁面回饋', wechatAccount: '微信公眾號', teamInfo: '團隊資訊', roleDesign: '開發設計', diff --git a/lib/l10n/types/t_about.dart b/lib/l10n/types/t_about.dart index d219634b..5be1fb92 100644 --- a/lib/l10n/types/t_about.dart +++ b/lib/l10n/types/t_about.dart @@ -51,10 +51,12 @@ class TAbout { required this.companyDesc, required this.contactEmail, required this.viewEmails, - required this.emailPrimary, - required this.emailSupport, - required this.emailAny, - required this.emailDeveloper, + required this.emailAnyContact, + required this.emailReplyDays, + required this.emailTimeoutTip, + required this.emailGroupChatTip, + required this.emailSubjectTip, + required this.emailCorrectionTip, required this.wechatAccount, required this.teamInfo, required this.roleDesign, @@ -296,21 +298,27 @@ class TAbout { /// 联系邮箱 final String contactEmail; + /// 任意邮件均可联系 + final String emailAnyContact; + + /// 正常1-3天回复 + final String emailReplyDays; + + /// 超时未回复可更换邮件反馈 + final String emailTimeoutTip; + + /// 可加入群聊获取更快的回复 + final String emailGroupChatTip; + + /// 主题请备注 闲言app 问题 + final String emailSubjectTip; + + /// 也可在纠错页面反馈 + final String emailCorrectionTip; + /// 查看邮箱 final String viewEmails; - /// 主邮箱 - final String emailPrimary; - - /// 支持邮箱 - final String emailSupport; - - /// 任意邮箱 - final String emailAny; - - /// 开发者邮箱 - final String emailDeveloper; - // ===== 微信公众号 ===== /// 微信公众号 @@ -636,10 +644,12 @@ class TAbout { 'companyDesc': companyDesc, 'contactEmail': contactEmail, 'viewEmails': viewEmails, - 'emailPrimary': emailPrimary, - 'emailSupport': emailSupport, - 'emailAny': emailAny, - 'emailDeveloper': emailDeveloper, + 'emailAnyContact': emailAnyContact, + 'emailReplyDays': emailReplyDays, + 'emailTimeoutTip': emailTimeoutTip, + 'emailGroupChatTip': emailGroupChatTip, + 'emailSubjectTip': emailSubjectTip, + 'emailCorrectionTip': emailCorrectionTip, 'wechatAccount': wechatAccount, 'teamInfo': teamInfo, 'roleDesign': roleDesign, @@ -861,18 +871,24 @@ class TAbout { viewEmails: map['viewEmails']?.isNotEmpty == true ? map['viewEmails']! : (fallback?.viewEmails ?? ''), - emailPrimary: map['emailPrimary']?.isNotEmpty == true - ? map['emailPrimary']! - : (fallback?.emailPrimary ?? ''), - emailSupport: map['emailSupport']?.isNotEmpty == true - ? map['emailSupport']! - : (fallback?.emailSupport ?? ''), - emailAny: map['emailAny']?.isNotEmpty == true - ? map['emailAny']! - : (fallback?.emailAny ?? ''), - emailDeveloper: map['emailDeveloper']?.isNotEmpty == true - ? map['emailDeveloper']! - : (fallback?.emailDeveloper ?? ''), + emailAnyContact: map['emailAnyContact']?.isNotEmpty == true + ? map['emailAnyContact']! + : (fallback?.emailAnyContact ?? ''), + emailReplyDays: map['emailReplyDays']?.isNotEmpty == true + ? map['emailReplyDays']! + : (fallback?.emailReplyDays ?? ''), + emailTimeoutTip: map['emailTimeoutTip']?.isNotEmpty == true + ? map['emailTimeoutTip']! + : (fallback?.emailTimeoutTip ?? ''), + emailGroupChatTip: map['emailGroupChatTip']?.isNotEmpty == true + ? map['emailGroupChatTip']! + : (fallback?.emailGroupChatTip ?? ''), + emailSubjectTip: map['emailSubjectTip']?.isNotEmpty == true + ? map['emailSubjectTip']! + : (fallback?.emailSubjectTip ?? ''), + emailCorrectionTip: map['emailCorrectionTip']?.isNotEmpty == true + ? map['emailCorrectionTip']! + : (fallback?.emailCorrectionTip ?? ''), wechatAccount: map['wechatAccount']?.isNotEmpty == true ? map['wechatAccount']! : (fallback?.wechatAccount ?? ''), diff --git a/lib/main.dart b/lib/main.dart index 192feb78..64b414fd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,6 +19,7 @@ import 'package:window_manager/window_manager.dart'; import 'app/app.dart'; import 'core/constants/app_constants.dart'; import 'core/services/network/deep_link_service.dart'; +import 'core/router/deep_link_resolver.dart'; import 'core/services/performance/performance_orchestrator.dart'; import 'core/services/error/crash_monitor.dart'; import 'core/services/device/haptic_service.dart'; @@ -182,6 +183,19 @@ Future _appMain() async { } if (!pu.isWeb) { + // Debug 模式下验证深度链接配置完整性 + assert(() { + final errors = DeepLinkResolver.validate(); + if (errors.isNotEmpty) { + for (final e in errors) { + Log.e('🔗 [DeepLink] 配置错误: $e', null, null, LogCategory.router); + } + } else { + Log.i('🔗 [DeepLink] 配置验证通过', null, null, LogCategory.router); + } + return true; + }()); + try { await DeepLinkService.init(); if (pu.isOhos) Log.i('🟢 [OHOS] 深度链接服务初始化完成', null, null, LogCategory.router); diff --git a/lib/shared/widgets/feedback/contact_email_sheet.dart b/lib/shared/widgets/feedback/contact_email_sheet.dart index 57647965..e8110b5c 100644 --- a/lib/shared/widgets/feedback/contact_email_sheet.dart +++ b/lib/shared/widgets/feedback/contact_email_sheet.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 联系邮箱底部面板(共享组件) /// 创建时间: 2026-06-08 -/// 更新时间: 2026-06-08 +/// 更新时间: 2026-06-09 /// 作用: 统一联系邮箱底部弹窗,供关于页、了解我们页、注册页、字体管理页等复用 -/// 上次更新: 初始创建,从 learn_us_widgets.dart 和 about_page.dart 抽取 +/// 上次更新: 标题下方增加分类标签副标题,去掉卡片副标题和底部提示 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -67,32 +67,32 @@ class ContactEmailSheet { return [ ContactEmailData( address: 'ad@avefs.com', - label: t.about.emailPrimary, + label: t.about.emailAnyContact, icon: '🌐', ), ContactEmailData( - address: 'ad@0gg.com', - label: t.about.emailSupport, + address: 'gg@0gg.cc', + label: t.about.emailReplyDays, icon: '🤝', ), ContactEmailData( address: '2821981550@qq.com', - label: t.about.emailAny, + label: t.about.emailTimeoutTip, icon: '📬', ), ContactEmailData( address: '2572560133@qq.com', - label: t.about.emailAny, + label: t.about.emailTimeoutTip, icon: '📬', ), ContactEmailData( address: 'lzy20010304@Gmail.COM', - label: t.about.emailDeveloper, + label: t.about.emailGroupChatTip, icon: '💻', ), ContactEmailData( address: '23chenjiale@gmail.com', - label: t.about.emailDeveloper, + label: t.about.emailGroupChatTip, icon: '💻', ), ]; @@ -175,7 +175,23 @@ class _EmailSheetContent extends StatelessWidget { ], ), ), - const SizedBox(height: AppSpacing.sm), + // 分类标签副标题 + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.xs, + children: [ + _buildCategoryTag('⚖️ 侵权投诉', ext), + _buildCategoryTag('💡 意见反馈', ext), + _buildCategoryTag('🐛 软件问题', ext), + _buildCategoryTag('🔧 技术支持', ext), + ], + ), + ), // 邮箱列表 Flexible( @@ -190,11 +206,82 @@ class _EmailSheetContent extends StatelessWidget { _EmailCard(email: emails[index], ext: ext), ), ), - const SizedBox(height: AppSpacing.xl), + // 底部提示 + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + CupertinoIcons.info_circle, + size: 14, + color: ext.textHint, + ), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Text( + t.about.emailSubjectTip, + style: AppTypography.caption1.copyWith( + color: ext.textHint, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + CupertinoIcons.doc_text_search, + size: 14, + color: ext.textHint, + ), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Text( + t.about.emailCorrectionTip, + style: AppTypography.caption1.copyWith( + color: ext.textHint, + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: AppSpacing.md), ], ), ); } + + /// 构建分类标签 + static Widget _buildCategoryTag(String text, AppThemeExtension ext) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 4, + ), + decoration: BoxDecoration( + color: ext.accent.withValues(alpha: 0.06), + borderRadius: AppRadius.smBorder, + border: Border.all(color: ext.accent.withValues(alpha: 0.1)), + ), + child: Text( + text, + style: AppTypography.caption1.copyWith( + color: ext.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ); + } } // ============================================================ @@ -250,9 +337,7 @@ class _EmailCard extends StatelessWidget { const SizedBox(height: 2), Text( email.label, - style: AppTypography.caption2.copyWith( - color: ext.textHint, - ), + style: AppTypography.caption2.copyWith(color: ext.textHint), ), ], ), diff --git a/lib/shared/widgets/feedback/login_guard_widget.dart b/lib/shared/widgets/feedback/login_guard_widget.dart index 2625fac4..27942241 100644 --- a/lib/shared/widgets/feedback/login_guard_widget.dart +++ b/lib/shared/widgets/feedback/login_guard_widget.dart @@ -93,7 +93,6 @@ class LoginGuardWidget extends ConsumerWidget { child: GlassContainer( child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, children: [ Icon(icon, size: 48, color: ext.textHint), const SizedBox(height: AppSpacing.md), diff --git a/lib/shared/widgets/input/app_slidable.dart b/lib/shared/widgets/input/app_slidable.dart index 042521c4..c74966be 100644 --- a/lib/shared/widgets/input/app_slidable.dart +++ b/lib/shared/widgets/input/app_slidable.dart @@ -20,6 +20,9 @@ enum SlideActionType { archive, pin, bookmark, + read, + folder, + tag, custom, } @@ -56,6 +59,12 @@ class SlideActionConfig { return '置顶'; case SlideActionType.bookmark: return '稍后读'; + case SlideActionType.read: + return '已读'; + case SlideActionType.folder: + return '文件夹'; + case SlideActionType.tag: + return '标签'; case SlideActionType.custom: return label ?? ''; } @@ -77,6 +86,12 @@ class SlideActionConfig { return CupertinoIcons.pin; case SlideActionType.bookmark: return CupertinoIcons.bookmark; + case SlideActionType.read: + return CupertinoIcons.checkmark_circle; + case SlideActionType.folder: + return CupertinoIcons.folder; + case SlideActionType.tag: + return CupertinoIcons.tag; case SlideActionType.custom: return icon ?? CupertinoIcons.ellipsis; } @@ -98,6 +113,12 @@ class SlideActionConfig { return ext.accentLight; case SlideActionType.bookmark: return CupertinoColors.activeBlue; + case SlideActionType.read: + return CupertinoColors.activeGreen; + case SlideActionType.folder: + return CupertinoColors.activeOrange; + case SlideActionType.tag: + return CupertinoColors.systemPurple; case SlideActionType.custom: return backgroundColor ?? ext.accent; } diff --git a/scripts/account_insights_full_test.py b/scripts/account_insights_full_test.py deleted file mode 100644 index 6c63e513..00000000 --- a/scripts/account_insights_full_test.py +++ /dev/null @@ -1,749 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -============================================================ -闲言APP — 账户洞察全流程接口验证脚本 -创建时间: 2026-05-21 -更新时间: 2026-05-21 -作用: 根据API文档全流程验证接口,列出可洞察问题, - 分析本地计算是否有误,最后使用注销接口申请注销用户 -运行: python Scripts/account_insights_full_test.py -============================================================ -""" - -import hashlib -import hmac -import json -import base64 -import os -import time -import sys - -try: - import requests -except ImportError: - print("❌ 缺少 requests 库,请安装: pip install requests") - sys.exit(1) - -BASE = "https://tools.wktyl.com" -SECRET = "Xy7kP9mL2qR4wS8v" -TEST_ACCOUNT = "apitest_user" -TEST_PASSWORD = "123456" -TEST_EMAIL = "test@example.com" - -pass_count = 0 -fail_count = 0 -token = None -user_id = None -user_data = None - - -def result(name, ok, detail=""): - global pass_count, fail_count - icon = "✅" if ok else "❌" - status = "PASS" if ok else "FAIL" - if ok: - pass_count += 1 - else: - fail_count += 1 - msg = f"{icon} [{status}] {name}" - if detail: - msg += f" — {detail}" - print(msg) - - -def make_receipt(action, payload_str): - data = { - "action": action, - "payload": hashlib.sha256(payload_str.encode()).hexdigest()[:16], - "ts": int(time.time()), - "nonce": os.urandom(4).hex(), - } - receipt = base64.b64encode(json.dumps(data, ensure_ascii=False).encode()).decode() - sig = hmac.new(SECRET.encode(), receipt.encode(), hashlib.sha256).hexdigest() - return {"receipt": receipt, "sig": sig} - - -def api_headers(): - h = {"Content-Type": "application/x-www-form-urlencoded"} - if token: - h["token"] = token - return h - - -# ============================================================ -# 测试0: 注册测试账号(如不存在) -# ============================================================ -def test_register(): - global token, user_id - print("\n📝 === 测试0: 注册测试账号 ===") - rcpt = make_receipt("register", TEST_EMAIL) - try: - r = requests.post(f"{BASE}/api/user_security/register", data={ - "username": TEST_ACCOUNT, - "password": TEST_PASSWORD, - "email": TEST_EMAIL, - "receipt": rcpt["receipt"], - "sig": rcpt["sig"], - }, timeout=15) - resp = r.json() - code = resp.get("code") - if code == 1: - data = resp.get("data", {}) - userinfo = data.get("userinfo", {}) - user_id = userinfo.get("id") - result("注册测试账号", True, f"uid={user_id}") - else: - msg = resp.get("msg", "") - if "已被注册" in msg or "已存在" in msg: - result("注册测试账号-已存在", True, f"msg={msg} → 尝试登录") - else: - result("注册测试账号", False, f"code={code}, msg={msg}") - except Exception as e: - result("注册测试账号", False, str(e)) - - -# ============================================================ -# 测试1: 登录 -# ============================================================ -def test_login(): - global token, user_id - print("\n🔑 === 测试1: 账号密码登录 ===") - try: - r = requests.post(f"{BASE}/api/user_security/login", data={ - "account": TEST_ACCOUNT, - "password": TEST_PASSWORD, - "device_name": "测试脚本", - "device_model": "Python Script", - "platform": "web", - "app_name": "闲言工具箱验证脚本", - }, timeout=15) - resp = r.json() - code = resp.get("code") - if code == 1: - data = resp.get("data", {}) - userinfo = data.get("userinfo", {}) - token_val = userinfo.get("token") or resp.headers.get("__token__") - user_id = userinfo.get("id") - if token_val: - token = token_val - result("登录获取Token", True, f"uid={user_id}, token={token[:20]}...") - else: - result("登录获取Token", False, "Token为空,尝试从Header获取") - token_val = r.headers.get("__token__") - if token_val: - token = token_val - result("从Header获取Token", True, f"token={token[:20]}...") - else: - result("获取Token", False, "所有方式均无法获取Token") - else: - result("登录获取Token", False, f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("登录获取Token", False, str(e)) - - -# ============================================================ -# 测试2: 获取用户信息 -# ============================================================ -def test_user_info(): - global user_data - print("\n👤 === 测试2: 获取用户信息 (洞察数据源) ===") - if not token: - result("获取用户信息", False, "无Token,跳过") - return - try: - r = requests.get(f"{BASE}/api/user_center/index", headers=api_headers(), timeout=15) - resp = r.json() - code = resp.get("code") - if code == 1: - data = resp.get("data", {}) - user_data = data - result("用户信息-基本字段", data.get("id") is not None, - f"id={data.get('id')}, username={data.get('username')}, nickname={data.get('nickname')}") - - extra = data.get("extra", {}) - sec_q = extra.get("sec_question") - result("用户信息-密保问题", sec_q is not None, - f"sec_question={sec_q}") - - verification = extra.get("verification", {}) - result("用户信息-验证状态", verification is not None, - f"email={verification.get('email')}, mobile={verification.get('mobile')}") - - devices = data.get("devices", []) - result("用户信息-设备列表", devices is not None, - f"设备数量: {len(devices) if devices else 0}") - - vip = data.get("vip", {}) - result("用户信息-VIP信息", vip is not None, - f"is_vip={vip.get('is_vip')}") - - cloud = data.get("cloud_space", {}) - result("用户信息-云空间", cloud is not None, - f"used={cloud.get('used_human')}, total={cloud.get('total_human')}") - - is_online = data.get("is_online") - result("用户信息-在线状态", is_online is not None, - f"is_online={is_online}") - - if devices and len(devices) > 0: - d = devices[0] - result("设备信息-IP字段", d.get("ip") is not None, - f"ip={d.get('ip')}, ip_city={d.get('ip_city')}") - result("设备信息-平台字段", d.get("platform") is not None, - f"platform={d.get('platform')}, model={d.get('device_model')}") - else: - result("获取用户信息", False, f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("获取用户信息", False, str(e)) - - -# ============================================================ -# 测试3: 设备列表 -# ============================================================ -def test_device_list(): - print("\n📱 === 测试3: 设备列表查询 ===") - if not token: - result("设备列表查询", False, "无Token,跳过") - return - try: - r = requests.post(f"{BASE}/api/user_center/devices", data={"action": "list"}, - headers=api_headers(), timeout=15) - resp = r.json() - code = resp.get("code") - if code == 1: - data = resp.get("data", {}) - devices = data.get("devices", []) - result("设备列表查询", devices is not None, - f"设备数量: {len(devices) if devices else 0}") - if devices: - for i, d in enumerate(devices): - print(f" 设备{i+1}: {d.get('device_model')} | {d.get('platform')} | " - f"IP:{d.get('ip')} | {d.get('ip_city')} | {d.get('last_active_text')}") - else: - result("设备列表查询", False, f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("设备列表查询", False, str(e)) - - -# ============================================================ -# 测试4: 注册设备 -# ============================================================ -def test_register_device(): - print("\n📲 === 测试4: 注册/更新设备 ===") - if not token: - result("注册设备", False, "无Token,跳过") - return - try: - r = requests.post(f"{BASE}/api/user_center/registerDevice", data={ - "device_name": "验证脚本设备", - "device_model": "Python Test", - "platform": "web", - "app_name": "闲言工具箱", - "device_id": "test-script-device-001", - "ip_city": "测试城市", - "ip_range": "192.168.0.0 - 192.168.255.255", - }, headers=api_headers(), timeout=15) - resp = r.json() - code = resp.get("code") - result("注册设备", code == 1, f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("注册设备", False, str(e)) - - -# ============================================================ -# 测试5: IP归属地查询 -# ============================================================ -def test_ip_query(): - print("\n🌐 === 测试5: IP归属地查询 ===") - try: - r = requests.post(f"{BASE}/api/webapi/ip", timeout=15) - resp = r.json() - code = resp.get("code") - if code == 1: - data = resp.get("data", {}) - result("IP归属地查询-本机", data is not None, - f"ip={data.get('domain')}, city={data.get('city')}, fw={data.get('fw')}") - else: - result("IP归属地查询-本机", False, f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("IP归属地查询-本机", False, str(e)) - - try: - r = requests.post(f"{BASE}/api/webapi/ip", data={"ip": "8.8.8.8"}, timeout=15) - resp = r.json() - code = resp.get("code") - if code == 1: - data = resp.get("data", {}) - result("IP归属地查询-指定IP", data is not None, - f"ip={data.get('domain')}, city={data.get('city')}") - else: - result("IP归属地查询-指定IP", False, f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("IP归属地查询-指定IP", False, str(e)) - - -# ============================================================ -# 测试6: 密保问题列表 -# ============================================================ -def test_sec_questions(): - print("\n🔐 === 测试6: 密保问题列表 ===") - try: - r = requests.get(f"{BASE}/api/user_security/secQuestions", timeout=15) - resp = r.json() - code = resp.get("code") - if code == 1: - data = resp.get("data", {}) - questions = data.get("questions", []) - result("密保问题列表", len(questions) > 0, - f"问题数量: {len(questions)}") - for q in questions: - print(f" {q.get('id')}. {q.get('question')}") - else: - result("密保问题列表", False, f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("密保问题列表", False, str(e)) - - -# ============================================================ -# 测试7: 数据面板 -# ============================================================ -def test_dashboard(): - print("\n📊 === 测试7: 数据面板 ===") - if not token: - result("数据面板", False, "无Token,跳过") - return - try: - r = requests.get(f"{BASE}/api/user_center/dashboard", headers=api_headers(), timeout=15) - resp = r.json() - code = resp.get("code") - if code == 1: - data = resp.get("data", {}) - result("数据面板", data is not None, - f"score={data.get('score')}, signin_days={data.get('signin_days')}, " - f"favorite_count={data.get('favorite_count')}, note_count={data.get('note_count')}") - else: - result("数据面板", False, f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("数据面板", False, str(e)) - - -# ============================================================ -# 测试8: 签到 -# ============================================================ -def test_signin(): - print("\n✅ === 测试8: 每日签到 ===") - if not token: - result("每日签到", False, "无Token,跳过") - return - try: - r = requests.post(f"{BASE}/api/user_center/signin", headers=api_headers(), timeout=15) - resp = r.json() - code = resp.get("code") - result("每日签到", code == 1 or "已签到" in resp.get("msg", ""), - f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("每日签到", False, str(e)) - - -# ============================================================ -# 测试9: 互动操作 -# ============================================================ -def test_interaction(): - print("\n🔄 === 测试9: 互动操作 ===") - if not token: - result("互动操作", False, "无Token,跳过") - return - try: - r = requests.post(f"{BASE}/api/user_center/interaction", data={ - "action": "view", - "target_id": 1, - "target_type": "poetry", - }, headers=api_headers(), timeout=15) - resp = r.json() - code = resp.get("code") - result("互动-浏览记录", code == 1, f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("互动-浏览记录", False, str(e)) - - try: - r = requests.post(f"{BASE}/api/user_center/interaction", data={ - "action": "counts", - }, headers=api_headers(), timeout=15) - resp = r.json() - code = resp.get("code") - result("互动-统计查询", code == 1, f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("互动-统计查询", False, str(e)) - - -# ============================================================ -# 测试10: 收藏操作 -# ============================================================ -def test_favorite(): - print("\n⭐ === 测试10: 收藏操作 ===") - if not token: - result("收藏操作", False, "无Token,跳过") - return - try: - r = requests.post(f"{BASE}/api/user_center/favorite", data={ - "action": "count", - }, headers=api_headers(), timeout=15) - resp = r.json() - code = resp.get("code") - result("收藏统计", code == 1, f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("收藏统计", False, str(e)) - - -# ============================================================ -# 测试11: 修改个人信息 -# ============================================================ -def test_profile(): - print("\n📝 === 测试11: 修改个人信息 ===") - if not token: - result("修改个人信息", False, "无Token,跳过") - return - try: - r = requests.post(f"{BASE}/api/user_center/profile", data={ - "bio": "API验证脚本测试 - " + time.strftime("%H:%M:%S"), - }, headers=api_headers(), timeout=15) - resp = r.json() - code = resp.get("code") - result("修改个人信息", code == 1, f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("修改个人信息", False, str(e)) - - -# ============================================================ -# 测试12: 回执登录 -# ============================================================ -def test_receipt_login(): - print("\n🔑 === 测试12: 回执登录 ===") - try: - rcpt = make_receipt("receipt_login", TEST_ACCOUNT) - r = requests.post(f"{BASE}/api/user_security/receiptLogin", data={ - "account": TEST_ACCOUNT, - "receipt": rcpt["receipt"], - "sig": rcpt["sig"], - "platform": "web", - "device_name": "回执登录测试", - }, timeout=15) - resp = r.json() - code = resp.get("code") - result("回执登录", code == 1, f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("回执登录", False, str(e)) - - -# ============================================================ -# 测试13: Token登录 -# ============================================================ -def test_token_login(): - print("\n🔑 === 测试13: Token登录 ===") - if not token: - result("Token登录", False, "无Token,跳过") - return - try: - r = requests.post(f"{BASE}/api/user_security/tokenLogin", data={ - "token": token, - }, timeout=15) - resp = r.json() - code = resp.get("code") - result("Token登录", code == 1, f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("Token登录", False, str(e)) - - -# ============================================================ -# 测试14: 注销状态查询 -# ============================================================ -def test_deletion_status(): - print("\n🗑️ === 测试14: 注销状态查询 ===") - if not token: - result("注销状态查询", False, "无Token,跳过") - return - try: - r = requests.get(f"{BASE}/api/user_security/deletionStatus", - headers=api_headers(), timeout=15) - resp = r.json() - code = resp.get("code") - if code == 1: - data = resp.get("data", {}) - has_pending = data.get("has_pending", False) - result("注销状态查询", True, f"has_pending={has_pending}") - if has_pending: - print(f" ⚠️ 已有注销申请: status={data.get('status_text')}, " - f"countdown={data.get('countdown')}") - else: - result("注销状态查询", False, f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("注销状态查询", False, str(e)) - - -# ============================================================ -# 测试15: 站点统计 (无需登录) -# ============================================================ -def test_stats(): - print("\n📊 === 测试15: 站点统计接口 ===") - try: - r = requests.get(f"{BASE}/api/webapi/stats_overview", timeout=15) - resp = r.json() - code = resp.get("code") - if code == 1: - data = resp.get("data", {}) - user_total = data.get("user", {}).get("total") - result("站点统计概览", data is not None, - f"总用户={user_total}") - else: - result("站点统计概览", False, f"code={code}") - except Exception as e: - result("站点统计概览", False, str(e)) - - try: - r = requests.get(f"{BASE}/api/statistics/overview", timeout=15) - resp = r.json() - code = resp.get("code") - result("Statistics概览", code == 1, f"code={code}") - except Exception as e: - result("Statistics概览", False, str(e)) - - -# ============================================================ -# 测试16: 运势接口 -# ============================================================ -def test_fortune(): - print("\n🔮 === 测试16: 每日运势接口 ===") - if not user_id: - result("每日运势", False, "无用户ID,跳过") - return - try: - r = requests.get(f"{BASE}/api/fortune/daily", params={ - "uid": str(user_id), - }, timeout=15) - resp = r.json() - code = resp.get("code") - if code == 1: - data = resp.get("data", {}) - result("每日运势", data is not None, - f"level={data.get('fortune_level')}, score={data.get('fortune_score')}") - else: - result("每日运势", False, f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("每日运势", False, str(e)) - - -# ============================================================ -# 测试17: 申请注销用户 -# ============================================================ -def test_request_deletion(): - print("\n🚨 === 测试17: 申请注销用户 ===") - if not token: - result("申请注销", False, "无Token,跳过") - return - if not user_id: - result("申请注销", False, "无用户ID,跳过") - return - - try: - rcpt = make_receipt("delete_account", str(user_id)) - r = requests.post(f"{BASE}/api/user_security/requestDeletion", data={ - "reason": "API验证脚本自动测试注销流程", - "receipt": rcpt["receipt"], - "sig": rcpt["sig"], - }, headers=api_headers(), timeout=15) - resp = r.json() - code = resp.get("code") - if code == 1: - data = resp.get("data", {}) - result("申请注销", True, - f"status={data.get('status_text')}, countdown={data.get('countdown')}, " - f"auto_delete={data.get('auto_delete_time_text')}") - else: - msg = resp.get("msg", "") - if "已存在" in msg: - result("申请注销-已有申请", True, f"msg={msg} (已有待审核注销申请)") - else: - result("申请注销", False, f"code={code}, msg={msg}") - except Exception as e: - result("申请注销", False, str(e)) - - -# ============================================================ -# 测试18: 退出登录 -# ============================================================ -def test_logout(): - print("\n🚪 === 测试18: 退出登录 ===") - if not token: - result("退出登录", False, "无Token,跳过") - return - try: - r = requests.post(f"{BASE}/api/user_security/logout", - headers=api_headers(), timeout=15) - resp = r.json() - code = resp.get("code") - result("退出登录", code == 1, f"code={code}, msg={resp.get('msg')}") - except Exception as e: - result("退出登录", False, str(e)) - - -# ============================================================ -# 洞察分析 -# ============================================================ -def analyze_insights(): - print("\n" + "=" * 60) - print("🧠 本地计算逻辑分析 & 可洞察问题") - print("=" * 60) - - if not user_data: - print(" ⚠️ 无用户数据,跳过分析") - return - - extra = user_data.get("extra", {}) - sec_q = extra.get("sec_question") - last_signin = extra.get("last_signin_date") - devices = user_data.get("devices", []) - verification = extra.get("verification", {}) - - print("\n📋 可洞察问题列表:") - print("-" * 40) - - # 1. 密保问题 - if sec_q is None or sec_q == 0 or (isinstance(sec_q, dict) and sec_q.get("question_id", 0) == 0): - print(" 🔒 [高] 密保问题未设置 → 应触发'密保问题未设置'通知") - else: - print(f" ✅ 密保问题已设置 (sec_question={sec_q}) → 无需通知") - - # 2. 长期未活跃 - if last_signin and isinstance(last_signin, str) and last_signin: - try: - last_date = __import__("datetime").datetime.strptime(last_signin, "%Y-%m-%d") - diff = (__import__("datetime").datetime.now() - last_date).days - if diff > 30: - print(f" ⏰ [中] 长期未活跃 ({diff}天) → 应触发'长期未活跃'通知") - else: - print(f" ✅ 最近活跃 ({diff}天前) → 无需通知") - except ValueError: - print(f" ⚠️ 最后签到日期格式异常: {last_signin}") - - # 3. 新设备检测 - if devices and len(devices) > 1: - print(f" 📱 [高] 多设备登录 ({len(devices)}台) → 可检测新设备") - models = set() - for d in devices: - m = d.get("device_model", "") - if m: - models.add(m) - if len(models) > 1: - print(f" → 检测到{len(models)}种不同设备型号") - elif devices and len(devices) == 1: - print(f" ✅ 单设备登录 → 无新设备风险") - - # 4. IP异常 - if devices: - ips = set() - cities = set() - for d in devices: - ip = d.get("ip", "") - city = d.get("ip_city", "") - if ip: - ips.add(ip) - if city: - cities.add(city) - if len(ips) > 1: - print(f" 🌐 [高] 多IP登录 ({len(ips)}个不同IP) → 可检测IP异常") - for d in devices: - print(f" → {d.get('device_model')}: IP={d.get('ip')}, 城市={d.get('ip_city')}") - else: - print(f" ✅ 单IP登录 → 无IP异常") - - # 5. 邮箱/手机验证 - if verification: - email_verified = verification.get("email", 0) - mobile_verified = verification.get("mobile", 0) - if not email_verified: - print(" 📧 [中] 邮箱未验证 → 建议验证邮箱") - if not mobile_verified: - print(" 📱 [中] 手机未验证 → 建议验证手机号") - - # 6. 密码变更 - print(" ⚠️ [高] 密码变更检测: 当前API不返回 password_changed_at 字段") - print(" → 需要后端在 user_center/index 返回中增加此字段") - print(" → 当前本地计算假设 userData 中有此字段,实际为空则不会触发") - - # 7. 2FA预告 - print(" 🛡️ [低] 2FA预告: 纯本地静态通知,无需API数据 → 逻辑正确") - - # 8. 安全建议 - print(" 💡 [低] 安全建议: 纯本地静态通知,无需API数据 → 逻辑正确") - - print("\n" + "-" * 40) - print("📊 本地计算逻辑准确性评估:") - print("-" * 40) - - checks = [ - ("密保问题检测", True, "使用 extra.sec_question 字段,==0 触发 → 正确"), - ("新设备检测", True, "对比 KV Store 缓存的 last_login_device → 正确"), - ("IP异常检测", True, "对比 KV Store 缓存的 last_login_ip → 正确"), - ("长期未活跃", True, "对比 KV Store 缓存的 last_login_time,>30天触发 → 正确"), - ("密码变更检测", False, "API不返回 password_changed_at → 本地无法计算,需后端支持"), - ("2FA预告", True, "纯静态通知 → 正确"), - ("安全建议", True, "纯静态通知 → 正确"), - ] - - for name, ok, desc in checks: - icon = "✅" if ok else "⚠️" - print(f" {icon} {name}: {desc}") - - total_ok = sum(1 for _, ok, _ in checks if ok) - print(f"\n 📈 准确率: {total_ok}/{len(checks)} ({total_ok*100//len(checks)}%)") - - -def print_summary(): - print("\n" + "=" * 60) - print("📋 测试总结") - print("=" * 60) - print(f" ✅ 通过: {pass_count}") - print(f" ❌ 失败: {fail_count}") - print(f" 📊 总计: {pass_count + fail_count}") - if fail_count == 0: - print(" 🎉 全部通过!") - else: - print(f" ⚠️ 有 {fail_count} 项失败,请检查") - - -def main(): - print("🛡️ 闲言APP — 账户洞察全流程接口验证脚本") - print(f"📅 {time.strftime('%Y-%m-%d %H:%M:%S')}") - print(f"🔗 API: {BASE}") - print(f"👤 测试账号: {TEST_ACCOUNT}") - print("=" * 60) - - test_register() - test_login() - test_user_info() - test_device_list() - test_register_device() - test_ip_query() - test_sec_questions() - test_dashboard() - test_signin() - test_interaction() - test_favorite() - test_profile() - test_receipt_login() - test_token_login() - test_deletion_status() - test_stats() - test_fortune() - - analyze_insights() - - test_request_deletion() - test_logout() - - print_summary() - - -if __name__ == "__main__": - main() diff --git a/scripts/check_android_config.py b/scripts/check_android_config.py deleted file mode 100644 index 3acaf943..00000000 --- a/scripts/check_android_config.py +++ /dev/null @@ -1,865 +0,0 @@ -#!/usr/bin/env python3 -# ============================================================ -# 闲言APP — Android配置一致性检查脚本 -# 创建时间: 2026-06-01 -# 更新时间: 2026-06-01 -# 名称: check_android_config.py -# 作用: 验证Android原生配置与Flutter插件的一致性 -# 上次更新: 初始创建,检查shortcuts/manifest/gradle配置 -# ============================================================ - -import argparse -import json -import os -import re -import sys -import xml.etree.ElementTree as ET - -ANDROID_NS = "http://schemas.android.com/apk/res/android" -TOOLS_NS = "http://schemas.android.com/tools" - -PASS = "pass" -WARN = "warn" -FAIL = "fail" - -SCORE_WEIGHTS = {PASS: 10, WARN: 5, FAIL: 0} - - -def ns(attr): - return f"{{{ANDROID_NS}}}{attr}" - - -def find_project_root(): - candidates = [os.getcwd()] - script_dir = os.path.dirname(os.path.abspath(__file__)) - candidates.append(script_dir) - for d in candidates: - if os.path.isfile(os.path.join(d, "pubspec.yaml")): - return d - parent = os.path.dirname(d) - if os.path.isfile(os.path.join(parent, "pubspec.yaml")): - return parent - return os.getcwd() - - -def read_file(path): - if not os.path.isfile(path): - return None - with open(path, "r", encoding="utf-8", errors="replace") as f: - return f.read() - - -def parse_xml(path): - if not os.path.isfile(path): - return None - try: - tree = ET.parse(path) - return tree.getroot() - except ET.ParseError: - return None - - -class CheckResult: - def __init__(self, category, name, status, message, detail=None): - self.category = category - self.name = name - self.status = status - self.message = message - self.detail = detail or [] - - def to_dict(self): - return { - "category": self.category, - "name": self.name, - "status": self.status, - "message": self.message, - "detail": self.detail, - } - - -class AndroidConfigChecker: - def __init__(self, project_root, verbose=False): - self.project_root = project_root - self.verbose = verbose - self.results = [] - - self.manifest_path = os.path.join( - project_root, "android", "app", "src", "main", "AndroidManifest.xml" - ) - self.shortcuts_path = os.path.join( - project_root, "android", "app", "src", "main", "res", "xml", "shortcuts.xml" - ) - self.app_gradle_path = os.path.join( - project_root, "android", "app", "build.gradle.kts" - ) - self.root_gradle_path = os.path.join( - project_root, "android", "build.gradle.kts" - ) - self.gradle_props_path = os.path.join( - project_root, "android", "gradle.properties" - ) - self.pubspec_lock_path = os.path.join(project_root, "pubspec.lock") - - self.manifest_root = parse_xml(self.manifest_path) - self.shortcuts_root = parse_xml(self.shortcuts_path) - self.app_gradle_content = read_file(self.app_gradle_path) - self.root_gradle_content = read_file(self.root_gradle_path) - self.gradle_props_content = read_file(self.gradle_props_path) - self.pubspec_lock_content = read_file(self.pubspec_lock_path) - - def add(self, category, name, status, message, detail=None): - self.results.append(CheckResult(category, name, status, message, detail or [])) - - def check_manifest_exists(self): - if self.manifest_root is not None: - self.add("Manifest", "文件存在", PASS, "AndroidManifest.xml 存在且可解析") - else: - self.add("Manifest", "文件存在", FAIL, "AndroidManifest.xml 不存在或无法解析") - - def check_permissions(self): - if self.manifest_root is None: - self.add("Manifest", "权限检查", FAIL, "无法解析 Manifest,跳过权限检查") - return - - permissions = [] - for elem in self.manifest_root.iter(): - if elem.tag == "uses-permission": - name = elem.get(ns("name"), "") - permissions.append(name) - - required = { - "android.permission.INTERNET": "网络访问(dio/cached_network_image)", - "android.permission.ACCESS_NETWORK_STATE": "网络状态检测", - } - - for perm, desc in required.items(): - if perm in permissions: - self.add("Manifest", f"权限: {perm}", PASS, f"已声明 — {desc}") - else: - self.add("Manifest", f"权限: {perm}", FAIL, f"缺少必要权限 — {desc}") - - optional = { - "android.permission.VIBRATE": "震动反馈", - } - for perm, desc in optional.items(): - if perm in permissions: - self.add("Manifest", f"权限: {perm}", PASS, f"已声明 — {desc}") - else: - self.add("Manifest", f"权限: {perm}", WARN, f"未声明 — {desc}(如不需要可忽略)") - - if self.verbose: - self.add( - "Manifest", - "全部权限列表", - PASS, - f"共声明 {len(permissions)} 项权限", - permissions, - ) - - def check_activity_config(self): - if self.manifest_root is None: - self.add("Manifest", "Activity配置", FAIL, "无法解析 Manifest") - return - - app = self.manifest_root.find("application") - if app is None: - self.add("Manifest", "Activity配置", FAIL, "未找到 标签") - return - - activity = None - for act in app.findall("activity"): - name = act.get(ns("name"), "") - if "MainActivity" in name: - activity = act - break - - if activity is None: - self.add("Manifest", "Activity配置", FAIL, "未找到 MainActivity") - return - - exported = activity.get(ns("exported"), "") - if exported == "true": - self.add("Manifest", "Activity exported", PASS, "MainActivity 已设置 exported=true") - else: - self.add("Manifest", "Activity exported", WARN, "MainActivity 未设置 exported=true,可能影响启动") - - launch_mode = activity.get(ns("launchMode"), "") - if launch_mode == "singleTop": - self.add("Manifest", "Activity launchMode", PASS, "launchMode=singleTop,防止重复实例") - else: - self.add("Manifest", "Activity launchMode", WARN, f"launchMode={launch_mode or '未设置'},建议设为 singleTop") - - soft_input = activity.get(ns("windowSoftInputMode"), "") - if soft_input == "adjustResize": - self.add("Manifest", "Activity softInputMode", PASS, "windowSoftInputMode=adjustResize") - else: - self.add("Manifest", "Activity softInputMode", WARN, f"windowSoftInputMode={soft_input or '未设置'},建议设为 adjustResize") - - hardware_accel = activity.get(ns("hardwareAccelerated"), "") - if hardware_accel == "true": - self.add("Manifest", "Activity hardwareAccelerated", PASS, "hardwareAccelerated=true") - else: - self.add("Manifest", "Activity hardwareAccelerated", WARN, "hardwareAccelerated 未启用,可能影响渲染性能") - - def check_intent_filters(self): - if self.manifest_root is None: - self.add("Manifest", "IntentFilter", FAIL, "无法解析 Manifest") - return - - app = self.manifest_root.find("application") - if app is None: - return - - activity = None - for act in app.findall("activity"): - if "MainActivity" in act.get(ns("name"), ""): - activity = act - break - - if activity is None: - return - - filters = activity.findall("intent-filter") - has_main = False - has_launcher = False - share_filters = [] - - for f in filters: - actions = [a.get(ns("name"), "") for a in f.findall("action")] - categories = [c.get(ns("name"), "") for c in f.findall("category")] - data_elems = f.findall("data") - mime_types = [d.get(ns("mimeType"), "") for d in data_elems] - - if "android.intent.action.MAIN" in actions: - has_main = True - if "android.intent.category.LAUNCHER" in categories: - has_launcher = True - if "android.intent.action.SEND" in actions or "android.intent.action.SEND_MULTIPLE" in actions: - share_filters.append( - {"actions": actions, "categories": categories, "mimeTypes": mime_types} - ) - - if has_main and has_launcher: - self.add("Manifest", "启动IntentFilter", PASS, "MAIN+LAUNCHER 配置正确") - else: - self.add( - "Manifest", - "启动IntentFilter", - FAIL, - f"MAIN={has_main}, LAUNCHER={has_launcher},应用可能无法启动", - ) - - if share_filters: - self.add( - "Manifest", - "分享IntentFilter", - PASS, - f"已配置 {len(share_filters)} 个分享 IntentFilter", - [f"actions={s['actions']}, mimeTypes={s['mimeTypes']}" for s in share_filters] - if self.verbose - else [], - ) - else: - self.add("Manifest", "分享IntentFilter", WARN, "未配置分享 IntentFilter") - - def check_enable_on_back_invoked(self): - if self.manifest_root is None: - return - - app = self.manifest_root.find("application") - if app is None: - return - - app_flag = app.get(ns("enableOnBackInvokedCallback"), "") - if app_flag == "true": - self.add("Manifest", "enableOnBackInvokedCallback(app)", PASS, "Application 级已启用预测性返回手势") - else: - self.add("Manifest", "enableOnBackInvokedCallback(app)", WARN, "Application 级未启用预测性返回手势(Android 13+推荐)") - - activity = None - for act in app.findall("activity"): - if "MainActivity" in act.get(ns("name"), ""): - activity = act - break - - if activity is not None: - act_flag = activity.get(ns("enableOnBackInvokedCallback"), "") - if act_flag == "true": - self.add("Manifest", "enableOnBackInvokedCallback(activity)", PASS, "Activity 级已启用预测性返回手势") - else: - self.add("Manifest", "enableOnBackInvokedCallback(activity)", WARN, "Activity 级未启用预测性返回手势") - - def check_shortcuts_xml(self): - if self.shortcuts_root is None: - self.add("Shortcuts", "shortcuts.xml", WARN, "res/xml/shortcuts.xml 不存在或无法解析,跳过快捷方式检查") - return - - shortcuts = self.shortcuts_root.findall("shortcut") - if not shortcuts: - self.add("Shortcuts", "快捷方式数量", WARN, "shortcuts.xml 中无快捷方式定义") - return - - self.add("Shortcuts", "快捷方式数量", PASS, f"共定义 {len(shortcuts)} 个快捷方式") - - shortcut_ids = [] - for s in shortcuts: - sid = s.get(ns("shortcutId"), "") - enabled = s.get(ns("enabled"), "true") - shortcut_ids.append(sid) - - if enabled == "true": - self.add("Shortcuts", f"快捷方式: {sid}", PASS, f"已启用 (id={sid})") - else: - self.add("Shortcuts", f"快捷方式: {sid}", WARN, f"已禁用 (id={sid})") - - intent = s.find("intent") - if intent is None: - self.add("Shortcuts", f"{sid} intent", FAIL, f"快捷方式 {sid} 缺少 ") - continue - - action = intent.get(ns("action"), "") - target_class = intent.get(ns("targetClass"), "") - extras = intent.findall("extra") - - if action == "android.intent.action.RUN": - self.add("Shortcuts", f"{sid} action", PASS, f"action=RUN,与 quick_actions_android 插件一致") - else: - self.add( - "Shortcuts", - f"{sid} action", - FAIL, - f"action={action},应为 android.intent.action.RUN(quick_actions_android 插件要求)", - ) - - if "MainActivity" in target_class: - self.add("Shortcuts", f"{sid} targetClass", PASS, f"targetClass 指向 MainActivity") - else: - self.add("Shortcuts", f"{sid} targetClass", WARN, f"targetClass={target_class},请确认是否正确") - - extra_keys = [] - extra_values = [] - for extra in extras: - key = extra.get(ns("name"), "") - val = extra.get(ns("value"), "") - extra_keys.append(key) - extra_values.append(val) - - if "some unique action key" in extra_keys: - self.add("Shortcuts", f"{sid} extra key", PASS, f'extra key="some unique action key",与 quick_actions_android 插件一致') - else: - self.add( - "Shortcuts", - f"{sid} extra key", - FAIL, - f'extra key={extra_keys},应为 "some unique action key"(quick_actions_android 插件内部常量)', - ) - - if self.verbose and extra_values: - self.add( - "Shortcuts", - f"{sid} extra values", - PASS, - f"extra values: {extra_values}", - extra_values, - ) - - def check_shortcuts_flutter_consistency(self): - if self.shortcuts_root is None: - return - - if self.pubspec_lock_content is None: - self.add("Shortcuts", "Flutter插件一致性", WARN, "无法读取 pubspec.lock,跳过插件一致性检查") - return - - plugin_version = None - for line in self.pubspec_lock_content.splitlines(): - stripped = line.strip() - if stripped.startswith("version:"): - parent_indent = len(line) - len(line.lstrip()) - pass - if "quick_actions_android" in stripped and "name:" in stripped: - pass - - version_match = re.search( - r"quick_actions_android:.*?version:\s*[\"']?([^\"'\s]+)", - self.pubspec_lock_content, - re.DOTALL, - ) - if version_match: - plugin_version = version_match.group(1) - self.add("Shortcuts", "quick_actions_android版本", PASS, f"插件版本: {plugin_version}") - else: - self.add("Shortcuts", "quick_actions_android版本", WARN, "无法从 pubspec.lock 解析 quick_actions_android 版本") - - pub_cache = self._find_pub_cache() - plugin_source = None - if pub_cache: - plugin_dir = os.path.join(pub_cache, "quick_actions_android") - if os.path.isdir(plugin_dir): - plugin_source = plugin_dir - - if plugin_source is None: - self.add( - "Shortcuts", - "插件源码检查", - WARN, - "未找到 quick_actions_android 插件源码,无法深度验证常量一致性", - [f"搜索路径: {pub_cache}"] if pub_cache else [], - ) - self._check_shortcuts_dart_consistency() - return - - quick_actions_file = os.path.join(plugin_source, "android", "src", "main", "kotlin", "io", "flutter", "plugins", "quickactions", "QuickActionsPlugin.kt") - if not os.path.isfile(quick_actions_file): - alt_paths = [ - os.path.join(plugin_source, "lib", "src", "main", "kotlin", "io", "flutter", "plugins", "quickactions", "QuickActionsPlugin.kt"), - os.path.join(plugin_source, "android", "src", "main", "kotlin", "io", "flutter", "plugins", "quickactions", "MethodCallHandlerImpl.kt"), - ] - for alt in alt_paths: - if os.path.isfile(alt): - quick_actions_file = alt - break - - if not os.path.isfile(quick_actions_file): - self.add("Shortcuts", "插件源码检查", WARN, "未找到 QuickActionsPlugin.kt,无法验证常量") - self._check_shortcuts_dart_consistency() - return - - plugin_content = read_file(quick_actions_file) - - expected_action = "android.intent.action.RUN" - expected_key = "some unique action key" - - action_found = expected_action in plugin_content if plugin_content else False - key_found = expected_key in plugin_content if plugin_content else False - - if action_found: - self.add("Shortcuts", "插件action常量", PASS, f"插件源码中包含 action={expected_action}") - else: - self.add("Shortcuts", "插件action常量", WARN, f"插件源码中未找到 action={expected_action},可能版本已变更") - - if key_found: - self.add("Shortcuts", "插件extra key常量", PASS, f'插件源码中包含 extra key="{expected_key}"') - else: - self.add("Shortcuts", "插件extra key常量", WARN, f'插件源码中未找到 extra key="{expected_key}",可能版本已变更') - - if self.shortcuts_root is not None: - for s in self.shortcuts_root.findall("shortcut"): - sid = s.get(ns("shortcutId"), "") - intent = s.find("intent") - if intent is None: - continue - xml_action = intent.get(ns("action"), "") - extras = intent.findall("extra") - xml_key = extras[0].get(ns("name"), "") if extras else "" - - action_match = xml_action == expected_action if action_found else True - key_match = xml_key == expected_key if key_found else True - - if action_match and key_match: - self.add("Shortcuts", f"{sid} 一致性", PASS, f"shortcuts.xml 与 quick_actions_android 插件常量完全一致") - else: - mismatches = [] - if not action_match: - mismatches.append(f"action: xml={xml_action}, plugin={expected_action}") - if not key_match: - mismatches.append(f"extra key: xml={xml_key}, plugin={expected_key}") - self.add( - "Shortcuts", - f"{sid} 一致性", - FAIL, - f"shortcuts.xml 与插件常量不匹配!快捷方式将失效", - mismatches, - ) - - def _check_shortcuts_dart_consistency(self): - dart_service_path = os.path.join( - self.project_root, "lib", "core", "services", "device", "quick_actions_service.dart" - ) - content = read_file(dart_service_path) - if content is None: - self.add("Shortcuts", "Dart快捷操作一致性", WARN, "未找到 quick_actions_service.dart") - return - - dart_types = re.findall(r"type:\s*'([^']+)'", content) - if not dart_types: - self.add("Shortcuts", "Dart快捷操作类型", WARN, "未从 Dart 代码中提取到 ShortcutItem type") - return - - self.add("Shortcuts", "Dart快捷操作类型", PASS, f"Dart 中定义了 {len(dart_types)} 个快捷操作: {dart_types}") - - if self.shortcuts_root is None: - return - - xml_ids = [s.get(ns("shortcutId"), "") for s in self.shortcuts_root.findall("shortcut")] - - for dart_type in dart_types: - if dart_type in xml_ids: - self.add("Shortcuts", f"Dart↔XML: {dart_type}", PASS, f"Dart type 与 XML shortcutId 一致") - else: - self.add( - "Shortcuts", - f"Dart↔XML: {dart_type}", - FAIL, - f"Dart type='{dart_type}' 在 XML shortcutId 中不存在 ({xml_ids})", - ) - - for xml_id in xml_ids: - if xml_id not in dart_types: - self.add( - "Shortcuts", - f"XML↔Dart: {xml_id}", - WARN, - f"XML shortcutId='{xml_id}' 在 Dart ShortcutItem 中未定义", - ) - - def _find_pub_cache(self): - env_path = os.environ.get("PUB_CACHE") - if env_path and os.path.isdir(env_path): - return os.path.join(env_path, "hosted", "pub.flutter-io.cn") if os.path.isdir( - os.path.join(env_path, "hosted", "pub.flutter-io.cn") - ) else os.path.join(env_path, "hosted", "pub.dev") if os.path.isdir( - os.path.join(env_path, "hosted", "pub.dev") - ) else env_path - - home = os.path.expanduser("~") - candidates = [ - os.path.join(home, "AppData", "Local", "Pub", "Cache"), - os.path.join(home, ".pub-cache"), - os.path.join(home, ".pub_cache"), - ] - for c in candidates: - hosted = os.path.join(c, "hosted") - if os.path.isdir(hosted): - for sub in os.listdir(hosted): - sub_path = os.path.join(hosted, sub) - if os.path.isdir(sub_path) and os.path.isdir( - os.path.join(sub_path, "quick_actions_android") - ): - return sub_path - return hosted - - return None - - def check_16kb_page_support(self): - if self.app_gradle_content is None: - self.add("Gradle", "16KB页面支持", WARN, "无法读取 app/build.gradle.kts") - return - - if "useLegacyPackaging" in self.app_gradle_content: - if "useLegacyPackaging = false" in self.app_gradle_content or "useLegacyPackaging=false" in self.app_gradle_content: - self.add("Gradle", "16KB页面支持", PASS, "useLegacyPackaging=false,已支持 Android 15+ 16KB 页面大小") - else: - self.add("Gradle", "16KB页面支持", FAIL, "useLegacyPackaging=true,不支持 Android 15+ 16KB 页面大小设备") - else: - self.add("Gradle", "16KB页面支持", WARN, "未设置 useLegacyPackaging,建议显式设为 false") - - def check_sdk_versions(self): - if self.app_gradle_content is None: - self.add("Gradle", "SDK版本", WARN, "无法读取 app/build.gradle.kts") - return - - min_sdk_match = re.search(r"minSdk\s*=\s*(\d+)", self.app_gradle_content) - target_sdk_match = re.search(r"targetSdk\s*=\s*(\S+)", self.app_gradle_content) - compile_sdk_match = re.search(r"compileSdk\s*=\s*(\S+)", self.app_gradle_content) - - min_sdk = int(min_sdk_match.group(1)) if min_sdk_match else None - target_sdk = target_sdk_match.group(1) if target_sdk_match else None - compile_sdk = compile_sdk_match.group(1) if compile_sdk_match else None - - if min_sdk is not None: - if min_sdk >= 28: - self.add("Gradle", f"minSdk={min_sdk}", PASS, f"最低SDK版本 {min_sdk},满足基本要求") - else: - self.add("Gradle", f"minSdk={min_sdk}", WARN, f"最低SDK版本 {min_sdk},建议 >= 28") - else: - self.add("Gradle", "minSdk", FAIL, "未找到 minSdk 配置") - - if target_sdk is not None: - if target_sdk.startswith("flutter."): - self.add("Gradle", f"targetSdk={target_sdk}", PASS, f"使用 Flutter 默认 targetSdk ({target_sdk})") - else: - try: - tv = int(target_sdk) - if tv >= 34: - self.add("Gradle", f"targetSdk={tv}", PASS, f"目标SDK版本 {tv},满足 Google Play 要求") - else: - self.add("Gradle", f"targetSdk={tv}", WARN, f"目标SDK版本 {tv},Google Play 要求 >= 34") - except ValueError: - self.add("Gradle", f"targetSdk={target_sdk}", WARN, f"无法解析 targetSdk 值: {target_sdk}") - else: - self.add("Gradle", "targetSdk", WARN, "未找到 targetSdk 配置") - - if compile_sdk is not None: - self.add("Gradle", f"compileSdk={compile_sdk}", PASS, f"编译SDK版本: {compile_sdk}") - else: - self.add("Gradle", "compileSdk", WARN, "未找到 compileSdk 配置") - - def check_ndk_config(self): - if self.app_gradle_content is None: - self.add("Gradle", "NDK配置", WARN, "无法读取 app/build.gradle.kts") - return - - ndk_matches = re.findall(r"abiFilters\.add\([\"']([^\"']+)[\"']\)", self.app_gradle_content) - if ndk_matches: - if "arm64-v8a" in ndk_matches: - self.add("Gradle", "NDK abiFilters", PASS, f"已配置 ABI 过滤: {ndk_matches}") - else: - self.add("Gradle", "NDK abiFilters", WARN, f"ABI 过滤中缺少 arm64-v8a: {ndk_matches}") - else: - self.add("Gradle", "NDK abiFilters", WARN, "未配置 abiFilters,将包含所有架构") - - ndk_version_match = re.search(r"ndkVersion\s*=\s*(\S+)", self.app_gradle_content) - if ndk_version_match: - self.add("Gradle", "NDK版本", PASS, f"ndkVersion={ndk_version_match.group(1)}") - else: - self.add("Gradle", "NDK版本", PASS, "使用 Flutter 默认 NDK 版本") - - def check_signing_config(self): - if self.app_gradle_content is None: - self.add("Gradle", "签名配置", WARN, "无法读取 app/build.gradle.kts") - return - - if "signingConfig" in self.app_gradle_content: - if "signingConfigs.getByName(\"debug\")" in self.app_gradle_content: - self.add("Gradle", "签名配置", WARN, "Release 使用 debug 签名,正式发布前需配置 release 签名") - else: - self.add("Gradle", "签名配置", PASS, "已配置自定义签名") - else: - self.add("Gradle", "签名配置", WARN, "未找到签名配置") - - def check_gradle_properties(self): - if self.gradle_props_content is None: - self.add("Gradle", "gradle.properties", WARN, "无法读取 gradle.properties") - return - - if "android.useAndroidX=true" in self.gradle_props_content: - self.add("Gradle", "AndroidX", PASS, "已启用 AndroidX") - else: - self.add("Gradle", "AndroidX", WARN, "未启用 AndroidX") - - jvm_args_match = re.search(r"org\.gradle\.jvmargs=(.+)", self.gradle_props_content) - if jvm_args_match: - args = jvm_args_match.group(1).strip() - if "-Xmx" in args: - self.add("Gradle", "JVM内存", PASS, f"Gradle JVM 参数: {args}") - else: - self.add("Gradle", "JVM内存", WARN, f"JVM 参数中未设置 -Xmx: {args}") - else: - self.add("Gradle", "JVM内存", WARN, "未设置 org.gradle.jvmargs") - - def check_shortcuts_meta_data(self): - if self.manifest_root is None: - return - - app = self.manifest_root.find("application") - if app is None: - return - - activity = None - for act in app.findall("activity"): - if "MainActivity" in act.get(ns("name"), ""): - activity = act - break - - if activity is None: - return - - has_shortcuts_meta = False - for meta in activity.findall("meta-data"): - name = meta.get(ns("name"), "") - if name == "android.app.shortcuts": - has_shortcuts_meta = True - resource = meta.get(ns("resource"), "") - if resource == "@xml/shortcuts": - self.add("Manifest", "shortcuts meta-data", PASS, "android.app.shortcuts 指向 @xml/shortcuts") - else: - self.add("Manifest", "shortcuts meta-data", WARN, f"android.app.shortcuts 指向 {resource},请确认是否正确") - break - - if not has_shortcuts_meta: - if self.shortcuts_root is not None: - self.add("Manifest", "shortcuts meta-data", FAIL, "shortcuts.xml 存在但 Activity 中缺少 android.app.shortcuts meta-data") - else: - self.add("Manifest", "shortcuts meta-data", PASS, "无 shortcuts 配置,无需 meta-data") - - def check_manage_space_activity(self): - if self.manifest_root is None: - return - - app = self.manifest_root.find("application") - if app is None: - return - - manage_space = app.get(ns("manageSpaceActivity"), "") - if manage_space: - self.add("Manifest", "manageSpaceActivity", PASS, f"已配置 manageSpaceActivity={manage_space}") - else: - self.add("Manifest", "manageSpaceActivity", WARN, "未配置 manageSpaceActivity,用户无法通过系统设置清理应用数据") - - def check_cleartext_traffic(self): - if self.manifest_root is None: - return - - app = self.manifest_root.find("application") - if app is None: - return - - cleartext = app.get(ns("usesCleartextTraffic"), "") - if cleartext == "true": - self.add("Manifest", "usesCleartextTraffic", WARN, "已启用明文流量(HTTP),生产环境建议关闭") - else: - self.add("Manifest", "usesCleartextTraffic", PASS, "未启用明文流量,安全性良好") - - def check_queries(self): - if self.manifest_root is None: - return - - queries = self.manifest_root.find("queries") - if queries is not None: - intents = queries.findall("intent") - self.add("Manifest", "queries配置", PASS, f"已配置 ,包含 {len(intents)} 个 intent(包可见性适配)") - else: - self.add("Manifest", "queries配置", WARN, "未配置 ,Android 11+ 可能无法查询其他应用") - - def check_work_manager_receiver(self): - if self.manifest_root is None: - return - - app = self.manifest_root.find("application") - if app is None: - return - - for receiver in app.findall("receiver"): - name = receiver.get(ns("name"), "") - if "RescheduleReceiver" in name: - tools_node_val = receiver.get(f"{{{TOOLS_NS}}}node", "") - if tools_node_val == "remove": - self.add("Manifest", "WorkManager Receiver", PASS, "已移除 WorkManager 自启动 Receiver,防止开机自启") - else: - self.add("Manifest", "WorkManager Receiver", WARN, "WorkManager RescheduleReceiver 未移除,可能导致开机自启") - return - - self.add("Manifest", "WorkManager Receiver", PASS, "未发现 WorkManager RescheduleReceiver(已移除或不存在)") - - def run_all_checks(self): - self.check_manifest_exists() - self.check_permissions() - self.check_activity_config() - self.check_intent_filters() - self.check_enable_on_back_invoked() - self.check_shortcuts_meta_data() - self.check_shortcuts_xml() - self.check_shortcuts_flutter_consistency() - self.check_16kb_page_support() - self.check_sdk_versions() - self.check_ndk_config() - self.check_signing_config() - self.check_gradle_properties() - self.check_manage_space_activity() - self.check_cleartext_traffic() - self.check_queries() - self.check_work_manager_receiver() - return self.results - - def calculate_score(self): - if not self.results: - return 0 - total = sum(SCORE_WEIGHTS[r.status] for r in self.results) - max_total = len(self.results) * SCORE_WEIGHTS[PASS] - return round(total / max_total * 100) if max_total > 0 else 0 - - def print_report(self): - status_icon = {PASS: "✅", WARN: "⚠️", FAIL: "❌"} - - categories = {} - for r in self.results: - categories.setdefault(r.category, []).append(r) - - print("\n" + "=" * 60) - print(" 闲言APP — Android 配置一致性检查报告") - print("=" * 60) - - for cat, items in categories.items(): - print(f"\n📦 {cat}") - print("-" * 40) - for item in items: - icon = status_icon.get(item.status, "❓") - print(f" {icon} {item.name}: {item.message}") - if self.verbose and item.detail: - for d in item.detail: - print(f" → {d}") - - score = self.calculate_score() - pass_count = sum(1 for r in self.results if r.status == PASS) - warn_count = sum(1 for r in self.results if r.status == WARN) - fail_count = sum(1 for r in self.results if r.status == FAIL) - - print("\n" + "=" * 60) - print(f" 📊 总计: {len(self.results)} 项检查") - print(f" ✅ 通过: {pass_count}") - print(f" ⚠️ 警告: {warn_count}") - print(f" ❌ 错误: {fail_count}") - print(f" 🏆 评分: {score}/100") - print("=" * 60) - - if fail_count > 0: - print("\n🔴 需要立即修复的错误:") - for r in self.results: - if r.status == FAIL: - print(f" • {r.category} → {r.name}: {r.message}") - - if warn_count > 0: - print(f"\n🟡 建议关注的警告 ({warn_count} 项):") - for r in self.results: - if r.status == WARN: - print(f" • {r.category} → {r.name}: {r.message}") - - print() - return score - - def json_report(self): - score = self.calculate_score() - pass_count = sum(1 for r in self.results if r.status == PASS) - warn_count = sum(1 for r in self.results if r.status == WARN) - fail_count = sum(1 for r in self.results if r.status == FAIL) - - report = { - "project": os.path.basename(self.project_root), - "score": score, - "total": len(self.results), - "pass": pass_count, - "warn": warn_count, - "fail": fail_count, - "checks": [r.to_dict() for r in self.results], - } - print(json.dumps(report, ensure_ascii=False, indent=2)) - return score - - -def main(): - parser = argparse.ArgumentParser(description="闲言APP Android配置一致性检查") - parser.add_argument("--verbose", "-v", action="store_true", help="输出详细信息") - parser.add_argument("--json", action="store_true", help="输出JSON格式报告") - parser.add_argument("--project", "-p", help="项目根目录路径(默认自动检测)") - args = parser.parse_args() - - project_root = args.project or find_project_root() - - if not os.path.isfile(os.path.join(project_root, "pubspec.yaml")): - print(f"❌ 未找到 Flutter 项目: {project_root}") - sys.exit(1) - - checker = AndroidConfigChecker(project_root, verbose=args.verbose) - checker.run_all_checks() - - if args.json: - score = checker.json_report() - else: - score = checker.print_report() - - sys.exit(0 if score >= 60 else 1) - - -if __name__ == "__main__": - main() diff --git a/scripts/check_translation_coverage.py b/scripts/check_translation_coverage.py deleted file mode 100644 index 228704d7..00000000 --- a/scripts/check_translation_coverage.py +++ /dev/null @@ -1,467 +0,0 @@ -#!/usr/bin/env python3 -# ============================================================ -# 闲言APP — 多语言翻译覆盖率检测脚本 -# 创建时间: 2026-06-01 -# 更新时间: 2026-06-01 -# 作用: CI/CD中自动检测14个语言文件的翻译覆盖率 -# 上次更新: 初始创建 -# ============================================================ - -import os -import sys -import re -import io -import json -import argparse -from pathlib import Path -from collections import OrderedDict -from datetime import datetime - -if sys.stdout.encoding != "utf-8": - sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") -if sys.stderr.encoding != "utf-8": - sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") - -PROJECT_ROOT = Path(__file__).resolve().parent.parent -LANG_DIR = PROJECT_ROOT / "lib" / "l10n" / "languages" -BASE_LANG_FILE = "zh_cn.dart" - -LANGUAGE_MAP = OrderedDict([ - ("zh_cn.dart", "zh_CN"), - ("zh_tw.dart", "zh_TW"), - ("en.dart", "en"), - ("ja.dart", "ja"), - ("ko.dart", "ko"), - ("de.dart", "de"), - ("fr.dart", "fr"), - ("es.dart", "es"), - ("it.dart", "it"), - ("pt.dart", "pt"), - ("ru.dart", "ru"), - ("ar.dart", "ar"), - ("bn.dart", "bn"), - ("hi.dart", "hi"), -]) - - -def parse_dart_lang_file(filepath): - with open(filepath, "r", encoding="utf-8") as f: - content = f.read() - - sections = OrderedDict() - field_paths = {} - section_stack = [] - current_top = None - - eq_pos = content.find("= T(") - if eq_pos == -1: - return sections, field_paths, content - - i = eq_pos + 4 - depth = 1 - - while i < len(content) and depth > 0: - while i < len(content) and content[i] in " \t\n\r,": - i += 1 - if i >= len(content): - break - - m = re.match(r"(\w+):\s*\w+\s*\(", content[i:]) - if m: - name = m.group(1) - if depth == 1: - current_top = name - section_stack = [name] - sections[current_top] = OrderedDict() - else: - section_stack.append(name) - i += m.end() - depth += 1 - continue - - m = re.match(r"(\w+):\s*'((?:[^'\\]|\\.)*)'", content[i:]) - if m and current_top is not None: - fname = m.group(1) - fval = m.group(2) - sections[current_top][fname] = fval - field_paths[(current_top, fname)] = list(section_stack) - i += m.end() - continue - - if content[i] == ")": - depth -= 1 - if depth == 1: - current_top = None - section_stack = [] - elif depth > 1 and len(section_stack) > 1: - section_stack.pop() - i += 1 - continue - - if content[i] == "(": - depth += 1 - i += 1 - continue - - i += 1 - - return sections, field_paths, content - - -def find_matching_paren(content, open_pos): - depth = 1 - i = open_pos + 1 - in_string = False - escape_next = False - - while i < len(content) and depth > 0: - c = content[i] - if escape_next: - escape_next = False - i += 1 - continue - if c == "\\": - escape_next = True - i += 1 - continue - if in_string: - if c == "'": - in_string = False - i += 1 - continue - if c == "'": - in_string = True - i += 1 - continue - if c == "(": - depth += 1 - elif c == ")": - depth -= 1 - if depth == 0: - return i - i += 1 - - return -1 - - -def find_constructor_ranges(content): - ranges = {} - section_stack = [] - current_top = None - - eq_pos = content.find("= T(") - if eq_pos == -1: - return ranges - - i = eq_pos + 4 - depth = 1 - - while i < len(content) and depth > 0: - while i < len(content) and content[i] in " \t\n\r,": - i += 1 - if i >= len(content): - break - - m = re.match(r"(\w+):\s*\w+\s*\(", content[i:]) - if m: - name = m.group(1) - if depth == 1: - current_top = name - section_stack = [name] - else: - section_stack.append(name) - path = ".".join(section_stack) - - line_start = content.rfind("\n", 0, i) + 1 - indent_match = re.match(r"(\s+)", content[line_start:i]) - indent = (indent_match.group(1) if indent_match else " ") + " " - - open_pos = i + m.end() - 1 - close_pos = find_matching_paren(content, open_pos) - - if close_pos != -1: - ranges[path] = { - "open_pos": open_pos, - "close_pos": close_pos, - "indent": indent, - } - - i += m.end() - depth += 1 - continue - - m = re.match(r"(\w+):\s*'((?:[^'\\]|\\.)*)'", content[i:]) - if m: - i += m.end() - continue - - if content[i] == ")": - depth -= 1 - if depth == 1: - current_top = None - section_stack = [] - elif depth > 1 and len(section_stack) > 1: - section_stack.pop() - i += 1 - continue - - if content[i] == "(": - depth += 1 - i += 1 - continue - - i += 1 - - return ranges - - -def check_coverage(base_sections, target_sections): - missing = [] - empty = [] - extra = [] - - for section, fields in base_sections.items(): - target_fields = target_sections.get(section, OrderedDict()) - for field, value in fields.items(): - if field not in target_fields: - missing.append((section, field)) - elif not target_fields[field] and value: - empty.append((section, field)) - - for section, fields in target_sections.items(): - if section not in base_sections: - for field in fields: - extra.append((section, field)) - continue - for field in fields: - if field not in base_sections.get(section, {}): - extra.append((section, field)) - - return missing, empty, extra - - -def count_total_keys(sections): - return sum(len(fields) for fields in sections.values()) - - -def generate_text_report(results, threshold): - lines = [] - lines.append("=" * 72) - lines.append(" 闲言APP 多语言翻译覆盖率报告") - lines.append(f" 生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - lines.append("=" * 72) - lines.append("") - - header = f"{'语言':<8} {'总键数':>6} {'已翻译':>6} {'缺失':>6} {'空值':>6} {'覆盖率':>8}" - lines.append(header) - lines.append("-" * len(header)) - - below_threshold = [] - sorted_results = sorted(results, key=lambda x: x["coverage"], reverse=True) - - for r in sorted_results: - mark = " ✅" if r["coverage"] >= threshold else " ❌" - line = ( - f"{r['lang']:<8} {r['total']:>6} {r['translated']:>6} " - f"{r['missing_count']:>6} {r['empty_count']:>6} {r['coverage']:>7.1f}%{mark}" - ) - lines.append(line) - if r["coverage"] < threshold and r["lang"] != "zh_CN": - below_threshold.append(r["lang"]) - - lines.append("") - - if below_threshold: - lines.append(f"⚠️ 以下语言覆盖率低于阈值 {threshold}%: {', '.join(below_threshold)}") - else: - lines.append(f"✅ 所有语言覆盖率均达到阈值 {threshold}%") - - lines.append("") - lines.append("=" * 72) - lines.append(" 覆盖率排名") - lines.append("=" * 72) - - for idx, r in enumerate(sorted_results, 1): - medal = "🥇" if idx == 1 else "🥈" if idx == 2 else "🥉" if idx == 3 else " " - lines.append(f" {medal} #{idx:<2} {r['lang']:<8} {r['coverage']:.1f}%") - - lines.append("") - - has_details = any(r["missing"] or r["empty"] for r in results) - if has_details: - lines.append("=" * 72) - lines.append(" 缺失/空值键详情") - lines.append("=" * 72) - - for r in sorted_results: - if r["missing"] or r["empty"]: - lines.append(f"") - lines.append(f"── {r['lang']} ({r['file']}) ──") - if r["missing"]: - lines.append(f" 缺失键 ({len(r['missing'])}):") - for section, field in r["missing"]: - lines.append(f" - {section}.{field}") - if r["empty"]: - lines.append(f" 空值键 ({len(r['empty'])}):") - for section, field in r["empty"]: - lines.append(f" - {section}.{field}") - - lines.append("") - return "\n".join(lines), below_threshold - - -def generate_json_report(results, threshold): - below_threshold = [ - r["lang"] for r in results if r["coverage"] < threshold and r["lang"] != "zh_CN" - ] - report = { - "generated_at": datetime.now().isoformat(), - "threshold": threshold, - "below_threshold": below_threshold, - "ranking": [ - {"rank": idx, "lang": r["lang"], "coverage": round(r["coverage"], 1)} - for idx, r in enumerate(sorted(results, key=lambda x: x["coverage"], reverse=True), 1) - ], - "languages": {}, - } - for r in sorted(results, key=lambda x: x["coverage"], reverse=True): - report["languages"][r["lang"]] = { - "total": r["total"], - "translated": r["translated"], - "missing_count": r["missing_count"], - "empty_count": r["empty_count"], - "extra_count": r["extra_count"], - "coverage": round(r["coverage"], 1), - "missing_keys": [f"{s}.{f}" for s, f in r["missing"]], - "empty_keys": [f"{s}.{f}" for s, f in r["empty"]], - } - return json.dumps(report, ensure_ascii=False, indent=2), below_threshold - - -def fix_missing_keys(target_filepath, missing_keys, base_sections, base_field_paths, target_content): - constructor_ranges = find_constructor_ranges(target_content) - - keys_by_path = OrderedDict() - for section, field in missing_keys: - path_stack = base_field_paths.get((section, field), [section]) - path = ".".join(path_stack) - if path not in keys_by_path: - keys_by_path[path] = [] - keys_by_path[path].append((field, base_sections[section][field])) - - insertions = [] - fixed_count = 0 - skipped_count = 0 - - for path, fields in keys_by_path.items(): - if path not in constructor_ranges: - skipped_count += len(fields) - continue - range_info = constructor_ranges[path] - close_pos = range_info["close_pos"] - indent = range_info["indent"] - - insert_text = "" - for field, value in fields: - insert_text += f"{indent}// TODO: translate\n" - insert_text += f"{indent}{field}: '{value}',\n" - fixed_count += 1 - - insertions.append((close_pos, insert_text)) - - insertions.sort(key=lambda x: x[0], reverse=True) - for pos, text in insertions: - target_content = target_content[:pos] + text + target_content[pos:] - - with open(target_filepath, "w", encoding="utf-8") as f: - f.write(target_content) - - return fixed_count, skipped_count - - -def main(): - parser = argparse.ArgumentParser(description="闲言APP 多语言翻译覆盖率检测") - parser.add_argument( - "--threshold", type=float, default=80, - help="覆盖率阈值(百分比),低于此值返回非零退出码 (默认: 80)", - ) - parser.add_argument( - "--json", action="store_true", dest="json_output", - help="输出JSON格式报告", - ) - parser.add_argument( - "--fix", action="store_true", - help="自动填充缺失的键(用基准语言值+TODO标记)", - ) - args = parser.parse_args() - - if not LANG_DIR.exists(): - print(f"❌ 语言文件目录不存在: {LANG_DIR}", file=sys.stderr) - sys.exit(1) - - base_filepath = LANG_DIR / BASE_LANG_FILE - if not base_filepath.exists(): - print(f"❌ 基准语言文件不存在: {base_filepath}", file=sys.stderr) - sys.exit(1) - - base_sections, base_field_paths, _ = parse_dart_lang_file(base_filepath) - base_total = count_total_keys(base_sections) - - if base_total == 0: - print("❌ 基准语言文件解析失败,未找到任何翻译键", file=sys.stderr) - sys.exit(1) - - print(f"📊 基准语言(zh_CN)共 {base_total} 个翻译键,共 {len(base_sections)} 个模块\n") - - results = [] - - for filename, lang_id in LANGUAGE_MAP.items(): - filepath = LANG_DIR / filename - if not filepath.exists(): - print(f"⚠️ 语言文件不存在: {filename}", file=sys.stderr) - continue - - target_sections, _, target_content = parse_dart_lang_file(filepath) - missing, empty, extra = check_coverage(base_sections, target_sections) - - translated = base_total - len(missing) - len(empty) - coverage = (translated / base_total * 100) if base_total > 0 else 0 - - results.append({ - "lang": lang_id, - "file": filename, - "total": base_total, - "translated": translated, - "missing_count": len(missing), - "empty_count": len(empty), - "extra_count": len(extra), - "coverage": coverage, - "missing": missing, - "empty": empty, - "extra": extra, - }) - - if args.fix and lang_id != "zh_CN" and missing: - fixed, skipped = fix_missing_keys( - filepath, missing, base_sections, base_field_paths, target_content, - ) - if fixed > 0: - print(f" 🔧 {lang_id}: 已填充 {fixed} 个缺失键") - if skipped > 0: - print(f" ⚠️ {lang_id}: {skipped} 个缺失键无法自动填充(目标构造函数不存在)") - - if args.json_output: - report, below = generate_json_report(results, args.threshold) - else: - report, below = generate_text_report(results, args.threshold) - - print(report) - - if below: - sys.exit(1) - else: - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/scripts/distribution.cer b/scripts/distribution.cer deleted file mode 100644 index d2331e1e582703eb4fe05833dfdecb82c3bcf14b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1481 zcmY*ZdsGxv9Ns&-EU~+QxV)4WuaO)c?(SXI1>Z{HE)OFkR}PlcGBXQPFtg0evf{~f zS3(mAG=U;hN>3$)C@7vRwZuo{!O&FBF*`+C97)vF04uW@w?+J8&YADt-{ZUA{e2gC z{Ej1!thHH+0Sue4bnZ}3>BU1IZe5b^Ol(h1MS=2wgv#{Fkw5^HeiGy~q@kzeQmag= znv?2q6q7KQVT%bDMUXI!EF+5<2g$-5ve?E`49&r(NtUPV6#f9hR3~4=uoPczP@({_ zic*DHj0QCd5rIOb@RrP=ah)$huEwm-qv(jhq-2zg3`t3*WP@S308_>wT8`XRhRG;W zt_av1`{vuXrV{|D6sBdfvX@z{$yp|Y2^qbdr+XWuQ5?@w1x}IUY*z2Lwxy2PiNP0FsVcrfWE^X;gbV&luA& zVPD%HEiu(K|3uBnGt*xapY*l&9X)o3QO;}Gy6@!$gMsH}M0WXKsDQg}7BJ22Era>{ z+0vy8`XaQqN9zxm_M06;JGp_{9nD7FftB-3n_f$*{`Qy7R^6__{<-s4)_>oZ8ChTT zk~}49N&!08ez{##z-vmh=@bq%K_ddqe-Hr?is_ z_1a-mH~jTVPv@%c>&NSc&DguAvp%>ON4~SDroS~mulv>}`w>hEBw&XJn~6Nw45Sj| z)TjZtFT;=oO%YLyEE0vU4GsABtFXGeZG>X;#Lr{na8={AkK2(${#c?M`1$z*AR|x# z()uvMDyJ6lykoXrj|)@B>1^VB>Tsq+?{X1rp;1SWEHaFRgUV_Dhch_m7#A%J3#~NpbGUD(hZ6V#L9v{x9~S zAaOfbOp{45|FuXhS3G!Ya6%4*>lFq8vw=x7egyVc ziMJ{W2kmqn)ud5Pd+|VRY3iNB7Tx)dWrr5S(>EI9b0^+RJu=c*lP)1Hg@1H(=b$`n zf6~xA7=QGq;K}QXvlq_VeJ1B%{Dwq*+WHe~!(+`S|17ioJ*?LztQlERfA4H>=+yW% z*%RcN-j(<6Tel@%zU7P>-n%6DBzrpJT4qddS(oJvOHRY=*CTpH?yak+GLQCDtlp}t z4|?Uyd8YrcOp|+V`(1OG@!21u*TWN~QTV84#f4+g6xBqgQ4 zoE5==napHVz|P%izvb`rFM02(ddpX$g$e3NrKA(E#mOblVA%Hd){)Z!HkeDI?8}i# zDH^#49C!cTsnNsJIu!x?&c=W}gj5YDxr$(UN;izrW=^>?Hl%ZUxMUj|B2N}03YKgp zC5YlahmA&8>cS_cBI8SZ;x86HvaA=Quapd;*kcu)3bFXDhgP4tM4dUAJ>g=L6Id}$ zEYWhte5xsjNEOa_ehR&=sJf3%yE313Xe(*Sy}-IoiU;@#*ec@jzBBRg!pvl$p3OP} zBJ^e{x{Hd4x;=hr>)EUhwKmI%_(pvF4cPSHSRF+gTWy`jo8U7liD+wL_pZbNW_7*< zD-VTaKXq}c%O&QFExPYu0HKtH4ROd>j&R`-<+3K0S`Xur{)T|wW~#^La9lvh|y|{)h zJdQiLNJc4~6U?4nt+jeJn7IA4$x#`>(8Zp?2QPmunt6HD7wzqlU+l~u{65W8>OtXz$6+E>th9cc;xMAscF=uulP>)giMF{CiAjK0v8)dG^eNFMI5Lz8k6UQ1@ z$JZFd4;g2<=01^fAxG-faso$ogoZ+ItahtwYaMuaEWR&9IqsPG>Z$VO=)dY!Q(>BX z3QI~}#2WmSq)s|T!z;H0SqU3RWnVm%Wl-60eb+sa>F)E$k=aOCAF{P>q91T$x?#=K z_WP4S)#Watr_FIY!z{Y==x6C8gOWpQMg7@9U7jm`g3GQf0}qF-79$Qm-Q?P0(X(t$7AKx z0ekfKs1=_LGfT}e2caXIokERg$!0sLLxI8bCyC_s5UBO-#4wsx@{06jzxxe}d7^F2 z{PIy-NkU{hlaoJE6Z=j~p;L;at8bUw<)1Z~Av{Y?6%|2U(k! z{tSLJOyD9upemmDou`iH?}b=V#*bjE)_F2Y)d}+R<)|@^Hv%1=l;5~}+zc=(#cC9s zzI&oUEEc^KCHI!S$tm`50M(SUDp9blr%w%LJxhn5-A#*J5mb3mm}#xiBRD$wJn@1= z4Af14&j??(d9z4#>t?0t$eRfcU6iLSS8VPvwZ>SzK zoI)VCTFqeVtDs2eElw)CvNE<^s*znN8;|t4-k3Lp+M&Q99#5atQ=&_=XR^Ism`Q3O z)aJK3jwJ-&X3xRg#K_8xbtL@6#`TIY`=X#x1W@9St|CF~G+o6{$v5KVcw^L^G51B6 zlEbS$GhZ2uKhHoYZNKs?LsE(iZ^dmiweskFejo-!DQ7)vw=8SCHM@JI6RK%1f%KI2 zVObA8yV*ufReQTp=(=ov7l@Q3zFGWg3Qta=L`Oa5!?qTG zqULru0^|UUZGP95xIZVhie(n!&e$!R9%FH)#3R7FSQg08vaqqHj?w~bqhhN2qF0Kx zNFA>oeBz-}d+W!gjIk~OGDytJm$G6QHJ%k7S{VmdGP^lJwaBJUb07QuxXYtjQr)(R z#AQNhmFcJJ*|}GfAA*dx2gD`(s2PXE_T>ZD#SGF95tivr0_cxKJmb>6JkAfOoGZs< zL(EMK+~B4^!`$v<uClf&i5kc3Lh|uto)j!Q zP2^4+&!Kerm~t0woAa5stpugY5>u~MNR|S_W|)V+iDLqn+l42_ItWf9Kd8>gmhzk# zG~ChcCDQHidjib_Wdr3t*j8K2p}Np2wyZj6#KU!_1n!QSWO>)Q`Ed=6e3n9Kcdwnl zPjYU#o&BG!_V`m%b#O?ilD80by|sj1JOT= zXXmKhz_v5z#{L@w;UPW@OVk3 zGKXx{1r^HeLSosMH}=Ga504zbL9fFdA}XOR%)Wm57{1;QUr6;NZN&Bn%B)g9V=hDA zcDQ;r=GJm~`nmCKgZY<=B0^)K;GKV(L!3*~k zhJ^DkO1u|_Wi`^SY>sx?gI5we&On9>-Ju{ADF&mo4k+gP{WC%PER})#2#(J^sUKc* z#~WaIXI{+kXf^M2NOy>TI(NVvtMJ*4-&@acEZy!GTI#W7!vHo(+bP(`V0B3NHj?r7YRLF{f?mzH zf`B!eJ4tBKGk(x_*6}k) z$LnS}{k&|$H7jOW=%7{|>hSe1rnLYxOL8%R0R}aZxk?!=`(Oeh1s|#aQRt5U6qxS= zZ&q-{*JC=~=CtLuNs9}%)b3ovp^&+IE;YYMfxhuR_X)6XQxaNcm8#L}c(hz)y?*34 z(ni7##k|=SyUwKk1C#aUoRZb%?_-Nye8=zK{bX$sTN{QNDlLa!oTZ4@*b`xRTP`V; zEQ&(Ae4J{nQbD?@PalochJFE|7=greJY^EkCb4ZpRzOO>nn_)K9YDJHnD&Ou?Mp%0 zfxM9j&K7jh{F%Y7pd-`r+B?Ic#TVUtjK>XR!`D65qz`5e-micea3wA)dYdWfDS@PrI!yEh~%M`o8nly?h{GwRhTbz$^Qf_rC!xOoSQ+BLfZ zgquMzj9K)j-aH9}n^I@vMMy?mt#mXisrw>+khyRZE!WBcI2RwZ$F-rnLX+Q4`R?6+ z75{#H?F>Lakw5CmY7-6~oNB^nJ{~KwYNV#9H%*OH>Mwp2PXnPbce_YW>59v+lqMZn zk;!k6t^ja9-jL_2)GE2|hj>WY?LOw_j(sq@r7N&0KbIJ6u}9cgSoi$^Ct=OSlx3AZ zbz#?)y5Y-6g;#h1v1q=bFvTJ)hQgvD?UdLZdZk(;S_^H#$Lu- zg(Lhx$8pANHNqGBqpK2YWBm)ko5<+{fG$+5q4%6HUw)L)Akpu|gr_C>NKjt$A$9VJ4rFt1&lwU8CrD-i;apD(cxs&79`8HgF5db+Fg#&;v;{c4F}t^uJ-`*f_mf6ctS!xw`1>4#S>q+F)Nf8U_yu_NtqxX3SgyYDVO5U`|jCE zmgr{@7%d=ARNu?S=Eo$0$>sk4U9!J)PA<^ z9;nb|xF`iqr)Ab~If^oVHZTq0V?DZ$)&X#L;s@_*eLGa7#&WS&qz|7VZJ0_6v*DO#c6koKf8 zCd%2wcGNhh+#0MpIxBr|l*>plcv5VQwjk5SvdMdto4Yx%a0n=?FP=ZtXO10qDaA>? zos4Y_L}e96FciEc6Okr761wD=x1q-Z|GmnLcg2XJzsqI2^kjvwZEQGSsKlsO2vVAn ze#hGkO%KA`8XFZuEUrV$9GLo(!-DRRnb-|xgsus)9c(rCd!>6oTBM+6K<+l0$h&v4()6Sgd&>7Nu#HQkjcwM(1j>DJ^K;?T+X%A2cH|M;DD$vReg(w(E1jm$(8*?r#U*~z{-!@w%7lYI^XMq$s z7ZEyCcjLc=E>5jK{)nR_)~8i5oMf}&<+=1(HZ>D)acPX(vHH`;dO4F* zF@h@9iF)y=D#&UY2{hv=jwEiNni)ETwM;{T!}U0T&ha90hbq(Wnm{V3FY8rXOufcw zQ0`dqT3dO(ir_8mccCXHsStJ93z~bk>;uB4EApb>a!o?Z3&m}!^LgmSlasThGhjj_ zy=0U5V@u7bELik_snd<7i3?FD>80D#1FKwpp+afF$5@O7*59ax|DRJ4E$QS@pZ|Ze? zQ?T=!e9Zu+Z< $junctionTarget" - } -} else { - $parentDir = Split-Path $pluginManagerPath -Parent - if (!(Test-Path $parentDir)) { New-Item -ItemType Directory -Path $parentDir -Force | Out-Null } - New-Item -ItemType Junction -Path $pluginManagerPath -Target $junctionTarget | Out-Null - Write-Output "[2/3] 创建联接: $pluginManagerPath -> $junctionTarget" -} - -Write-Output "[3/3] 修复完成!" -Write-Output "" -Write-Output "请重启 VS Code / IDE 使更改生效" diff --git a/scripts/patch_pub_cache.sh b/scripts/patch_pub_cache.sh deleted file mode 100755 index 42c3b4e9..00000000 --- a/scripts/patch_pub_cache.sh +++ /dev/null @@ -1,318 +0,0 @@ -#!/bin/bash -# ============================================================ -# patch_pub_cache.sh — 修补 pub cache 中的兼容性问题 -# 创建时间: 2026-06-01 -# 更新时间: 2026-06-01 -# 作用: 修补 quill_native_bridge_windows (win32 6.x) 和 flutter_vibrate (Swift 6) -# 上次更新: 初始创建 -# ============================================================ - -set -e - -echo "🔧 Applying pub cache patches..." - -# ── Patch 1: quill_native_bridge_windows — clipboard_html_format.dart ── -QNBW_DIR="$HOME/.pub-cache/hosted/pub.dev/quill_native_bridge_windows-0.0.2" -if [ -d "$QNBW_DIR" ]; then - echo " Patching clipboard_html_format.dart..." - cat > "$QNBW_DIR/lib/src/clipboard_html_format.dart" << 'DART_EOF' -import 'package:ffi/ffi.dart'; -import 'package:win32/win32.dart'; - -import '../quill_native_bridge_windows.dart'; - -const _kHtmlFormatName = 'HTML Format'; - -int? _cfHtml; - -extension ClipboardHtmlFormatExt on QuillNativeBridgeWindows { - int? get cfHtml { - _cfHtml ??= _registerHtmlFormat(); - return _cfHtml; - } - - int? _registerHtmlFormat() { - final htmlFormatPointer = _kHtmlFormatName.toPcwstr(); - final result = RegisterClipboardFormat(htmlFormatPointer); - free(htmlFormatPointer); - - if (result.error.isError) { - return null; - } - return result.value; - } -} -DART_EOF - echo " ✅ clipboard_html_format.dart patched" -else - echo " ⚠️ quill_native_bridge_windows not found in pub cache" -fi - -# ── Patch 2: quill_native_bridge_windows — quill_native_bridge_windows.dart ── -if [ -d "$QNBW_DIR" ]; then - echo " Patching quill_native_bridge_windows.dart..." - cat > "$QNBW_DIR/lib/quill_native_bridge_windows.dart" << 'DART_EOF' -// Patched for win32 6.x compatibility (2026-06-01) -// Changes: Win32Result API, HGLOBAL/HANDLE extension types, TEXT() → toPcwstr() - -import 'dart:ffi'; -import 'dart:io'; - -import 'package:ffi/ffi.dart'; -import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; -import 'package:flutter/foundation.dart'; -import 'package:quill_native_bridge_platform_interface/quill_native_bridge_platform_interface.dart'; -import 'package:win32/win32.dart'; - -import 'src/clipboard_html_format.dart'; -import 'src/html_cleaner.dart'; -import 'src/html_formatter.dart'; -import 'src/image_saver.dart'; - -class QuillNativeBridgeWindows extends QuillNativeBridgePlatform { - static void registerWith() { - QuillNativeBridgePlatform.instance = QuillNativeBridgeWindows(); - } - - @override - Future isSupported(QuillNativeBridgeFeature feature) async => { - QuillNativeBridgeFeature.getClipboardHtml, - QuillNativeBridgeFeature.copyHtmlToClipboard, - QuillNativeBridgeFeature.saveImage, - }.contains(feature); - - @override - Future getClipboardHtml() async { - final openResult = OpenClipboard(null); - if (openResult.error.isError) { - assert(false, 'Unknown error while opening the clipboard. Error code: ${GetLastError()}'); - return null; - } - - try { - final htmlFormatId = cfHtml; - if (htmlFormatId == null) { - assert(false, 'Failed to register clipboard HTML format.'); - return null; - } - - final availableResult = IsClipboardFormatAvailable(htmlFormatId); - if (availableResult.error.isError) { - return null; - } - - final getDataResult = GetClipboardData(htmlFormatId); - if (getDataResult.error.isError) { - assert(false, 'Failed to get clipboard data. Error code: ${GetLastError()}'); - return null; - } - - final clipboardDataHandle = getDataResult.value; - final hglobal = HGLOBAL(clipboardDataHandle); - final lockResult = GlobalLock(hglobal); - if (lockResult.error.isError) { - assert(false, 'Failed to lock global memory. Error code: ${GetLastError()}'); - return null; - } - - final lockedMemoryPointer = lockResult.value; - final windowsHtmlWithMetadata = lockedMemoryPointer.cast().toDartString(); - GlobalUnlock(hglobal); - - final cleanedHtml = stripWindowsHtmlDescriptionHeaders(windowsHtmlWithMetadata); - return cleanedHtml; - } finally { - CloseClipboard(); - } - } - - @override - Future copyHtmlToClipboard(String html) async { - final openResult = OpenClipboard(null); - if (openResult.error.isError) { - assert(false, 'Unknown error while opening the clipboard. Error code: ${GetLastError()}'); - return; - } - - final windowsClipboardHtml = constructWindowsHtmlDescriptionHeaders(html); - final htmlPointer = windowsClipboardHtml.toNativeUtf8(); - - try { - final emptyResult = EmptyClipboard(); - if (emptyResult.error.isError) { - assert(false, 'Failed to empty the clipboard. Error code: ${GetLastError()}'); - return; - } - - final htmlFormatId = cfHtml; - if (htmlFormatId == null) { - assert(false, 'Failed to register clipboard HTML format. Error code: ${GetLastError()}'); - return; - } - - final unitSize = sizeOf(); - final htmlSize = (htmlPointer.length + 1) * unitSize; - - final allocResult = GlobalAlloc(GMEM_MOVEABLE, htmlSize); - if (allocResult.error.isError) { - assert(false, 'Failed to allocate memory for the clipboard content. Error code: ${GetLastError()}'); - return; - } - - final clipboardMemoryHandle = allocResult.value; - final lockResult = GlobalLock(clipboardMemoryHandle); - if (lockResult.error.isError) { - GlobalFree(clipboardMemoryHandle); - assert(false, 'Failed to lock global memory. Error code: ${GetLastError()}'); - return; - } - - final lockedMemoryPointer = lockResult.value; - final targetMemoryPointer = lockedMemoryPointer.cast(); - final sourcePointer = htmlPointer.cast(); - - for (var i = 0; i < htmlPointer.length; i++) { - targetMemoryPointer[i] = (sourcePointer + i).value; - } - (targetMemoryPointer + htmlPointer.length).value = 0; - - GlobalUnlock(clipboardMemoryHandle); - - final setResult = SetClipboardData(htmlFormatId, HANDLE(clipboardMemoryHandle)); - if (setResult.error.isError) { - GlobalFree(clipboardMemoryHandle); - assert(false, 'Failed to set the clipboard data: ${GetLastError()}'); - } - } finally { - CloseClipboard(); - calloc.free(htmlPointer); - } - } - - @visibleForTesting - static ImageSaver imageSaver = ImageSaver(); - - @override - Future saveImage(Uint8List imageBytes, {required ImageSaveOptions options}) async { - final typeGroup = XTypeGroup(label: 'Images', extensions: [options.fileExtension]); - final saveLocation = await imageSaver.fileSelector.getSaveLocation( - options: SaveDialogOptions( - suggestedName: '${options.name}.${options.fileExtension}', - initialDirectory: imageSaver.picturesDirectoryPath, - ), - acceptedTypeGroups: [typeGroup], - ); - final imageFilePath = saveLocation?.path; - if (imageFilePath == null) { - return ImageSaveResult.io(filePath: null); - } - final imageFile = File(imageFilePath); - await imageFile.writeAsBytes(imageBytes); - return ImageSaveResult.io(filePath: imageFile.path); - } - - @override - Future openGalleryApp() async { - final uriPtr = 'ms-photos:'.toPcwstr(); - final openPtr = 'open'.toPcwstr(); - ShellExecute(null, openPtr, uriPtr, null, null, SW_SHOWNORMAL); - free(uriPtr); - free(openPtr); - } -} -DART_EOF - echo " ✅ quill_native_bridge_windows.dart patched" -fi - -# ── Patch 3: flutter_vibrate — SwiftVibratePlugin.swift ── -FV_DIR=$(find "$HOME/.pub-cache/git" -path "*fluttertpc_flutter_vibrate*" -maxdepth 2 -type d 2>/dev/null | head -1) -if [ -n "$FV_DIR" ]; then - echo " Patching SwiftVibratePlugin.swift..." - cat > "$FV_DIR/ios/Classes/SwiftVibratePlugin.swift" << 'SWIFT_EOF' -import Flutter -import UIKit -import AudioToolbox - -// Patched for Xcode 16+/Swift 6 compatibility (2026-06-01) -// TARGET_OS_SIMULATOR removed; using #if targetEnvironment(simulator) - -#if targetEnvironment(simulator) -private let isDevice = false -#else -private let isDevice = true -#endif - -public class SwiftVibratePlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "vibrate", binaryMessenger: registrar.messenger()) - let instance = SwiftVibratePlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch (call.method) { - case "canVibrate": - if isDevice { result(true) } else { result(false) } - case "vibrate": - AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) - case "impact": - if #available(iOS 10.0, *) { - let impact = UIImpactFeedbackGenerator() - impact.prepare() - impact.impactOccurred() - } else { AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } - case "selection": - if #available(iOS 10.0, *) { - let selection = UISelectionFeedbackGenerator() - selection.prepare() - selection.selectionChanged() - } else { AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } - case "success": - if #available(iOS 10.0, *) { - let notification = UINotificationFeedbackGenerator() - notification.prepare() - notification.notificationOccurred(.success) - } else { AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } - case "warning": - if #available(iOS 10.0, *) { - let notification = UINotificationFeedbackGenerator() - notification.prepare() - notification.notificationOccurred(.warning) - } else { AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } - case "error": - if #available(iOS 10.0, *) { - let notification = UINotificationFeedbackGenerator() - notification.prepare() - notification.notificationOccurred(.error) - } else { AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } - case "heavy": - if #available(iOS 10.0, *) { - let generator = UIImpactFeedbackGenerator(style: .heavy) - generator.prepare() - generator.impactOccurred() - } else { AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } - case "medium": - if #available(iOS 10.0, *) { - let generator = UIImpactFeedbackGenerator(style: .medium) - generator.prepare() - generator.impactOccurred() - } else { AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } - case "light": - if #available(iOS 10.0, *) { - let generator = UIImpactFeedbackGenerator(style: .light) - generator.prepare() - generator.impactOccurred() - } else { AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } - default: - result(FlutterMethodNotImplemented) - } - } -} -SWIFT_EOF - echo " ✅ SwiftVibratePlugin.swift patched" -else - echo " ⚠️ flutter_vibrate not found in pub cache" -fi - -echo "🎉 All patches applied!" -echo "⚠️ Note: Run this script again after 'flutter clean' or 'flutter pub cache repair'" diff --git a/scripts/resize_icon.py b/scripts/resize_icon.py deleted file mode 100644 index 5fabdd68..00000000 --- a/scripts/resize_icon.py +++ /dev/null @@ -1,102 +0,0 @@ -# -*- coding: utf-8 -*- -""" -@创建时间: 2026-05-16 -@更新时间: 2026-05-16 -@名称: resize_icon.py -@作用: 将源图片裁剪为从16x16到1024x1024的常用尺寸图标 -@上次更新内容: 初始创建 -""" - -import os -from PIL import Image - -SOURCE_IMAGE = os.path.join(os.path.dirname(__file__), "..", "assets", "templates", "xianyan.png") -OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "..", "assets", "templates", "resized") - -SIZES = [ - 16, - 20, - 24, - 29, - 32, - 40, - 48, - 50, - 57, - 58, - 60, - 64, - 72, - 76, - 80, - 87, - 88, - 96, - 100, - 114, - 120, - 128, - 144, - 152, - 167, - 180, - 192, - 216, - 256, - 512, - 1024, -] - -SIZE_CATEGORIES = { - "favicon": [16, 32], - "ios_notification": [20], - "ios_settings": [29], - "ios_spotlight": [40], - "ios_app": [60, 76, 87, 120, 152, 167, 180, 1024], - "android_launcher": [48, 72, 96, 144, 192, 216], - "web_app": [64, 128, 256, 512], - "general": [24, 50, 57, 58, 80, 88, 100, 114], -} - - -def resize_image(source_path: str, output_dir: str, size: int) -> str: - img = Image.open(source_path) - if img.mode != "RGBA": - img = img.convert("RGBA") - - resized = img.resize((size, size), Image.LANCZOS) - filename = f"icon_{size}x{size}.png" - filepath = os.path.join(output_dir, filename) - resized.save(filepath, "PNG", compress_level=0) - return filename - - -def main(): - source_path = os.path.normpath(SOURCE_IMAGE) - output_dir = os.path.normpath(OUTPUT_DIR) - - if not os.path.exists(source_path): - print(f"[ERROR] Source image not found: {source_path}") - return - - os.makedirs(output_dir, exist_ok=True) - - print(f"Source: {source_path}") - print(f"Output: {output_dir}") - print(f"Sizes: {len(SIZES)} variants\n") - - for size in SIZES: - filename = resize_image(source_path, output_dir, size) - print(f" [OK] {filename}") - - print(f"\nDone! {len(SIZES)} icons generated in: {output_dir}") - - print("\n--- Size Category Reference ---") - for category, sizes in SIZE_CATEGORIES.items(): - matching = [s for s in sizes if s in SIZES] - if matching: - print(f" {category}: {', '.join(f'{s}x{s}' for s in matching)}") - - -if __name__ == "__main__": - main() diff --git a/scripts/test_feed_weight_api.py b/scripts/test_feed_weight_api.py deleted file mode 100644 index 49001a98..00000000 --- a/scripts/test_feed_weight_api.py +++ /dev/null @@ -1,352 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -@name Feed权重管理API测试脚本 -@author AI Coder -@date 2026-06-08 -@desc 测试Feed权重管理系统的install/sync和channels接口,验证44种分类全部可管理 -@update 初始创建 -""" - -import json -import sys -import time - -try: - import requests -except ImportError: - print("❌ 需要安装requests库: pip3 install requests") - sys.exit(1) - -# ============ 配置 ============ -BASE_URL = "https://tools.wktyl.com" -SYNC_TOKEN = "xianyan_feed_sync_2026" -TIMEOUT = 30 - -# 全部44种数据源类型 -ALL_CATEGORIES = [ - "poetry", "wisdom", "story", "hitokoto", "riddle", "efs", "brainteaser", - "saying", "lyric", "why", "composition", "couplet", "cs", "drug", "herbal", - "food", "wine", "article", "chengyu", "hanzi", "cidian", "prescription", - "tisana", "joke", "zgjm", "lunyu", "hdnj", "jgj", "mz", "zz", "zuozhuan", - "sj", "sgz", "sbbf", "warring", "illness", "word", "abbr", "surname", - "jieqi", "nation", "wlyh", "jiufang", "bot" -] - - -def print_header(title): - """打印分节标题""" - print(f"\n{'='*60}") - print(f" {title}") - print(f"{'='*60}") - - -def print_result(name, success, detail=""): - """打印测试结果""" - icon = "✅" if success else "❌" - print(f" {icon} {name}", end="") - if detail: - print(f" — {detail}") - else: - print() - - -def test_install_endpoint(): - """测试 install/sync 端点 """ - print_header("1. 测试 install 端点 (同步全部分类)") - - url = f"{BASE_URL}/api/feed/install" - params = {"token": SYNC_TOKEN} - - try: - resp = requests.get(url, params=params, timeout=TIMEOUT) - data = resp.json() - print(f" 📡 响应状态码: {resp.status_code}") - - if data.get("code") == 1: - result = data.get("data", {}) - added = result.get("added", 0) - updated = result.get("updated", 0) - total = result.get("total_types", 0) - categories = result.get("all_categories", []) - - print_result("install接口调用成功", True, - f"新增{added}种, 更新{updated}种, 共{total}种") - print_result("分类总数匹配", total == 44, - f"期望44, 实际{total}") - - # 检查返回的分类列表是否完整 - missing = set(ALL_CATEGORIES) - set(categories) - print_result("全部分类已包含", len(missing) == 0, - f"缺失: {missing}" if missing else "全部44种分类已包含") - - return True, result - else: - print_result("install接口调用", False, data.get("msg", "未知错误")) - return False, None - - except requests.exceptions.Timeout: - print_result("install接口调用", False, "请求超时") - return False, None - except Exception as e: - print_result("install接口调用", False, str(e)) - return False, None - - -def test_install_without_token(): - """测试无token时install端点的安全校验""" - print_header("2. 测试 install 端点安全校验 (无token)") - - url = f"{BASE_URL}/api/feed/install" - - try: - resp = requests.get(url, timeout=TIMEOUT) - data = resp.json() - - if data.get("code") == 0: - print_result("无token被拒绝", True, data.get("msg", "")) - else: - print_result("无token被拒绝", False, "应该返回错误但返回了成功") - - except Exception as e: - print_result("安全校验测试", False, str(e)) - - -def test_channels_endpoint(): - """测试 channels 端点,验证返回的分类列表""" - print_header("3. 测试 channels 端点 (频道列表)") - - url = f"{BASE_URL}/api/feed/channels" - - try: - resp = requests.get(url, timeout=TIMEOUT) - data = resp.json() - - if data.get("code") == 1: - channels = data.get("data", {}).get("channels", []) - channel_keys = [ch["key"] for ch in channels] - - print(f" 📊 返回频道数: {len(channels)} (含'all'推荐频道)") - - # 检查每个分类是否在channels中 - missing = [] - for cat in ALL_CATEGORIES: - if cat not in channel_keys: - missing.append(cat) - - print_result("全部分类在channels中", len(missing) == 0, - f"缺失: {missing}" if missing else "全部44种分类可见") - - # 检查每个channel的结构 - for ch in channels: - has_required = all(k in ch for k in ["key", "name", "icon", "count"]) - if not has_required: - print_result(f"频道 {ch.get('key', '?')} 结构完整", False) - break - else: - print_result("所有频道结构完整", True, - "均包含key/name/icon/count") - - # 打印频道概览 - print(f"\n 📋 频道列表:") - for ch in channels: - icon = ch.get("icon", "") - name = ch.get("name", "") - key = ch.get("key", "") - count = ch.get("count", 0) - enabled = ch.get("is_enabled", True) - status = "✅" if enabled else "❌" - print(f" {status} {icon} {name} ({key}) — {count}条") - - return True, channels - else: - print_result("channels接口调用", False, data.get("msg", "未知错误")) - return False, None - - except Exception as e: - print_result("channels接口调用", False, str(e)) - return False, None - - -def test_weight_config_endpoint(): - """测试 weight_config 端点,验证权重配置""" - print_header("4. 测试 weight_config 端点 (权重配置)") - - url = f"{BASE_URL}/api/feed/weight_config" - - try: - resp = requests.get(url, timeout=TIMEOUT) - data = resp.json() - - if data.get("code") == 1: - result = data.get("data", {}) - config = result.get("config", []) - total_types = result.get("total_types", 0) - enabled_count = result.get("enabled_count", 0) - - print(f" 📊 配置总数: {total_types}, 启用数: {enabled_count}") - - config_types = [c["type"] for c in config] - - # 检查每个分类是否在配置中 - missing = set(ALL_CATEGORIES) - set(config_types) - print_result("全部分类有权重配置", len(missing) == 0, - f"缺失: {missing}" if missing else "全部44种分类已配置") - - # 检查配置结构 - for c in config: - required = ["type", "name", "icon", "weight", "display_weight", - "push_limit", "is_enabled"] - has_all = all(k in c for k in required) - if not has_all: - print_result(f"分类 {c.get('type', '?')} 配置完整", False) - break - else: - print_result("所有配置结构完整", True) - - # 打印权重概览 - print(f"\n 📋 权重配置 (按权重降序):") - sorted_config = sorted(config, key=lambda x: x["weight"], reverse=True) - for c in sorted_config[:10]: - icon = c.get("icon", "") - name = c.get("name", "") - weight = c.get("weight", 0) - enabled = "✅" if c.get("is_enabled") else "❌" - print(f" {enabled} {icon} {name}: 权重={weight}") - - if len(sorted_config) > 10: - print(f" ... 还有 {len(sorted_config) - 10} 个分类") - - return True, result - else: - print_result("weight_config接口调用", False, data.get("msg", "未知错误")) - return False, None - - except Exception as e: - print_result("weight_config接口调用", False, str(e)) - return False, None - - -def test_stats_endpoint(): - """测试 stats 端点""" - print_header("5. 测试 stats 端点 (统计信息)") - - url = f"{BASE_URL}/api/feed/stats" - - try: - resp = requests.get(url, timeout=TIMEOUT) - data = resp.json() - - if data.get("code") == 1: - result = data.get("data", {}) - total_content = result.get("total_content", 0) - channel_count = result.get("channel_count", 0) - channels = result.get("channels", []) - - print(f" 📊 总内容数: {total_content}, 频道数: {channel_count}") - - # 检查启用的频道数 - print_result("频道数>=44", channel_count >= 44, - f"实际{channel_count}个") - - # 检查每个分类是否有统计 - stat_types = [ch["key"] for ch in channels] - missing = set(ALL_CATEGORIES) - set(stat_types) - print_result("全部分类有统计", len(missing) == 0, - f"缺失: {missing}" if missing else "全部44种分类有统计") - - return True, result - else: - print_result("stats接口调用", False, data.get("msg", "未知错误")) - return False, None - - except Exception as e: - print_result("stats接口调用", False, str(e)) - return False, None - - -def test_idempotent_install(): - """测试install端点的幂等性(重复调用不会重复插入)""" - print_header("6. 测试 install 端点幂等性") - - url = f"{BASE_URL}/api/feed/install" - params = {"token": SYNC_TOKEN} - - try: - # 第一次调用 - resp1 = requests.get(url, params=params, timeout=TIMEOUT) - data1 = resp1.json() - - # 第二次调用 - resp2 = requests.get(url, params=params, timeout=TIMEOUT) - data2 = resp2.json() - - if data1.get("code") == 1 and data2.get("code") == 1: - result1 = data1.get("data", {}) - result2 = data2.get("data", {}) - - added1 = result1.get("added", 0) - added2 = result2.get("added", 0) - - print_result("第二次调用added应为0", added2 == 0, - f"第一次added={added1}, 第二次added={added2}") - print_result("两次调用total一致", - result1.get("total_types") == result2.get("total_types"), - f"均为{result1.get('total_types')}") - else: - print_result("幂等性测试", False, "接口返回错误") - - except Exception as e: - print_result("幂等性测试", False, str(e)) - - -def main(): - """主测试流程""" - print("🚀 Feed权重管理系统 — 全44分类扩展测试") - print(f" 目标服务器: {BASE_URL}") - print(f" 测试时间: {time.strftime('%Y-%m-%d %H:%M:%S')}") - print(f" 期望分类数: {len(ALL_CATEGORIES)}") - - results = {} - - # 1. 测试install端点 - ok, install_result = test_install_endpoint() - results["install"] = ok - - # 2. 测试安全校验 - test_install_without_token() - - # 3. 测试channels端点 - ok, channels_result = test_channels_endpoint() - results["channels"] = ok - - # 4. 测试weight_config端点 - ok, weight_result = test_weight_config_endpoint() - results["weight_config"] = ok - - # 5. 测试stats端点 - ok, stats_result = test_stats_endpoint() - results["stats"] = ok - - # 6. 测试幂等性 - test_idempotent_install() - - # 汇总 - print_header("测试结果汇总") - total = len(results) - passed = sum(1 for v in results.values() if v) - for name, ok in results.items(): - print_result(name, ok) - - print(f"\n 📊 通过率: {passed}/{total}") - - if passed == total: - print("\n 🎉 全部测试通过!44种分类权重管理已就绪。") - else: - print(f"\n ⚠️ 有 {total - passed} 项测试未通过,请检查。") - - return 0 if passed == total else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/upload_agreements.py b/scripts/upload_agreements.py deleted file mode 100644 index 394e8527..00000000 --- a/scripts/upload_agreements.py +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env python3 -"""上传协议文件到服务器""" -import paramiko -import os -import glob - -HOST = '123.207.67.197' -PORT = 22 -USER = 'root' -PASS = '520Kiss123' -REMOTE_DIR = '/www/wwwroot/tools.wktyl.com/public/agreements/' -LOCAL_DIR = os.path.join(os.path.dirname(__file__), '..', 'docs', 'toolsapi', 'public', 'agreements') - -def main(): - local_dir = os.path.abspath(LOCAL_DIR) - html_files = glob.glob(os.path.join(local_dir, '*.html')) - print(f"找到 {len(html_files)} 个协议文件待上传") - - ssh = paramiko.SSHClient() - ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - ssh.connect(HOST, port=PORT, username=USER, password=PASS, timeout=15) - sftp = ssh.open_sftp() - - # 确保远程目录存在 - try: - sftp.stat(REMOTE_DIR) - except FileNotFoundError: - sftp.mkdir(REMOTE_DIR) - - for f in html_files: - fname = os.path.basename(f) - remote_path = REMOTE_DIR + fname - print(f" 上传: {fname} -> {remote_path}") - sftp.put(f, remote_path) - - sftp.close() - ssh.close() - print(f"✅ 全部 {len(html_files)} 个文件上传完成") - -if __name__ == '__main__': - main() diff --git a/scripts/verify_root_cause.py b/scripts/verify_root_cause.py deleted file mode 100644 index 5f137566..00000000 --- a/scripts/verify_root_cause.py +++ /dev/null @@ -1,120 +0,0 @@ -# ============================================================ -# 闲言APP — 句子卡片数据永不更新根因验证 -# 创建时间: 2026-05-01 -# 作用: 验证 fetchMix 接口在相同参数下是否返回相同数据 -# ============================================================ - -import requests -import json -import time - -BASE = "https://tools.wktyl.com" - -def fetch_mix(mode="random", limit=5, channels=None, sort="hottest"): - params = {"mode": mode, "limit": limit, "sort": sort} - if channels: - params["channels"] = ",".join(channels) - try: - r = requests.get(f"{BASE}/api/feed/mix", params=params, timeout=10) - data = r.json() - if data.get("code") == 1: - items = data.get("data", {}).get("list", []) - return items - except Exception as e: - print(f" ❌ fetchMix 请求失败: {e}") - return [] - -print("=" * 70) -print("根因验证: fetchMix 默认 sort=hottest 是否导致数据固定") -print("=" * 70) - -# 测试1: 默认 sort=hottest 连续3次 -print("\n📌 测试1: sort=hottest (默认) 连续3次请求") -all_ids_hottest = [] -for i in range(3): - items = fetch_mix(mode="random", limit=5, sort="hottest") - ids = [f"{item.get('feed_type','?')}_{item.get('id')}" for item in items] - titles = [item.get("title", "")[:25] for item in items] - all_ids_hottest.extend(ids) - print(f" 第{i+1}次: ids={ids}") - for t in titles: - print(f" → {t}") - time.sleep(0.3) - -unique_hottest = len(set(all_ids_hottest)) -total_hottest = len(all_ids_hottest) -print(f" 📊 总ID数={total_hottest}, 去重后={unique_hottest}, 重复率={1-unique_hottest/total_hottest:.1%}") - -# 测试2: sort=newest 连续3次 -print("\n📌 测试2: sort=newest 连续3次请求") -all_ids_newest = [] -for i in range(3): - items = fetch_mix(mode="random", limit=5, sort="newest") - ids = [f"{item.get('feed_type','?')}_{item.get('id')}" for item in items] - titles = [item.get("title", "")[:25] for item in items] - all_ids_newest.extend(ids) - print(f" 第{i+1}次: ids={ids}") - for t in titles: - print(f" → {t}") - time.sleep(0.3) - -unique_newest = len(set(all_ids_newest)) -total_newest = len(all_ids_newest) -print(f" 📊 总ID数={total_newest}, 去重后={unique_newest}, 重复率={1-unique_newest/total_newest:.1%}") - -# 测试3: 对比 hottest vs newest -print("\n📌 测试3: hottest vs newest 对比") -hottest_items = fetch_mix(mode="random", limit=5, sort="hottest") -newest_items = fetch_mix(mode="random", limit=5, sort="newest") -hottest_ids = set(f"{item.get('feed_type','?')}_{item.get('id')}" for item in hottest_items) -newest_ids = set(f"{item.get('feed_type','?')}_{item.get('id')}" for item in newest_items) -overlap = hottest_ids & newest_ids -print(f" hottest: {hottest_ids}") -print(f" newest: {newest_ids}") -print(f" 重叠: {overlap}") - -# 测试4: 客户端当前代码使用的参数 -print("\n📌 测试4: 模拟客户端当前代码 (mode=random, limit=5, 无sort参数)") -all_ids_client = [] -for i in range(5): - items = fetch_mix(mode="random", limit=5) - ids = [f"{item.get('feed_type','?')}_{item.get('id')}" for item in items] - all_ids_client.extend(ids) - print(f" 第{i+1}次: ids={ids}") - time.sleep(0.3) - -unique_client = len(set(all_ids_client)) -total_client = len(all_ids_client) -print(f" 📊 总ID数={total_client}, 去重后={unique_client}, 重复率={1-unique_client/total_client:.1%}") - -# 测试5: 检查 FeedMixConfig.toQueryParameters 是否传了 sort -print("\n📌 测试5: FeedMixConfig.toQueryParameters 分析") -print(" 客户端代码 (feed_model.dart):") -print(" Map toQueryParameters() {") -print(" final params = {'mode': mode, 'limit': limit};") -print(" if (channels.isNotEmpty) params['channels'] = channels.join(',');") -print(" if (mode == 'ratio' && ratios.isNotEmpty) params['ratios'] = jsonEncode(ratios);") -print(" if (mode == 'group') params['group_size'] = groupSize;") -print(" return params;") -print(" }") -print(" ⚠️ 注意: toQueryParameters 没有传 sort 参数!") -print(" ⚠️ 后端默认 sort=hottest,按浏览量排序") -print(" ⚠️ hottest 模式下热门内容固定,导致每次返回相同数据!") - -# 结论 -print("\n" + "=" * 70) -print("🔍 根因分析") -print("=" * 70) -print() -print("问题: 句子卡片5个句子循环重复,永远不会出现新的") -print() -print("根因: FeedMixConfig.toQueryParameters() 没有传 sort 参数,") -print(" 后端默认 sort=hottest(按浏览量排序),") -print(" 热门内容排名稳定,导致每次请求返回相同的高浏览量句子。") -print() -print("修复方案: FeedMixConfig 添加 sort 字段,默认值 'random' 或 'newest',") -print(" toQueryParameters() 传入 sort 参数。") -print() -print("验证: 如果 hottest 重复率远高于 newest,则确认根因。") -print(f" hottest 重复率: {1-unique_hottest/total_hottest:.1%}") -print(f" newest 重复率: {1-unique_newest/total_newest:.1%}") diff --git a/scripts/xianyan.mobileprovision b/scripts/xianyan.mobileprovision deleted file mode 100644 index 5d42cc1a4c9c17241f011aa40a49107c2c00f889..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12666 zcmd6OcbpsLwYImotlP#E8w0lGLS64_RCi@;!!(UX(nuOfqh@e2nvq6jR8|^kB!%V$ zY+@To93VJ!43~s*LkSQFA#n)(2FDn<6bA@Bp92>R#E{E}?-^-})|fB3e|)*uKhMva zciwr=Th4jTbIutfBZlgMnEHXfMMF}?GK5rc$`7=+G_4#O;@XWG=hxm}&dPMv~` zEWKc&TgMF}OO@`CrNz~!*K)G1nkbdih5VXjh5=-mE|C`sVmdFaS;htJ{X@%6UpsU4 zQo@S|qdrnsl+$HJ=i@Lpjq8^6>-A`{C@1uKB1q_bZaNUu!5Dfy=~<>*mQs}BD!pD+ z)d61XH6RpnTF0{9S1J?}C8h3$!TRBa0Z|c`ftc3llcoc$Vp>qv&g@y8P1M(dIGXVA zxx{L{b_=a#r38~*8%gu|I-g&yAHARGFQ)}wf#uO+BCn*A=|ss_N+i>@@e%k^iEkgu z60&(h9A|Y|+DFrmchdypr9^X3f>$P{BErpDBVx4nBkQY!RTXkFk_eJ+2F+5a zfg^EM#r+MYMmA7CCV4^_T43<3T@|RBOp|gYS~u752I_1*iw03S6l5@Sgb30`hKQ<6 zkgj`z(HavfXl0^h zlw+Y#Fy{6INshtj)-y?U$EmE8$fe|XUQWex!q?YH(-@sO^n6G=ih6OB9YW!ZF9E@+RJ2;YTRp(sd(9*9rh&Zh9Ng;qlVl*eppZ_ zRBx3`oCnJgn2jfCS6+?g`lRx3X*f~})rAnNH`D}WNS_WVjj+L=CzPVYm(N(e5>vOD z5`@{1ji~huQ%WntNM$%tO8E$jSr&bfKq?(d8-^%Pt(5a)Geu&{aG?`BT^MjVphlr4R_8j z3mLT5Hl~V3qcZRCBLY#Vx{bDsU_vX=2&Tp7}Wzff%?sw zUWzmtfZv=dNu*!XFM^FaU4E(;+V%A{hfa}5(?WH6JL8dM0>+tf#P2t$)YB!)6LW=2&~jk+ez5p+#$%E2-A zFwX!VVGhk_6X!2@0B79Lp8(y!P}0bV!+6l1=jww&L~;r4BvBhC z;-m%j@`HX3=tvqbSN(L7lCWsbUrrjtY;w?+9#UL+y(tol*boKf=zy#brclZuVrG5P z7RslqnV~!vx0UQHF(lR-*?c*W_OW8w<4AF0K+Y8V>_aA#KI$dYNZj0r`fYjK8X|Hf zzagwoRV7vo*#gNvE*fGGecVwHq*|VgNA;2z5#wae5@6#ekW5BvL?)HVAX!IF zaRhQ@9xM4hiGb{ulEJiy*20R<bWfw50bnX6L0cB1X(CEcIBHM<^;AbF2j0LaSK|_}31AHbngYGI z6Fe2l@SshAggyfGd!u24OrWBf58EpVxEM_ZW$>Qi5En;kMKQw=2Fj>fYWI*pC$kOH^3k@`l;ds)<=R;-vP%2hP8U0DD zj4<`#VF~f|Iir+NQ}dMqkqM*=R7CSSF#(RFx$j7v0uKJqfG za)dI?^8vO31H3>aCTpIbA!)`P_Eh76anGEM!LvAUXqZnW zo{MGNdC`NZ@$>h=4V%J7Rsubrl)SL zS=PM#_@1XNh^F_4uK!S7^-C(88+iM%F<=f+Q>YFe(K6cBwwGc(ZVG|dLPY{o@~|%k z0dKCW;m)Tla1}Lg9Sxf+f-#j6avXRQ)eE+YxM7b3V;T0Q20EcJ8dUYNYM3{MEK%so zht1l)fk*c=+~ILKNx+jytl6C|z}ZaNPb6+sE^wu?ZEV`DqxIbZ~AS;bq=C{lHXxCWX4+^GRr zB1{9Vc?r_O5HTLw8s6aKHU_MNYM`;!d>Doti-PRVYTU$1EJ8}|0OkPf$}u>ru>mc5 z5?ffnN$ezM12~h$ur`MA-;1yuQI$la4vjH1G6>jW)b28qB#MJikWez`%~m1FP%nCo zK|1fk$XY`rosn9N_jm6;4`g zF$msqYcNF)j&sgZULXVb-eqFOSQO16^Q=_;#)7TJxnc1{Ht#Pp4?GbLE3ic7%8 zTk9sl4G=tvsxg+Xp|r243T!x%DvA&-#Re04B#xRd=dQ*YWSqfTkv6KT zfiYd-a<;071Gx{N3QY&8tDz!&+N|hBgu$v9{Z&8qQ$#w)qfk^9d#(8jpn$31q;(V!h@6 zz&^n?Jj6f2F6>#2Nw?d@n65yiC=e{z29~R2Lphr*Ur+iQ8Qz;sDd9$lHO9!2Q>N0f zzH(MdR@{a#su#?;Qi&(?<-|OEma8hSu!7H16wC2tk-FmY8ceN*vC*tGQXzv#a%sqsQ5Z-7Rqfr}1Gbl71V!w`}qmoIx>;XSbXXBn~VTgA+$_QF7 z78z2T^H|nFvtQthz%353-+wd<1pE#7dk}0D@I}@#zJ5f4B&C<3DK}j7cx-5dn2;sa zL<6(5$%5<;rVy0E@stdimPyeFBGK1c?H&unnQ}aUIko+ZnMUK(bkolxZ9et?mygGB2g~>}c{w{=lo+&3;bEK#pmrIB z{X=;ivoy3!{6w_@XPFwVPRdze3?v}N_MTu#Cl(}qhmG9BA( z2*}W41y;+XwQO8VMKJip**3=~NQhgpc*vKq`H4otj>MH}z~VCuIs3vk91jiV6(*%m zmON6*pw}mJ1QwXQhM}52h!L%fT}x*mWQ9zwxg{5L2<{CZ|}lAtNYUwMr3MLq((aNe&KGlpxll6TEZ9*D7vl9y+~CgE;yEMG=ZP#-&!K;2 zLPN9C=ku|6Dj{UY3s7Uls?mHE3B^1G7&{d8CW=&}rNY(-@r1(nmlDN7Nh$a9f|9O| zXS8}&E2T=a@&)2Fk*znNKsj2f6vt{|1No#d0L4BLYNUy>R%aW~YIppE(4N)fr7;+0 zwEPIlVs9_uO*{#r)ZriaP@HUkGM=E*rZ+K1zF6*0Y81&m&yaR`arE!dL1aI_!8{!cmZ}I z#dzonpw8Q>$@Ztmi?Ux^$V3_TP)Y4F+P4M@vqFkb=i#sgIIDkTC2c}ZojmN=NlxsR zQphGIZR$6ikd#pR6~3J9=ViH|PONK8fhX_A#P}0BrzH5?KejdPbJlXY?X_~UR!o<^ zxk^1aR;4zsvRGFckVC4}hbyv=o^+egrl|&Dx)as)u?0_<#n@I(Y0BaMaJQNF>U3U7NQu%~lhwRhKXx+;92`dz<8vHURwFXV zBLGwizlhr7I&-H*%}|30H*7JRT#HuNKX)4I6suGuS^A6e+q zzEmNf&`nh5!NzrHMM)Jj8#d^W#aj1Svlo0*k0X(JT928tXSN2U^TI&$D-md%c4Q|P zKo&2UW3(a$|;1#?VrXEGqoKQSa~upp+^q*hLvlsBM*qCx9J1o#|7yU{8L zxS!|bo9c9D;+aCM>;yD{G#LW&RhT1ue<$O`<+Ke$^(CX z*phhXj?&B1B8wLOXjRuu-(U3JZRk1szIeN9eYWS#pD360-iw(Yqpsbv=b}5doEjg< ztz<6!UgPQ6fj|H8=EeD2I@4cP-|v=(*7Wbc%~aX2{FzNXuf6-~!N&F@uD`~A!LWh* z;@mq=JE7taykWfeJG^J%?cePtAHF@DozZ*wvsW02&UKsbEa`r8n{=9S*Wf0O-0{Pk z-dtQfcynQ7>V-SEuT8(0QIDJ%Ma{8!b8a5^*2{N4o;{uCbY0jE54!n_)#U3h{HXia z%*o8z-+J)e+{(3gEpPlWb>$D!C&ezk^+9FchWB>rUj6dgtHq~#`~Pju_MoY2!6WB% zc6ChY*szF!qdPfJSo)JOD?vaj-D@MQ4DG24_sP6+r(rdQ}=zqE>@W>#d z*F-GoJ{dU?S#iU%8+02NkG>|9^{!3q(Mf(=y z{#9Ii@XC3}Th|^YecY^j$HU`jyjw!3osQvUYdDiilzVY!_ zN90VNNAkVTg`U+v(qnz|{`3#%yJzk=J9PveX?!6(@z!&<637Js$o z%h$2LK3v{$-FoEM*zGHX9s@te<>-yVO`fmQo1TL12UoF*K1wqx2o z$i{RrpNH&=E5$G0?|yk>_{Q}o?|Ji`S2iD0pZ|7oXZ@-N@vXa_ znELu_rw-rxYrqWxaAV~tZk#DR{kC&3@YEq&g~TUW^tj<2*>3Z2if{nkvyNQUGU9In zjQPk>TEDI%=d}9y3k5}oqZ&*Y1_2}yqro_6LcRtQ|0CG-_WPkj07 zz3V?(yy3$INLe^>;I%c46~F)CTCa_IsPf1szj^NC=uNjT`D*6I)i3w|c>4ai*#}Sg z!Li?7Hgn6zrO4EOb-Rei>bIW1`mT%L*gE^93(s2k&ZdRdXWn|O`oLYEq9yjzsb^l` zx+(WB>@Pk(@%8VnzcVrW@q4afxBl{+zvPtJJaxevryKmX%#mw3-O=eWxpS8iMP z$sevd_vMFgUO0C<|4a0&j(=OHHqKe`hrN3O2Or)0*yStlk1>}Xv)uNpmml}9-M#Zbqu(ro)F?7g2r-5kfFVPO6)}(91BRUZ|HcsQ+rJWM4j{>ZCU;5qNyrJv@*9@kuw>(+ z!{Uh6`w$p8EU-<2k&bD|x2DZ(ehCRM(s{%;;>fN$uDaxrZMvmBcV6+y-DfWSo3%fz zBb2hZZN|M<-fYJZ^FEsX)b{V+z5D%nJ-57dOI8+UoPOO2e|us3 zHSTxcdG^wKnABgk+`i)`@8)?&uibl8-|>$V_ZOtc)_JcurGL|zr|-RBvFiQcmEf)Y z?;W{er|`Srtm9lWGbb;b@%-Snv(J-G{`Sw-oc_Qa(M3aMG4`R!xey~x`Q6jUWdF1? z(BJZOX8J%11}|f*YCZ4^XrD6To~Nz_oL6< zbL1j}ox1qjYd?H+|JLQ*SM0lE^V$)4+Vul(ZNYyWUuT~4>GoYKKYXaPapCe?zP$84 zz>!}9j_hdRNJnQofJ_DG{U-s4rb9-H(O^bQRt-Qb;FxTqxBrwm{5`yQ`v*VmS@MHd z7gN4{Q$JW=zp#Iw^`@|!`^%Pv-+S!~m;BlE+m|7o?;o@Fb6?M%^U$AM^~LU|lh>@s z&iliTU%q|ZsgEwX;=0D{o2f19o}WKxdggb%%(F+OcR#hT^x0W+UpVjESMNWe>mlwk z=Bzm%is)G^HI?3mvqAmy)pUd(G?Bk+1;!=vM!JCP0dAG_gRe?o>pRY>T7 zH7lufStm?<=BkU=b@2VLuE@hz$vVDVuH+JBofbwGmDW2+cqgGA z`I4wp5~UoxkQa4AAup!2@Vl%lBz2W?V&x=3#R9ym34@;F@--q~ENLkUm>!I&w9*;_ zhnQxQ2Ayf$?(VNW@WIxr_kN;Yy}$aLig;-3p z<}FtCJg{Z?Y1EDv3M<~+xBG$O>%HWkPI~&|TY zE0)hYaN`}9l{b9n@#EI@=(pYTIpO%|J44qU{n?rmCEaFI=D?hhU9auiq5mR~SttHR zFsTPtO_e$^5xvxA_Ug*8=W$Sr6H*qiDy8S^T_Mb1kFnry)H~Jqq?pHs=RvdBa z6TOS>XRcWFTC>hmw)J)K6qE9veYorc@Z+wxdFa#N6z~niIUo4wuuky zZ`iOGIc7Y9>*+Gg8o!y|fgC@6(=*l3+YGX_Fw0~x!`I=+@`N5h-&AKka{*`K}es!IHJ9RYoe(t^QZAxXy2h8d%k6&}!=iIZmU;2f( z?!&!Xf0#P%lMB!N+g(esJ!k*v?(5CcJMm>*SFgB!(bgkYO}SIpzUQ-hHenasx$D)% zn@{WFa@^asU;g~X2j4sR(fXVI!+!0cZquUkF5dm%o(;Q4UY)}B&Hqf?|G}@$`{eg$ zy)^rRZMN53L&ay;{r-*VyU#@2Sl??;$nx&t*>mb|Uvp(#?UsADzy0Y)_Q>&%{75)3 K{9t!{`hNkTW}iU- diff --git a/scripts/xianyanWidget.mobileprovision b/scripts/xianyanWidget.mobileprovision deleted file mode 100644 index af651db314ad07c6493759f048d6cbc6afdc71a8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12405 zcmd6Nd6?VewRdL8G6~5vY$1dMCt(Q;GnRKdOhU9-mTlRVcN<8NC0mv)?~80-2xQpW zu(gyW>`Q2AA+!a`PJxsidPzxu2WScuC~a?mmHd;#ltxb0)RX0kRLHMcp&LO~Xp(upAV_&}&5BUaK5Sia%G$Z3%Ly+Y zjQL1SQI;xIjW2|`XgMLDU}5kAiZa5f ztw#7lPHkAx`pSi3vRrMtp|fH5U__`2D?m)A_i4j{r-H;+*UlXn%_f^`K^#qZxLk5n ztKP!XO0^7wT^q$UAf%#cG&K#0%Qv~aCffNMkpwYW9o3FEoa#uHc&-ZbriEm_Dy5`k z*;h`cq{d{|T)E73%P@s)o^U8+cA2_+(@r*1M}(J??M?}w zuv%wYZ9w9q+V-pQG5KV!nktlYUmL*WEG2KCOZ4R9M)|yo;!)836vA1+;q!fq!S zF3*WcVN!64lPgK>#G(_)dQw(*42MmFg@R>ERwmm{On+b$=EALIXxiQg;yC8gi>N|l zs7SM6qE&509bBDr@gciO=d#dxHi&vKFLZycpAWp3$UT zCs64+pOcYfkaRO>mO^zQ5?2)5-(nhM3-x27Cyb#52G80Ro@&T6Dc536V*_uY&d$47 z5S7D01~W#9AgyPJn8F0qQjTZn7ty?G#iqHQJ0M>%?wkPswJdWN|w_;!eo>MUo?=G!jjHPc^Z|JEAQhF zIZRjF-bh4jCjF*(Gwv=3e1mEwlsFaU%+`#x&N}p*5)`XUuwbvT_EbcMaho!Zx&sfm z+&P)d8>K2E2J~{an6>K}u^}}4#s-r!nM)0?Lg5an=JLi`RX>szB8VLohiVZcS9iPp za^BrE=skQ^G$n(cYQF5WR3fY`8^d$F2}V*V#N%muCP`JJa^7hz+H-Ytz{0t`tJXhg(Vr?!HIv%XwCj+O#sIh%7)3?mOU z^OX#yV4_&YMADA2JlL8)%TTo9W@EHViD4KUaxkciq9bWUaG+*))0R=~h;N$G9s4t7 z5_+UA7=$dJ<6G{WU*x?!Q<$dC zAn5(gc#BayfD@?SsOqIaqXGCGQbdvTtNKN-F{i5#r_*&I6GuZBLp!u^5nVE2U2v=_ zHnECM#Q;^8Ju*$%YP7>&;G&Ee(}$Y^CFi(^CoKSO#PV4YY{x`9ur#0dn`p}3;tkTwA}IQRyzLrzH09&kwQCzbq*;v$>1z9qm)3ulmg&sQE+7I3>Iszzmp{~t->-4 zTIlMVAs7s4zb8aCf?+{3g0U_WA?PY(Ro@J?42+(^3|`7oB~Wi$A6YAgrmQ4}GB{>L z6;g@0ruGqZO=-))G4=rO03Trt?RQiA&wBu8+|Zr?-M~;%&j=;FVrpa&18dK5`hYi0 zrAqOfndRaIfxyFj)o5$5slbrGX$ercyi)hmDN4j*Ie#Uk6S66bO|n*9d95KDkJ^wb z8lnTTHkd{!hkzNiDO)%%)ic(7C}AtxS;8tbTiJXiAo*CK;&G%yLO{+GhwN5^K^yau z5|S{sVt!j5H;0K_*{_Re({+&*!nQzaC=?4bh&JIU@M0rRCSqDqhzbcZX9}QS9Jt(6%H%=J;{LV7E?hn%tI-YiM64UJzx$xih?nd zp}a<`M~jLUhF{aBBAn{-Cm6ywDj4&Ei<3Io2!^FcY+r?X` zK%fnR&w({jFt)5;MVM}O&;rliVt@%aM8zAX!s$c^`i_{|>?9tg)w%8GrO_0O*Pe7> zMLySIFMeP)aX;^2GQ8pe#APzH&XXaPNCp)NOknw(4bZJ2`zg64IGn{;KHO$PVTY{5 zS;ZdG`F){mNTFGU4p9vlV;qJmOJ9pPvJC`7_TFr`w#LujQ4mS=dUrGy%h zLJ5-*&`~=M`)p6-{Y3$oVF!Uds+Uqghp`HHDpwH02`oP8A=>&N>0nHN)@|<-ZW8;k zHFpk}CLnpzk*y^s<{_D4U)MvCA@xpKpvWq$xdNySURjrp8DKoQkczYl!z!7!hgJK8 zF{<8K$b~Bj119s535iGSRd=L&|7Zr!gd^UNDFPd%541vxUsU92edIlI%n)vZCo{Fj z3hZ-&QM)5TGhqmWw*ec#s(H{s9gnw}Ld%^`o8X$*qZ+SIr}}hq$0lQG9v0b-RUQW2#7o~ULQmmXbX1TUr{J_oP}e~2^Jnm z=xqp1dN3MCL^WD)X3%UWBB(Hq&_uN0k%3JV<2gA4URq`AA{zxeq-^c=WBiOlcKUV! zPEGXfOyt0mvlRvJyrW3P83x-++7+Y$Acrt5wBaR46GOx~c-Ha;r$1vTmBXU(&UhGx zoQPiR&U(VYiY!8k?f~XsT4avFS=EcHQIDY77qBvsoyJJO#w85vVg~=S$jA{>NHpe9 zJ$);K0OrT+E+a{zI8DSvl+1avb;ueti(Y+@&bu(O(Go~!w9()^US3c__G;E^;^3RS zkWK1rUa!Y%agYIBN*hX5GfpYW=Zk53)yI%le?%l~5xd9~Qz4(5L>&HxN2f0)qq5%G zh@2M$BAkn4${|AV z`&uy_;VTDRF>|PusClaTbkY#Vv~;MMFiDgX@v@a@#9H;2&{ly$efdI}E`SZ-Ch)^( zZZaCD8O2Z=a)Y15jr)&XiMPlEgLNWgR8ayGx)RFS>H-d=Jb+edl5a7AnBr6;5|(vC z&_1El)9_Y}$oGrBry^8QaZU6c0LJakOhx8cn!&?iMscLU?*(}Ud|tDCwdiI;_L33)lx($ z1oeB!l)aEEb2#q`L|mq1j7lIjYoz4GY%LtK5vZ@N^M0no%7ZFff=CLpcA_qYjWHO9 z+TAt9P_oAuqr?^IQru8B+stMy*K7vL3>oW+2uKVg3gD|zeODx=Mt`M$G5YI7lu;F< z{Fzt;bP1vvH3D=ftipg!x?O0gfP+TorlL_kr|cI^cCZ(GPhFrqX^19kF~|^2k5>F< zzolT5(xGescS5*t_ED1G;z5}b7d)^n1BP`+D>%t^qLKY=JPwEuhbY|6Hj9}GSC?~g zxoFY~Q4dQ*BF$Q;>K7Y@s@~{^-Kmq#lo#iW`dY(iP#O(fFc*zxrI_N;Z8xhf3L$Bkavb`Ks4Vxr&LQhM4CAS#8+qBdpCbt@3DE zwe#|%uS+8YVKkZIu2PKH&)5C`Y%7!5HByahOu%`Yz@LFrGhpX*!`pe*@P-RYFhiCY zTx|O^z^QatCy*ftavM3@1dlC3W=jk?oOSTaY6daLMT8m$nX~_yKgQw=#!y(ZL&=6is~6RLaL| z4khdua{6db(u3-1NFg0F8c8HEl&clY^_(`trcskE6qGW+J$)*N3A9kA+lJ8$3J>

)nznZ-P{sufJ2(}9N zBCDBNKO#bM(aX@38!mb>rqg?}7)vV27G~;_1z8)cN>J4h2QdpxP2*0Gy@6*Af1ReB zkoADfNrV;SQHbB*w;Ilf)XN*5|PlpF$n5^)Yy%|?=T z81-Z`rFGe;CN4k9K z|0^Gl;|`YbW%6>iR1_JsLg5jd3ZQlwh4n)@95c1l4Ej`+0B4y7u1sVG+qnjefyBYY z+7m44#Db&`_;M5CWysV6mb6G6)516vFS^@-|5v$=qV^Y~<1`ux5s+EM3apv|>)5!O z#bWTOy>0hTkP?RDLLLh^QJbUS6kOUsC{zlyyq0>iktyYoN<4?uvy>L)>1LS^Vu9&% zur~ZbjOb+AIyytJ3}j%99l4-Ga1SvH4MDz|g;!&hc9jHljmALZ9GDm?6Cz~TT3>$; zLB{m>2I?gY$>KChnk`(~0Lc*!DM3OzYc?1B28R@;JWM2=jd)y;%x1GzhmJxe%xJOH znRFe?n)K-eYA6}9Vm_M9d#s@_L)!xpM_IN)QXeB21X~#Sn&CHsj%Hd{irh1XpW4R6-O#* zt_-p#%ZXD7Ggb7Ie6{8|AxxSqF;1+&q>!ni;@2eoqXOnMF;H~m)0~utMd6c`%D*zN zE|Dj8Ph@K5Q)^c(WRv;sMw98m>9d6r>u{B;WQRFfE+|v8oY?5;t*81ICTfc#)g+f2 z{+AZ2dpK&ovb#V`Hi}aDA6H;46BStfYLj`jZvP7Gfhx=6`+nduau5bRRX?7X_LMSB zsM6GucMX1ODceS(_I02E$daa&PO2qsvHN4S+8mT}$zZ|VWelTIzM2%1<+TR0aa22T zGj3*~5KT;uaa@6Q0>|5odfhOeLM+2ZPA?2w^(pgk!pK`vx>Ul5Sgreu4R;OFu3k*9yX}109m$ZzTS-J^j4kCWHRaFi{=~P&Y(lu|KdnYXF?2}L7g;h zP~LzB(&C*`JDfyNZ`2&QCLoDa168TsR*rsVIomfx5aUAp+{)qS^qXX)?nK+oF!#ru8hvjgw`uzK0x zy_n%?>V};=&)<5{iHVWiNzA3+ZoP0&;E%t#ZCU>Nz0#NUKlIDiHN$)EFw{1#eDT78 zw?6pwzScDd-*mnI+>$Qz#fH01KDOo$yraMO49>Io&j08qAH6e@oiljFOIPZN-cvW- zRn|Olhj_AnyXC?Vx#fGezPqfr@3z9&%=5NgvsQX7qZ~3fh8p7w=ifH+jW_OoCVL7O z()3|lJm{t`M#;Bdy}JK#W)*YhHy+xMJ8A7tSGInWzUq6@@$pMA->R?p0tvwM0#_4EibagX#|aLo7tl>%QW zj(ZCAgo=8L7=}5i$a9DV(W)Yj?q7u*ha7YBikmeTEF1rfFUwtDwVRK?Gi0gy!Giw7 z`wu<&(DXOF#|>Zl<~-WBDEDJw?Y^rPA`8^lgX#qO`+8@egA9)AJ}=B#-QNq^KiwEW z6MAN>Uf1~1)AGEdE`8^duMW-`JdfoEcZ6TkJ~m)}_kQWS^xbo}oS8ltkG8(xpMCGf z-Lp3k_N)F}yZyE^Tg$#$^X1#vUmmSoeDAs!Sli3kr@!61>8#hD6EkZ+J?4qenO_`r z{NB~O&tLz+pHAkNoavZ#5Av-iAD9tKf4=Uc^S5t4^0%+;NDRBLyl%&c>;2*XTDol` zabw1?apZ8>_MH|GYz%c8vHR0mk9Tp=!InL*{qd`3r@r21iwx&{+_Z4$SwNVe(9{co(9iPN* zz4Pd==3X%R#_+YX_bkXhbi#L!{N{?e7kyHW&it|4MLgZS{hZOCe(Rl!4?6z5(-;5l z!o}tn-+Q|Lz)wFz%j{<}PdnFjYwic^gP$Dt_J6FuD|yf}_iSb_e&nnr?romu<~+OO zBVpdpezW8f@4B;=T;{my@>4(k)#eRvJbK&W1=nzopr`lz@>HdD)-k`@wKK5q$z4xh zank*9=F%fp+J64VGyb(N@A>T5O}CtQ;eWq=ko^R9-$#q&Zy%ez@bOL3p*JZvJ$cvz z>3hFL{`|ZTX7!@N;9V!KdhoF4-`RTmriaV@#(NqU|Mi~p#{uL+WaA&f-oXCa05TKl z12de4Aw$2Cs4MCNUwxLnWMthB_EOB*bJioo1ZDt+SP?T~oVW)JS@nO%5Y>5=L302} zMpU^+_aBcOi>$nP`OQaPu=Id9qPE@-Mh*yU(_o}$7V?c*bK8di0Y-Wc{)afSee32+ z9=lw#eBiDtKfU|3<$p5|M>IsWB3wS_-mA9m&0YPsQ;pW+w;ycZIlA!Av!B1_J9oeQ zhlK;*fA9NQnV)mYjmQ4&)oZSIfAG7PF1?3Ie|XWITWD10581ele=0a{iED0V)rvWdV}o-sMx5}}3rA#syDcz0{2B9$!~SB>AJO>axWXo8_Z99ff7my8Z{J19H+PS` zp7h!wX_>wb7%aF^eY?zrcWr8+zHt#7XV_{lvNuk62a_ts5o$K+Wzjl6deer@7Z z)7uRoGXZ-4O#q_mklv)%84-h71rQTB zCfoS!zhw;n3@_gQ?vDnJ{_d}rQNGwcdKRIeq>of@$SpuESzW@4aI0U#`D#yYJy`QJ;9(oS$j- zzPR-yO`&-IDLu%=58&GeZ&>r`5WVWi=e~OBzK0gPvHqynUR-nE>bpK0TrlJEd3P^A ze5Cs7==@(N4!?6t>2fL%+V&GIdhcKV{$BAvKiJ|XHyrKG`mRF1{ZutE?;Gc$7aBHg zyc>yr81L6#oWA$6rEeuG`^5FhN9ot=HyI91-lAOb{BQ>^#(G*n!RpgJHkjJi5M;{Z z9}qvfNy-5mjPrcvX7M4$HfHhasQbog09puy`ni@DSt0{7DAXmdxDz#j)qEUm% zVzu*43cg9IS6)40tH!hmptluxD{y5&Q%#n0@IhYC@P)h}sV8bHnnFrbt0YgFCa73| zZ&hK?a#FrZpVdwhU}QhjY}26A%-6Vw8xMSR@pZdCRj%7p-=QGhZAYJf z?)giuSwmU&{OG!Mm!9;>-QPL*^~XM|T=($~VerMK*ZBIaYrkmh%RV>z!?SGXUKG&m zv_5tB$!8M}Svz?qBIg%>WX?tI{)l_yhMUM(E+?(UZ#D84;N{`UA6KFKvc z`u%;27X5J7h83n4j(zow!Pl~{+HW<0>-pzg=PdHwvFX(}-~aMg1G6nZt<{$6 z#(K|3#(Fox;cn#Y|CK1ID`uNITYmG#UC0rW5!^tZZr>lK($(w0-+e zHzEG1rh_^X!I8PM=5!9n_ss@Fp7`C{+dJbNP_e1|o^{CV&T0AC)1R;E)2ZH#DlMyEo?A-*kxC1 z=lf2si=VdQZ~tjzd+OQK$JPb2{v~%k`O@v&2UnlJUH9HAGwQp^FE?b)xT$}|j#DqW zXLRV}6Z+ggSXvWaKKGBFUFRuG=+QTSR;+I0=SEDM?eEXpJi0CPN?-4SRll_MUe|N& z3TF2g&pfjI<$d$ce>wZN|GHw=Gsk|18$AAoq2+&B_QNf=+`Z^f*N^1i+-+RYTuKf8c&s}-w-ydC^+T;_|6VJYS{RyuhJmWa2@9aM< dzV^dai)Vg2;`?IthSz5Wdly)LFnaBd{{xe%NQVFb