diff --git a/CHANGELOG.md b/CHANGELOG.md index 01bb3020..69cd9114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,277 +4,782 @@ *** -## \[12.7.3] - 2026-05-14 +## \[12.25.0] - 2026-05-15 -### 🐛 修复 — 日签卡片布局溢出 + HTML标签显示 + 排版偏移 +### 🔧 架构重构 — 目录整理 + home_widget本地化 + 代码拆分 -> 1. 日签卡片 RenderFlex 底部溢出 5 像素 -> 2. API 返回的文本含 HTML 标签(`

`, `
` 等),直接显示为原始符号 -> 3. 文字排版偏左,右侧空白 +> 代码架构优化:chat_flow_page拆分、inspiration/presentation目录整理、core/services分类归档、home_widget本地包更新至0.9.1 -1. **布局溢出修复** — `lib/features/daily_card/presentation/widgets/card_renderer.dart` - - 容器从固定 `width:360, height:480` 改为 `width:double.infinity` + `BoxConstraints(minHeight:440, maxHeight:480)` - - 所有内容区域从 `LayoutBuilder+TextPainter+FittedBox` 改为 `Expanded+SingleChildScrollView`,自适应内容高度 - - 消除底部溢出 +#### 🔧 chat_flow_page.dart 拆分 +- 原1526行拆分为7个文件,主文件373行 +- `chat_flow_page.dart` — State管理和build方法 +- `chat_flow_readlater_mixin.dart` → `ChatFlowReadlaterHelper` 静态辅助类 +- `chat_flow_conversation_mixin.dart` → `ChatFlowConversationHelper` 静态辅助类 +- `chat_flow_top_bar.dart` — 搜索栏、分类栏、背景图、动态主题 +- `chat_flow_input_bar.dart` — 输入栏、附件按钮、发送按钮 +- `chat_flow_message_list.dart` — 消息列表展示、空状态占位 +- `chat_flow_send_toast.dart` — 消息发送浮动提示 -2. **HTML 内容渲染** — `card_renderer.dart` - - 新增 `_buildHtmlText()` 方法,使用 `flutter_html` 组件渲染 HTML 标签 - - 4 种样式(默认/大字报/中国风/毛玻璃)全部替换为 HTML 渲染 - - 杂志样式也改用 `_buildHtmlText()` +#### 🔧 inspiration/presentation 目录整理 +- 新增子目录: `pages/home/`, `pages/tool/`, `widgets/chat/`, `widgets/chat_bubble/`, `widgets/chat_input/`, `widgets/common/`, `widgets/tool/` +- 每个文件夹不超过8个文件 +- 所有import路径已修复 -3. **文字排版居中** — `card_renderer.dart` - - `crossAxisAlignment` 从 `start` 改为 `center` - - `textAlign` 统一设为 `TextAlign.center` - - 消除右侧空白 +#### 🔧 core/services 目录整理 +- 原29个文件分类到6个子目录: + - `auth/` (3): token_service, permission_service, validate_service + - `data/` (5): backup_service, data_export_service, settings_export_service, home_widget_service, jinrishici_sdk_service + - `device/` (5): app_lock_service, battery_optimization_service, device_info_service, haptic_service, screen_wake_service + - `network/` (4): connectivity_service, deep_link_service, network_proxy_service, og_metadata_service + - `notification/` (4): local_notification_service, notification_scheduler, notification_service, readlater_reminder_service + - `readlater/` (5): readlater_ai_service, readlater_collab_service, readlater_device_sync_service, readlater_sync_service, sharing_receiver_service +- 根目录保留3个: smart_mode_service, sound_service, clipboard_monitor_service +- 所有import路径已修复 -4. **首页/发现页 HTML 清理** — `home_daily_card.dart`, `daily_read_section.dart` - - 新增 `_stripHtml()` 工具方法,清除 HTML 标签和转义字符 - - 首页日签卡片文本经过 HTML 清理后显示 - - 发现页今日一读内容经过 HTML 清理后显示 - -5. **依赖新增** — `pubspec.yaml` - - 新增 `flutter_html: ^3.0.0-beta.2` 用于 HTML 内容渲染 +#### 🔧 home_widget 本地包更新 +- 从pub.dev下载home_widget 0.9.1版本到 `packages/home_widget` +- pubspec.yaml引用改为本地路径: `path: packages/home_widget` +- 版本从0.7.0+1升级到0.9.1 *** -## \[12.7.2] - 2026-05-14 +## \[12.24.0] - 2026-05-15 -### 🐛 修复 — 文件传输页面循环导入 + 应用锁异常捕获增强 +### ✨ 新功能 — E10剪贴板监控 + E13桌面小组件 + E14文件夹管理 + E15AI摘要 + E17稍后读协作 -> 1. `file_transfer_page.dart` ↔ `file_transfer_device_actions.dart` 双向导入形成循环依赖 -> 2. `onAppResumed()` 中 `catchError` 无法捕获同步异常,改用 `async/try-catch` +> 稍后读功能体系扩展:剪贴板自动检测链接、桌面小组件数据推送、文件夹归档、AI智能摘要、好友共享协作 -1. **打破循环导入** — `lib/features/file_transfer/presentation/pages/file_transfer_device_actions.dart` - - 移除对 `file_transfer_page.dart` 的反向导入 - - mixin 改为泛型 `FileTransferDeviceActions` - - 页面使用 `FileTransferDeviceActions` 满足类型约束 +#### ✨ E10 剪贴板链接监控服务 +- 新增 `ClipboardMonitorService` (`core/services/clipboard_monitor_service.dart`) + - `startMonitor()` — 每3秒轮询剪贴板,检测URL链接 + - `stopMonitor()` — 停止监控 + - `_isUrl()` — 仅匹配http/https协议链接,隐私保护不保存非URL内容 + - `_saveToReadlater()` — 自动保存到稍后读,显示📋提示 + - `setEnabled()` / `getEnabled()` — 开关状态持久化(AppKVStore key: clipboard_monitor_enabled) + - `initFromStore()` — 应用启动时根据存储状态自动恢复监控 -2. **异常捕获增强** — `lib/core/services/app_lock_service.dart` - - `onAppResumed()` 中 `catchError` 改为 `async/try-catch` 包裹 - - 同时捕获同步异常和异步异常,防止未处理错误崩溃 +#### ✨ E13 桌面小组件服务 +- 新增 `HomeWidgetService` (`core/services/home_widget_service.dart`) + - `init()` — 初始化App Group (group.com.xianyan.share) + - `updateReadlaterCount()` — 更新稍后读未读数到小组件 + - `updateReadlaterPreview()` — 更新最新稍后读内容预览(截断80字) + - `updateDailySentence()` — 更新每日一句到小组件 + - `handleWidgetClick()` — 获取小组件点击数据 + - `registerInteractivityCallback()` — 注册后台交互回调 + - Android/iOS WidgetId: XianyanReadlaterWidget + - `debugGetAllData()` — 调试用,读取所有小组件数据 +- pubspec.yaml 新增 `home_widget: ^0.7.0` 依赖 + +#### ✨ E14 稍后读文件夹管理 +- 新增 `ReadlaterFolderService` (`features/inspiration/services/readlater_folder_service.dart`) + - `getFolders()` — 获取所有文件夹(自动同步消息计数) + - `createFolder()` — 创建文件夹(支持emoji图标) + - `renameFolder()` / `deleteFolder()` — 重命名/删除文件夹 + - `moveMessageToFolder()` / `removeMessageFromFolder()` — 消息归档/移出 + - `getMessageIdsInFolder()` / `getMessageFolder()` — 查询归档关系 + - 数据持久化: AppKVStore (key: readlater_folders + readlater_message_folder) +- 新增 `ReadlaterFolder` 数据模型(id/name/emoji/count/createdAt/updatedAt) + +#### ✨ E15 AI摘要生成服务 +- 新增 `ReadlaterAiService` (`core/services/readlater_ai_service.dart`) + - `generateSummary()` — 生成单条消息摘要 + - `batchSummarize()` — 批量生成摘要 + - `generateDailySummary()` — 生成每日摘要 + - `suggestTags()` — 智能分类标签建议 + - `applySummary()` / `getSummary()` — 摘要写入/读取消息ext['aiSummary'] + - Edge Function: generate-readlater-summary + - 请求参数: { content, type, action: 'summary'|'daily'|'tags' } + - 降级策略: AI不可用时返回null,不影响UI + +#### ✨ E17 稍后读协作服务 +- 新增 `ReadlaterCollabService` (`core/services/readlater_collab_service.dart`) + - `createSharedList()` — 创建共享列表 + - `inviteMember()` — 邀请好友加入 + - `shareToSharedList()` — 分享消息到共享列表 + - `getSharedListMessages()` — 获取共享列表消息 + - `getMySharedLists()` — 获取我参与的共享列表 + - `leaveSharedList()` — 退出共享列表 + - `subscribeToListChanges()` / `unsubscribeFromListChanges()` — 实时订阅/取消 + - Supabase表: readlater_shared_lists, readlater_shared_members, readlater_shared_messages + - 实时订阅: supabase.realtime PostgresChanges +- 新增 `SharedReadlaterList` 数据模型(id/name/ownerId/memberIds/messageCount/createdAt) + +#### 📝 文件变更 +- 新增: `lib/core/services/clipboard_monitor_service.dart` +- 新增: `lib/core/services/home_widget_service.dart` +- 新增: `lib/features/inspiration/services/readlater_folder_service.dart` +- 新增: `lib/core/services/readlater_ai_service.dart` +- 新增: `lib/core/services/readlater_collab_service.dart` +- 修改: `pubspec.yaml` — 新增 home_widget 依赖 *** -## \[12.7.1] - 2026-05-14 +## \[12.23.0] - 2026-05-15 -### 🐛 修复 — 应用锁认证异常未捕获 +### 🔧 服务端修复 — 信令协议3项问题修复 + API文档拆分 -> `onAppResumed()` 中 `authenticate()` 以 fire-and-forget 方式调用,未捕获异常可能导致应用崩溃 +> 修复WebSocket信令服务器heartbeat/discover/ping无响应问题,API文档拆分为独立文档 -1. **异常捕获** — `lib/core/services/app_lock_service.dart` - - `onAppResumed()` 中为 `authenticate()` 添加 `.catchError()` 处理 - - 捕获非 PlatformException 类型的意外异常(如 MissingPluginException) - - 异常时记录日志并返回 `false`,防止未处理的异步错误崩溃 +#### 🐛 服务端修复 — heartbeat无响应 +- **根因**: `server/index.js` 第119-121行,`heartbeat` case 仅更新 `lastBeat`,未回复 `heartbeat_ack` +- **修复**: 添加 `this._send(sender, { type: 'heartbeat_ack', timestamp: Date.now() })` 回复 +- **影响**: 客户端现在可以主动检测连接存活状态 + +#### 🐛 服务端修复 — discover设备发现无响应 +- **根因**: `discover` 不在 switch case 和 handledTypes 中,无 `to` 字段被通用转发丢弃 +- **修复**: 新增 `_handleDiscover` 方法,返回所有在线设备列表;添加 `discover` 到 handledTypes +- **影响**: 客户端可以主动请求刷新设备列表 + +#### 🐛 服务端修复 — ping/pong无响应 +- **根因**: 客户端发送 `ping` 不在 switch case 中,被通用转发丢弃 +- **修复**: 新增 `ping` case,回复 `{ type: 'pong', timestamp }` 并更新 `lastBeat` +- **影响**: 客户端可以主动测试连通性 + +#### 🔧 服务端优化 — 注册响应顺序 +- **根因**: `_onConnection` 中 `display-name` 在 `registered` 之前发送 +- **修复**: 先发送 `registered`,再发送 `display-name` + +#### 📄 文档拆分 — API_FILE_TRANSFER_DOC.md +- 原始合并文档已删除 +- 拆分为 `API_FILE_TRANSFER_CORE_DOC.md`(文件传输核心API + WebSocket信令协议) +- 拆分为 `API_CLOUD_CACHE_DOC.md`(云端暂存CloudCache API) +- 新增 `API_FILE_TRANSFER_ANALYSIS.md`(问题与性能分析报告) +- 新增 `verify_file_transfer_api.py`(全流程API验证脚本,28个测试用例) + +#### ✅ API验证结果 +- REST API: 14/14 通过 (100%) +- WebSocket信令: 6/6 通过 (100%) +- 云端暂存: 8/8 通过 (100%) +- **总计: 28/28 通过 (100%)** + +#### 🔧 客户端修复 — signaling_service.dart +- 新增 `discoverResponse` 枚举和 case 处理 +- 新增 `heartbeatAck` 枚举和 case 处理,追踪 `_lastHeartbeatAck` +- 修复 `pong` case,记录心跳响应时间 +- 修复 `displayName` case,解析并存储服务器推送的设备名称 *** -## \[12.8.0] - 2026-05-14 +## \[12.22.0] - 2026-05-15 -### 🎨 重构 — 精灵图标完全重绘 +### ✨ 新功能 — E16跨设备稍后读同步 + 稍后读缓存管理集成 -> 删除之前粗糙的绘制代码,使用精细贝塞尔曲线(cubicBezier)重新绘制所有12种精灵组合,细节大幅提升 +> 稍后读内容可通过文件传输助手同步到同账号设备,缓存管理页面新增稍后读统计和清理 -1. **绘制系统重构** — `lib/shared/widgets/tab_icon_sprite.dart` - - 采用 32×32 逻辑坐标系 + `canvas.scale`,所有坐标精确到小数点 - - 所有形状使用 `cubicTo` 贝塞尔曲线绘制,告别直线/简单矩形 - - 开启 `isAntiAlias = true` 抗锯齿 - - 统一画笔工具 `_fill()` / `_subFill()` / `_stroke()` 支持透明度参数 +#### ✨ E16 跨设备稍后读同步 +- 新增 `ReadlaterDeviceSyncService` (`core/services/readlater_device_sync_service.dart`) + - `discoverDevices()` — 发现同账号在线设备 + - `sendToDevice()` — 将稍后读消息打包发送到指定设备 + - `sendAsFile()` — 导出稍后读内容为文件并传输 + - `handleReceivedSyncData()` — 处理接收到的稍后读同步数据 + - `scanAndImportPendingSyncFiles()` — 扫描传输目录中未处理的同步文件 +- 发送流程:序列化JSON → 保存临时文件 `readlater_sync_{timestamp}.json` → 调用TransferNotifier.sendFileToMyDevice传输 +- 接收流程:检测同步文件 → 解析JSON → 按消息类型(readlater_sentence/link/document/text)导入稍后读会话 +- 复用现有文件传输模块能力(TransferNotifier/SignalingService) -2. **猫咪造型重绘** - - 尖耳:贝塞尔曲线绘制弧形耳廓 + 半透明内耳 - - 选中时:6根胡须(3左3右)带弯曲 +#### ✨ 稍后读缓存管理集成 +- `CacheService` 新增方法: + - `getReadlaterCacheSize()` — 获取稍后读缓存大小(消息+附件+同步文件) + - `cleanReadlaterCache()` — 清理稍后读缓存(缩略图/附件/同步临时文件,保留消息记录) + - `clearReadlaterData()` — 清理全部稍后读数据(消息+附件+缩略图) +- `CacheStats` 新增 `readlaterSizeBytes` 字段和 `readlaterSizeFormatted` getter +- 缓存管理页面新增: + - 分类统计中"📖 稍后读"条目,显示缓存大小 + - "📖 清理稍后读缓存"操作按钮(保留消息记录) + - "📖 清理全部稍后读数据"操作按钮(含确认弹窗,不可撤销) -3. **狗狗造型重绘** - - 垂耳:流畅的S型曲线 + 内耳渐变 - - 选中时:贝塞尔曲线舌头 + 中线细节 - -4. **男孩造型重绘** - - 短发:弧形发顶 + 刘海碎发 - - 渐变透明度层次感 - -5. **女孩造型重绘** - - 长发:顶部发弧 + 两侧垂发 + 齐刘海 - - 三层透明度(0.65/0.5/0.4)营造层次 - -6. **图标主体重绘** - - 房子:弧形屋顶 + 圆角墙体 + 窗户 + 门把手 + 烟囱爱心 - - 指南针:弧形指针 + 内环 + 中心点 + 菱形闪光 - - 人物:椭圆头部 + 圆角肩部 + 光环 - -7. **表情系统重绘** - - 眼睛:椭圆眼球 + 大高光 + 小高光(双层反光) - - 闭眼:弧形曲线(非直线) - - 嘴巴:3种状态(中性线/微笑弧/张嘴填充) - - 腮红:`MaskFilter.blur` 柔化边缘 +#### 📝 文件变更 +- 新增: `lib/core/services/readlater_device_sync_service.dart` +- 修改: `lib/features/home/services/cache_service.dart` — 新增稍后读缓存管理方法+CacheStats字段 +- 修改: `lib/features/home/presentation/cache_management_page.dart` — 新增稍后读统计条目+清理操作 *** -## \[12.7.0] - 2026-05-14 +## \[12.22.0] - 2026-05-15 -### ✨ 新增 — 选中Tab隐藏文字+精灵画质提升 +### ✨ 新功能 — E4视频压缩保存 + E11稍后读同步 + E12句子卡片制作 -> 选中Tab时文字标签淡出+向下滑出,icon弹性放大;离开时文字淡入+从下方滑入,icon恢复大小。精灵图标增加更多细节 +> 视频气泡支持压缩保存到相册,稍后读支持云端同步,句子卡片支持截图制作分享 -1. **文字标签动画** — `lib/shared/widgets/tab_icon_sprite.dart` - - 新增 `_labelController` 控制文字显隐动画 - - 选中时:文字淡出(opacity 1→0) + 向下滑出(Offset 0→0.5) - - 未选中时:文字淡入(opacity 0→1) + 从下方滑入 - - `GlassBottomBarTab.label` 设为 null,文字完全由 TabIconSprite 管理 +#### ✨ E4 视频压缩保存 +- `ChatVideoBubble` 新增长按菜单(CupertinoActionSheet) +- 新增"💾 压缩保存"选项:使用 `video_compress` 压缩视频(MediumQuality) +- 压缩过程显示 `CupertinoActivityIndicator` + 进度百分比 +- 压缩完成后使用 `gal` 保存到相册(album: 闲言) +- 新增"▶️ 播放视频"菜单项 +- 权限检测:先检查相册权限,未授权则请求 -2. **精灵画质提升** — `lib/shared/widgets/tab_icon_sprite.dart` - - 猫咪:内耳细节(半透明三角) + 选中时胡须 - - 狗狗:圆角垂耳 + 选中时伸舌头 - - 男孩:短发刘海 + 圆角细节 - - 女孩:长发+侧发 + 圆角细节 - - 房子:选中时烟囱+爱心冒烟 - - 指南针:选中时4个方向闪光点 - - 人物:选中时光环 - - 表情:选中时腮红 + 张嘴(夸张模式) + 眼睛高光 +#### ✨ E11 稍后读离线同步服务 +- 新增 `ReadlaterSyncService` (`core/services/readlater_sync_service.dart`) +- 基于 `supabase_flutter` 实现云端同步 +- 核心方法:`uploadMessage` / `downloadMessages` / `fullSync` / `getLastSyncTime` / `setAutoSync` / `getAutoSync` / `deleteMessage` +- Supabase表名: `readlater_messages`,字段: id, user_id, message_id, type, content, meta_json, created_at, updated_at +- 使用 `AppKVStore` 存储同步状态(last_sync_time, auto_sync) +- 冲突策略:以 `updated_at` 较新的为准 +- `SyncResult` 模型记录上传/下载/冲突数量 +- `ensureInitialized()` 静态方法确保 Supabase 已初始化 + +#### ✨ E12 句子卡片制作 +- `ChatSentenceCardBubble` 操作按钮新增"🎨 制作" +- 使用 `RepaintBoundary` 包裹卡片,`toImage()` 截图(3x像素密度) +- 截图保存到临时目录,通过 `share_plus` 分享图片 +- 分享文本包含句子内容+作者+来源标识 +- 生成过程显示 Loading 提示 *** -## \[12.6.0] - 2026-05-14 +## \[12.21.0] - 2026-05-15 -### ✨ 新增 — 底部Tab精灵动画图标 + 命名修改 +### 🔧 Bug修复 — 信令服务客户端适配服务端修复 -> 底部导航栏全面升级为精灵动画图标,支持4种造型(猫/狗/男/女)、2种表情风格(夸张/含蓄),选中时表情变化+呼吸光效+果冻弹跳+相邻注视 +> 服务端已修复心跳/发现/Ping信令,客户端同步更新枚举和处理逻辑 -1. **Tab命名修改** — `lib/core/layout/app_shell.dart` - - 底部Tab"足迹" → "发现",图标改为compass - - 灵感页顶部"👣 足迹" → "⚙️ 工作流" +#### 🐛 问题根因 +- `SignalingMessageType` 枚举缺少 `heartbeat_ack` 和 `discover_response` 类型 +- `_handleMessage` 中 `discover` case 错误地调用了 `_handleDiscoverResponse`(应为 `discoverResponse`) +- `heartbeat_ack` 消息被解析为 `unknown` 类型,无法正确处理 +- `pong` 消息处理仅 break,未记录心跳响应时间 +- `display-name` 消息仅日志输出,未存储显示名称 -2. **TabIconSprite 精灵动画组件** — `lib/shared/widgets/tab_icon_sprite.dart` - - 4种造型:猫咪(尖耳+胡须) / 狗狗(垂耳+舌头) / 男孩(短发) / 女孩(长发+侧发) - - 3种图标主体:房子(home) / 指南针(discover) / 人物(profile) - - 表情动画:选中时睁眼微笑,未选中时闭眼/中性,相邻图标注视方向 - - 呼吸光效:选中时脉冲光晕 + 3个浮动微粒子 - - 果冻弹跳:弹性缩放 1.0→1.35→0.92→1.05→1.0 + 微旋转 - - 动画强度跟随主题设置 animationIntensity +#### ✅ 修复内容 +- `SignalingMessageType` 新增 `heartbeatAck('heartbeat_ack')` 枚举值 +- `SignalingMessageType` 新增 `discoverResponse('discover_response')` 枚举值 +- `_handleMessage` 修复 `discover` case:不再调用 `_handleDiscoverResponse` +- `_handleMessage` 新增 `discoverResponse` case:正确调用 `_handleDiscoverResponse` +- `_handleMessage` 新增 `heartbeatAck` case:记录心跳响应时间 +- `_handleMessage` 改进 `pong` case:记录心跳响应时间和日志 +- `_handleMessage` 改进 `displayName` case:解析并存储服务器推送的显示名称 +- 新增 `_lastHeartbeatAck` 字段和 `lastHeartbeatAck` getter +- 新增 `_serverDisplayName` 字段和 `serverDisplayName` getter -3. **AppShell 集成** — `lib/core/layout/app_shell.dart` - - 替换静态 CupertinoIcons 为 TabIconSprite - - 读取 themeSettingsProvider 的表情风格和造型偏好 - - 相邻注视方向自动计算(adjacentFor) +#### 📝 文档更新 +- `API_FILE_TRANSFER_DOC.md`: 更新心跳/发现/ping为双向机制,添加display-name消息类型 +- `API_FILE_TRANSFER_CORE_DOC.md`: 同步更新 +- `API_FILE_TRANSFER_ANALYSIS.md`: 标记已修复项(心跳/发现/Ping/display-name/文档) *** -## \[12.5.0] - 2026-05-14 +## \[5.3.0] - 2026-05-15 -### ✨ 优化 — 搜索流式返回 + 多项Bug修复 +### ✨ 新功能 — E1链接OG元数据自动抓取 + E8全文搜索 -> 本次更新涵盖13个问题修复,包括搜索体验优化、UI动画增强、布局异常修复等 +> 链接消息自动抓取OG预览信息,稍后读模式支持全文搜索+搜索结果高亮 -1. **搜索流式返回策略** — `lib/features/search/providers/search_provider.dart` - - 新增 `_streamingSearchAll()`: 替代旧的两阶段搜索 - - 快速阶段: 先搜索全部数据源(limit:10),8秒超时,结果立即展示 - - 优先类型阶段: 逐个搜索9个热门数据源(poetry/hitokoto/wisdom等),每类5条,5秒超时 - - 次要类型阶段: 逐个搜索35个其他数据源,每类3条,3秒超时 - - 每个类型搜索完成后立即更新UI,用户可看到结果逐步增加 - - 搜索中途切换关键词自动中断旧搜索 - - 搜索结果头部显示流式进度("已找到X条,继续搜索...") -2. **主页句子卡片空白修复** — `lib/features/home/models/feed_model.dart` - - `_extractTitleFromExtra` 字段映射对齐API文档(food/prescription/jiufang等14种类型) - - `HomeSentence.fromFeedItem` 新增 `_extractTextFromExtra` 类型感知回退逻辑 - - `home_sentence_card.dart` 空内容时显示类型相关占位提示 -3. **句子卡片加载动画增强** — `lib/features/home/presentation/home_daily_card.dart` - - 🔄 旋转动画替代静态emoji - - 文字呼吸脉动效果(透明度+浮动) - - 卡片微光shimmer效果 - - 3个浮动圆点指示器(波浪序列) -4. **足迹日签卡片TextPainter异常修复** — `lib/features/daily_card/presentation/widgets/card_renderer.dart` - - 5处TextPainter添加 `textDirection: TextDirection.ltr` -5. **个人中心加载闪烁修复** — `lib/features/auth/providers/auth_provider.dart` - - 用户数据缓存到Hive,同步加载,消除"登录中"闪烁 -6. **签到日历"补"字Bug修复** — `lib/features/signin/presentation/signin_page.dart` - - 已签到日期显示"签"+主题色,过去未签到显示"补"+橙色 -7. **邮箱/手机/密码整合到账户设置** — 已有页面整合,无需新建 -8. **删除数据管理按钮** — `lib/features/user_center/presentation/widgets/account_section.dart` -9. **收藏双向同步** — `lib/features/home/providers/favorite_provider.dart` - - 双向同步(上传本地+拉取云端)+进度横幅+冲突处理 -10. **积分/签到/笔记按钮导航** — `lib/features/profile/presentation/profile_page.dart` -11. **足迹搜索框禁止自动弹出键盘** — `lib/features/inspiration/presentation/footprint_page.dart` -12. **搜索HTML文本清理** — `lib/features/home/models/feed_model.dart` - - FeedItem.fromJson 应用 `.cleanHtml` 清理HTML实体和标签 -13. **搜索页面emoji→CupertinoIcons** — `lib/features/search/presentation/search_page.dart` - - 44种数据源图标映射 `FeedTypeIcon` - - 区域标题/空状态/统计芯片全部替换为CupertinoIcons +#### ✨ E1 链接OG元数据自动抓取服务 +- 新增 `OgMetadataService` (`core/services/og_metadata_service.dart`) + - 使用dio发起HTTP请求,5秒超时,失败静默降级 + - 使用compute/isolate处理HTML解析,避免主线程卡顿 + - 解析 og:title / og:description / og:image / og:site_name + - 降级策略:title标签 / meta description / favicon + - 相对URL自动转绝对URL +- `ChatLinkBubble` 改为StatefulWidget,渲染后异步调用OgMetadataService.fetch + - 缺少OG数据时自动触发异步抓取 + - 抓取中显示加载占位+指示器 + - 抓取成功后更新UI(标题/描述/图片/siteName) + - 新增onMetaUpdated回调通知父组件持久化meta + +#### ✨ E8 全文搜索 +- `ChatState` 新增 searchQuery 字段和 isSearching getter +- `ChatState.filteredMessages` 支持搜索过滤(与分类过滤叠加) +- `ChatState._searchInMessages` 静态方法:搜索text/author/source/meta.url/meta.title/meta.description/attachments.fileName +- `ChatNotifier` 新增 searchMessages / setSearchQuery / clearSearch 方法 +- `ChatFlowPage` 稍后读模式AppBar新增🔍搜索按钮 +- 搜索栏替代分类栏位置,使用CupertinoSearchTextField +- 输入时实时过滤消息列表,显示结果计数(N/Total) +- `ChatBubble` 新增 highlightQuery 参数 +- `ChatBubble._buildHighlightText` 方法:匹配文本高亮显示(accent色背景+加粗) *** -## \[12.4.0] - 2026-05-14 +## \[13.3.0] - 2026-05-15 -### ✨ 新增 — 底部Tab栏个性交互设置 +### ✨ 新功能 — E2文档预览 + E7阅读统计 + E9标签管理 -> 主题个性化页面新增 Tab 表情风格和 Tab 造型偏好两项设置,为后续底部导航图标动画提供个性化配置基础 +> 新增文档预览页面、稍后读阅读统计页面、标签管理服务,丰富稍后读功能体系 -1. **Tab 表情风格选项** — `lib/features/settings/providers/theme_settings_provider.dart` - - 新增 `TabExpressionStyleOption` 类(id/label/emoji/eyeScale/mouthCurve/bounceMultiplier) - - 两种风格:`exaggerated`(夸张🤩) / `subtle`(含蓄😊) - - 持久化 Key: `theme_tab_expression`,默认值 `exaggerated` +#### ✨ E2 文档预览页面 +- 新增 `DocumentPreviewPage` — CupertinoPageScaffold风格 +- 接收文件路径参数,支持本地文件和网络链接 +- 图片文件:直接预览显示 +- 其他文件:显示文件信息卡片(文件名/大小/类型/修改时间) +- 操作按钮:打开文件(url_launcher) / 分享(ShareSheet) / 保存到相册(图片) +- 使用GlassContainer毛玻璃卡片 + AppTheme设计令牌 -2. **Tab 造型偏好选项** — `lib/features/settings/providers/theme_settings_provider.dart` - - 新增 `TabCharacterStyleOption` 类(id/label/emoji/category) - - 四种造型:`cat`(猫咪🐱) / `dog`(狗狗🐕) / `boy`(男孩👨) / `girl`(女孩👩) - - 分为宠物(pet)和人物(human)两大类 - - 持久化 Key: `theme_tab_character`,默认值 `cat` +#### ✨ E7 阅读统计页面 +- 新增 `ReadlaterStatsPage` — CupertinoPageScaffold + CustomScrollView +- 统计卡片:总消息数/已读数/未读数 (GlassContainer毛玻璃) +- 类型分布饼图:使用fl_chart PieChart,按消息类型统计(句子/链接/图片/视频/文档/文本) +- 最近7天新增趋势折线图:使用fl_chart LineChart,曲线+渐变填充 +- 接收List参数,纯计算无副作用 +- 使用AppTheme.ext(context) / AppSpacing / AppTypography / AppRadius设计令牌 -3. **ThemeSettingsState 扩展** — `lib/features/settings/providers/theme_settings_provider.dart` - - 新增字段 `tabExpressionStyleId` + `tabCharacterStyleId` - - 新增 getter `tabExpressionStyle` + `tabCharacterStyle` - - 新增 setter `setTabExpressionStyle()` + `setTabCharacterStyle()` - - `copyWith` / `_loadFromStorage` / `resetAll` 同步更新 - -4. **主题个性化页面新增两个 Section** — `lib/features/settings/presentation/theme_settings_page.dart` - - `_TabExpressionStyleSection`:表情风格切换(夸张/含蓄),带大 Emoji 预览 - - `_TabCharacterStyleSection`:造型偏好选择(宠物🐾/人物👤分组),带 Emoji 预览 +#### ✨ E9 标签管理 +- ChatMessage模型新增标签便捷方法:getTags / hasTag / addTag / removeTag +- 标签存储在ext['tags']字段,格式: List +- 新增 `ReadlaterTagService` — 基于AppKVStore持久化 +- 核心方法:getAllTags() / addTag() / removeTag() / getMessagesByTag() / getTagStats() +- 标签存储key: 'readlater_tags',数据结构: Map> +- 支持syncFromMessages / exportToMessages 双向同步 +- 支持批量设置/清空标签 *** -## \[12.3.0] - 2026-05-14 +## \[13.2.0] - 2026-05-15 -### 🐛 修复 — 搜索三大问题修复 (Issue 10+11+12) +### ✨ 新功能 — E5 多格式导出 + E6 稍后读提醒 + +> 稍后读内容支持JSON/Markdown/ZIP多格式导出,新增稍后读定时提醒通知 + +#### ✨ E5 多格式导出 +- `_exportReadlater` 替换为 `CupertinoActionSheet` 选择导出格式 +- `_exportAsJson()`: 导出JSON到剪贴板 +- `_exportAsMarkdown()`: 导出Markdown到剪贴板(含时间戳/作者/出处/链接) +- `_exportAsZip()`: 打包JSON+Markdown为ZIP文件,通过系统分享发送 +- 新增依赖导入: `archive`, `path_provider`, `share_plus` + +#### ✨ E6 稍后读提醒 +- `LocalNotificationService.scheduleReadLaterReminder()`: 调度每日稍后读提醒通知 +- `LocalNotificationService.cancelReadLaterReminder()`: 取消稍后读提醒 +- `_onNotificationTapped` 新增 `readlater` payload 路由跳转至 `/readlater-chat` + +*** + +## \[13.1.0] - 2026-05-15 + +### 🔧 Bug修复 — 屏幕共享接收方确认按钮 + +> 修复接收方收到屏幕共享请求后无法看到确认按钮的问题,完善屏幕共享信令回调链路 + +#### 🐛 问题根因 +- `TransferSignalingHandler` 创建时未传入 `onScreenShareOffer` 等回调 +- 接收方收到 `screenShareOffer` 后只添加系统消息,未触发 UI 状态更新 +- UI 层未监听屏幕共享请求状态 + +#### ✅ 修复内容 +- `TransferState` 新增 `ScreenShareOffer` 类和 `pendingScreenShareOffer` 字段 +- `TransferNotifier` 传入 `onScreenShareOffer/Answer/Stop/RemoteInput` 回调 +- `TransferNotifier` 新增 `acceptScreenShareOffer()`/`rejectScreenShareOffer()`/`clearPendingScreenShareOffer()` 方法 +- `TransferChatPage` 监听 `pendingScreenShareOffer` 弹出 `CupertinoAlertDialog` 确认对话框 +- `TransferChatPage` 消息列表新增屏幕共享请求卡片(📺图标+接受/拒绝按钮) +- 接受后自动调用 `screenShareProvider.startViewing()` 并导航到 `ScreenSharePage` + +*** + +## \[13.0.1] - 2026-05-15 + +### 🔧 Bug修复 — 协作画布参与者同步 + +> 修复协作画布中对方始终不在线、消息路由错误等关键问题 + +#### 🐛 修复 +- CanvasSyncService: `_handleCanvasJoin`/`_handleCanvasLeave` 现在正确维护 `_participants` 集合并触发 `onParticipantsChanged` 回调 +- CanvasSyncService: `joinCanvas` 方法将本机 deviceId 加入 `_participants`,`leaveCanvas` 清空集合 +- CanvasSyncService: `_sendCanvasMessage` 添加 `to: _peerId` 字段,确保消息正确路由到对方设备 +- CanvasSyncService: `joinCanvas` 新增 `peerId` 可选参数,用于指定消息目标设备 +- CanvasPage: `initState` 中使用 `sharedSignalingProvider.deviceId` 获取本机 deviceId 替代错误的 `peerDeviceId` +- CanvasPage: 同时传递 `peerDeviceId` 作为 `peerId` 参数,确保消息路由正确 +- CanvasNotifier: 构造函数绑定 `_syncService.onParticipantsChanged = _handleParticipantsChanged` +- CanvasNotifier: `joinCanvas` 方法签名更新,支持传递 `peerDeviceId` 到 SyncService + +*** + +## \[13.0.0] - 2026-05-15 + +### ✨ 新功能 — 稍后读会话 + ChatFlowPage增强 + +> 新增稍后读聊天会话、系统分享接收、链接/文档/句子卡片气泡,丰富所有会话体验 + +#### ✨ 新功能 — 稍后读内置会话 +- 工作流(InspirationPage)会话列表新增"📖 稍后读"内置会话,默认置顶 +- 点击进入聊天风格页面,支持发送文本/链接/图片/视频/文件/文档 +- 类似微信"文件传输助手",自己给自己发消息,管理稍后读内容 + +#### ✨ 新功能 — 句子详情 → 稍后读会话 +- 主页句子详情Sheet点击"稍后读"按钮,句子自动发送到稍后读会话 +- 句子以专属卡片气泡展示(渐变背景+作者+出处+统计) +- 取消稍后读时保留历史消息,不删除 + +#### ✨ 新功能 — ChatFlowPage增强 +- 新增ChatLinkBubble: 链接预览卡片(OG元数据+打开/复制按钮) +- 新增ChatDocumentBubble: 文档卡片(PDF/Word/Excel等+打开/分享按钮) +- 新增ChatSentenceCardBubble: 句子卡片(渐变背景+统计+操作) +- ChatMessageType新增: link / document / readlaterSentence +- ChatFlowPage支持动态配置(按sessionType调整标题/分类栏/设置等) +- 所有会话均可使用新增气泡组件 + +#### ✨ 新功能 — 系统分享接收 +- 集成receive_sharing_intent本地包(v1.8.1) +- 新增`SharingReceiverService`单例服务(`core/services/sharing_receiver_service.dart`) +- Android/iOS: 通过`getInitialMedia`+`getMediaStream`接收文本/链接/图片/视频/文件 +- Web: URL参数降级兼容(text/url/title query参数) +- Windows: 命令行参数降级兼容(自动检测文件路径/文本) +- 分享内容自动写入稍后读会话(conversationId='readlater') +- 文本自动检测URL,链接→sendLink,纯文本→sendText +- 文件按mimeType分发: image→sendImage, video→sendVideo, application→sendDocument, 其他→sendFile +- 分享成功时AppToast提示,失败时记录日志 + +#### ✨ 新功能 — 稍后读设置面板 +- AppBar右侧设置按钮,弹出CupertinoActionSheet +- 支持: 标记全部已读/清空/导出/统计/分享接收开关 + +#### 📄 开发文档 +- 新增: `docs/spec/readlater_chat_spec.md` — 稍后读会话+ChatFlowPage增强开发文档 + +*** + +## \[12.20.0] - 2026-05-15 + +### 🔧 Bug修复 + ✨ 功能增强 + +> 修复账户安全验证、底部Tab栏、文件传输助手等多项问题 + +#### 🐛 Bug修复 — 修改密保页面缺少邮箱验证码 +- **根因**: `security_question_page.dart` 的邮箱验证方式(receipt)仅输入回执码,缺少发送验证码步骤 +- **修复**: 新增邮箱验证码发送/倒计时/校验完整流程;新增 `EmsEvent.changesecquestion` 事件类型;验证方式选择器增加"📧 邮箱验证"选项 + +#### 🐛 Bug修复 — 修改密码页面缺少邮箱验证 +- **根因**: `change_password_page.dart` 的邮箱验证方式仅输入回执码,缺少发送验证码步骤 +- **修复**: 新增邮箱验证码发送/倒计时/校验完整流程;验证方式选择器增加"📧 邮箱"选项;新增 `EmsEvent.changepwd` 事件类型 + +#### 🔧 功能调整 — 安全与Token管理移至账户设置 +- **变更**: 将"安全与Token管理"按钮从"我的"页面移至"账户设置"页面 +- **原因**: 安全管理属于账户设置范畴,集中管理更符合用户心智模型 +- **影响**: `profile_page.dart` 移除按钮和 `_SecuritySheet` 组件;`account_settings_page.dart` 新增按钮和 `_SecuritySheet` 组件 + +#### 🐛 Bug修复 — 底部Tab栏间距过大+文字太小 +- **根因**: `tab_icon_sprite.dart` 中 glowSize=60/iconSize=44 过大导致图标区域占据过多空间;fontSize=10 过小 +- **修复**: glowSize 60→40,选中iconSize 44→36,未选中 28→24;fontSize 10→12;SizedBox height 14→16 + +#### 🐛 Bug修复 — 附近设备点进去看不到消息 +- **根因**: 设备通过LAN发现时id=fingerprint,但消息peerDeviceId可能为信令服务器分配的不同ID +- **修复**: `TransferChatPage` 新增 `_collectPeerIds()` 方法,收集设备所有已知ID,消息过滤改为多ID匹配 + +#### 🐛 Bug修复 — 共享屏幕/协作画布信令未连接 +- **根因**: `canvasProvider` 和 `screenShareProvider` 各自创建新的未连接 `SignalingService()` 实例 +- **修复**: 新增 `shared_signaling_provider.dart`,从 `transferProvider.notifier.pairingService.signalingService` 获取已连接实例 + +#### 🐛 Bug修复 — 画布页面返回黑屏 +- **根因**: `_openCanvas` 使用 `context.go()` 替换整个导航栈,返回时无上一页 +- **修复**: 改为 `Navigator.push()` + `CupertinoPageRoute`,保持导航栈完整 + +#### 🐛 Bug修复 — 我的设备显示本机+显示IP+消息发送失败 +- **根因1**: `buildMyDeviceCard` 对所有设备使用相同样式,无法区分本机与远程 +- **修复1**: 本机设备使用灰色背景+不可点击+"📱 本机"标签,远程设备保持原有样式 +- **根因2**: `displayAlias` 回退显示IP地址 +- **修复2**: `TransferDevice` 新增 `accountAlias` 字段,优先显示账号昵称而非IP +- **根因3**: `sendTextMessage` 固定通道顺序,wsRelay偏好设备无法优先使用WsRelay +- **修复3**: 根据设备 `preferredTransport` 优先选择通道 + +#### ✨ 新功能 — 局域网访问二维码弹窗 +- 点击局域网访问横幅弹出CupertinoModalPopup sheet +- 弹窗包含:二维码(QrImageView)、URL文本、说明文字、复制链接按钮 + +*** + +### 🐛 Bug修复 + ✨ 功能增强 + +> 修复"我的设备"页面本机设备样式、设备名称显示IP、消息发送失败三个问题 + +#### 🐛 Bug修复 — 本机设备与远程设备样式区分 +- **根因**: `buildMyDeviceCard` 对所有设备(包括本机)使用相同样式,用户无法区分本机与远程设备 +- **修复**: `TransferState` 新增 `localFingerprint` 字段标识本机设备;`buildMyDeviceCard` 通过 fingerprint 匹配判断本机设备,本机使用灰色背景+不可点击+"📱 本机"标签,远程设备保持原有样式并可点击进入聊天 + +#### 🐛 Bug修复 — 设备名称显示IP地址 +- **根因**: `displayAlias` getter 在 alias 为默认值时回退显示 IP 地址,不够友好 +- **修复**: `TransferDevice` 新增 `accountAlias` 字段(账号昵称/用户名),`displayAlias` 优先使用 accountAlias;`fromSignaling` 从信令数据提取 accountAlias/nickname/username;`buildMyDeviceCard` 不再显示 IP 地址行,改为显示账号昵称 + +#### 🐛 Bug修复 — 消息发送失败 +- **根因**: `sendTextMessage` 按固定顺序尝试通道(LocalSend→WiFi Direct→WebRTC→信令直发→WsRelay),对于 preferredTransport=wsRelay 的"我的设备",信令直发可能成功但服务器未正确路由,导致 WsRelay 通道永远不会被尝试 +- **修复**: `sendTextMessage` 新增根据设备 `preferredTransport` 优先选择通道逻辑;wsRelay 偏好设备优先通过 WsRelay 中转发送,失败后再尝试其他通道;非 wsRelay 偏好设备保持原有通道顺序 + +*** + +## \[12.18.3] - 2026-05-15 + +### 🐛 Bug修复 + ✨ 功能增强 + +> 修复附近设备点击后看不到消息的问题;局域网访问横幅增加二维码弹窗 + +#### 🐛 Bug修复 — 附近设备消息过滤 +- **根因**: 设备通过LAN发现时 `id=fingerprint`,但同一设备通过信令通道收到的消息 `peerDeviceId` 可能为信令服务器分配的ID(与fingerprint不同),导致聊天页面消息过滤条件 `m.peerDeviceId == widget.peerDevice.id` 无法匹配 +- **修复**: `TransferChatPage` 新增 `_collectPeerIds()` 方法,收集设备的所有已知ID(id、fingerprint、pairedDevices/discoveredDevices/myDevices中同fingerprint的设备ID),消息过滤改为多ID匹配+多sessionId匹配 + +#### ✨ 新功能 — 局域网访问二维码弹窗 +- 点击局域网访问横幅不再直接复制链接,改为弹出CupertinoModalPopup sheet +- 弹窗包含:二维码(QrImageView,圆角样式)、URL文本(可选中复制)、说明文字、复制链接按钮 +- 横幅右侧图标从剪贴板图标改为二维码扫描图标,更直观 + +*** + +## \[12.18.2] - 2026-05-15 + +### 🐛 Bug修复 — 协作画布返回黑屏 + +> 修复从传输聊天页进入协作画布后,点击返回出现黑屏的问题 + +#### 根因 +- `_openCanvas` 方法使用 `context.go()` 导航到画布页面,`go()` 会替换整个导航栈 +- 画布页面返回时导航栈为空,无上一页可回退,导致黑屏 + +#### 修复 +- 将 `context.go()` 改为 `Navigator.push()` + `CupertinoPageRoute`,保持导航栈完整 +- 通过 `ref.read(authProvider)` 获取 userId 并传递给 CanvasPage +- 移除不再使用的 `go_router` 和 `app_router.dart` import + +*** + +## \[12.18.1] - 2026-05-15 + +### 🐛 Bug修复 — 协作画布/屏幕共享信令服务未连接 + +> 修复canvasProvider和screenShareProvider各自创建未连接的SignalingService实例,导致协作画布和屏幕共享无法正常通信的问题 + +#### 根因 +- `canvasProvider` 直接 `SignalingService()` 创建新实例,未连接信令服务器 +- `screenShareProvider` 同样直接 `SignalingService()` 创建新实例,未连接信令服务器 + +#### 修复 +- 新增 `shared_signaling_provider.dart`,从 `transferProvider.notifier.pairingService.signalingService` 获取已连接实例 +- `canvasProvider` 改为 `ref.watch(sharedSignalingProvider)` 获取共享信令服务 +- `screenShareProvider` 改为 `ref.watch(sharedSignalingProvider)` 获取共享信令服务 + +*** + +## \[12.18.0] - 2026-05-15 + +### 🔧 Bug修复 + 功能增强 + +> 修复多个关键Bug,增强文件传输稳定性,新增发现页工具箱 + +#### 🐛 Bug修复 +- **USB Transport MissingPluginException**: 所有MethodChannel调用添加MissingPluginException捕获,无原生实现时优雅降级 +- **文件传输扫描卡死**: USB发现服务EventChannel添加MissingPluginException处理,设备扫描改为并行执行(Future.wait)避免阻塞UI +- **跨网文件传输失败**: discoverMyDevices自动连接信令服务器,扫描时自动触发connectSignaling,LocalSend连接超时从10s增至15s +- **搜索超时**: 搜索超时从8-10s增至12-15s,优化超时提示文案 +- **搜索结果HTML符号**: 搜索高亮内容应用stripHtml+decodeHtmlEntities清理,自动高亮输入也先cleanHtml +- **Feed API 414错误**: seen_ids/seen_hashes参数限制最多30个,避免URL过长 + +#### ✨ 新功能 +- **工作流页面新增会话流**: 底部Tab"发现"(工作流)页面新增"会话流📡"条目(RSS/XML订阅) +- **会话流登录状态**: 未登录点击提示"请先登录",已登录提示"即将开放" + +#### 🏗️ 重构 +- **file_transfer_page.dart拆分**: 1102行拆分为5个Mixin文件(每个<800行) + - `file_transfer_page.dart`(157行) — 主页面+build+TabBar + - `file_transfer_discovery_tab.dart`(394行) — 发现设备Tab + - `file_transfer_my_devices_tab.dart`(191行) — 我的设备Tab + - `file_transfer_records_tab.dart`(223行) — 传输记录Tab + - `file_transfer_debug_panel.dart`(144行) — 调试面板 + +*** + +## \[12.17.0] - 2026-05-15 + +### 🛡️ 密保问题系统 — 注册+管理+多验证方式 + +> 新增密保问题功能,支持注册时选填密保、个人中心管理密保、修改密码/邮箱/密保时多验证方式 + +#### 📝 注册页面 +- Step3新增密保问题选填区域(折叠展开式) +- CupertinoPicker弹窗选择8个预置密保问题 +- 密保答案输入框(1-50位) +- 注册API新增 `sec_question` / `sec_answer` 可选参数 + +#### 🛡️ 密保问题管理页面(新建) +- `security_question_page.dart` — 设置/修改密保问题 +- 状态卡片:显示密保是否已设置 + 当前问题 +- 身份验证:支持密码/原密保答案/邮箱回执 三选一 +- 新密保设置:选择问题 + 输入答案 +- 路由:`/settings/security-question` + +#### ⚙️ 账户设置页面 +- 新增「密保问题」管理行(修改密码行下方) +- 右侧显示状态徽标:已设置(绿)/未设置(灰) + +#### 🔑 修改密码页面(重构) +- 新增身份验证分段选择器:🔑 原密码 / 🛡️ 密保 / 📧 邮箱 +- 根据选择动态切换验证表单 +- API新增 `verify_method` 参数(password/sec_question/receipt) + +#### 📧 修改邮箱弹窗(重构) +- 新增验证方式分段选择器:📧 回执 / 🛡️ 密保 +- 密保验证时显示答案输入框 +- API新增 `verify_method` / `sec_answer` 参数 + +#### 🔧 底层服务变更 +- `ReceiptHelper`: 新增 `changeSecQuestion` ReceiptAction +- `UserModel`: 新增 `secQuestion` / `secQuestionText` / `hasSecQuestion` 字段 +- `UserSecurityService`: 新增 `secQuestions()` / `changeSecQuestion()` 方法 +- `UserSecurityService.changePassword()`: 支持 `verify_method` 多验证方式 +- `UserSecurityService.changeEmail()`: 支持 `verify_method` 多验证方式 +- `UserCenterService.changeEmail()`: 同步支持 `verify_method` 多验证方式 +- `AuthService`: 新增 `secQuestions()` / `changeSecQuestion()` 委托方法 +- `AuthProvider.register()`: 新增 `secQuestion` / `secAnswer` 参数 +- `AuthProvider.changePassword()`: 新增 `verifyMethod` / `secAnswer` 参数 + +#### 📐 验证逻辑规则 +| 操作 | 验证方式(三选一) | +|------|------------------| +| 修改密保 | 密码 / 原密保答案 / 邮箱回执 | +| 修改密码 | 原密码 / 密保答案 / 邮箱回执 | +| 修改邮箱 | 原邮箱回执 / 密保答案 | + +*** + +## \[12.16.1] - 2026-05-14 + +### 🐛 修复 — 底部Tab栏4项UI问题 + +> 修复底部导航栏显示异常:双文本重叠、图标文字间距过大、选中图标偏小、阴影范围不足 + +1. **双文本问题** — `app_shell.dart` + - 移除 GlassBottomBarTab 的 label 属性(设为空字符串) + - 文字显隐由 TabIconSprite 统一控制:选中时隐藏文字,未选中时显示单个文字 + +2. **去掉空白** — `tab_icon_sprite.dart` + - 移除 SVG 图标与文字之间的 SizedBox(height: 2) 间距 + - 文字紧贴图标底部,消除大片空白 + +3. **增大选中图标** — `tab_icon_sprite.dart` + - 选中时图标尺寸从 36px → 44px + - 光晕容器从 44px → 60px + - 浮动粒子从 5 个 → 7 个 + +4. **增大阴影范围** — `tab_icon_sprite.dart` + - 光晕半径乘数 1.4x,alpha 从 0.45 → 0.55 + - 渐变从 3 色阶 → 4 色阶,过渡更柔和 + - 新增 MaskFilter.blur(BlurStyle.normal, 8) 模糊滤镜 + - 粒子半径从 16+10 → 22+14,尺寸从 3.0 → 4.5 + - 粒子阴影 blurRadius 从 3 → 6,新增 spreadRadius: 2 + +*** + +## \[12.16.0] - 2026-05-14 + +### 🎮 游戏化系统v2 — 五大功能全量上线 + +> Phase 1~5 全部完成!包含:EXP独立体系+等级展示、勋章系统、每日任务、赛季排行榜、管理员后台完善 +> 同时修复管理员后台5个bug,修复客户端4个类型错误 + +#### ⚡ Phase 1: EXP独立体系 + 等级展示 +- 服务端: User.php新增exp()方法+nextlevelByExp()等级查找表(Lv1~Lv10) +- 服务端: UserCenter.php返回level/exp/exp_to_next/exp_progress/level_title +- 客户端: level_card.dart + level_utils.dart等级卡片组件 +- 测试: 7/7通过 + +#### 🏅 Phase 2: 勋章系统 +- 服务端: Achievement.php新增badges()/badgeDisplay()/checkBadges() +- 管理员: Badge CRUD 8文件 + UserBadge 5文件 +- 客户端: badge_wall_page + badge_provider + badge_icon +- 测试: 7/7通过 + +#### 📋 Phase 3: 每日任务系统 +- 服务端: Task.php(today/reportProgress/claim/claimPerfect/registerCustom) +- 管理员: DailyTask CRUD 8文件 + UserTask 5文件 +- 客户端: daily_task_page + task_provider + task_service + task_card +- 测试: 9/10通过 + +#### 🏆 Phase 4: 赛季排行榜 +- 服务端: Rank.php(seasons/leaderboard/myRank/claimReward) +- 管理员: RankSeason CRUD+结算 9文件 + RankRecord 5文件 +- 客户端: rank_page + rank_provider + rank_service + rank_item_card +- 测试: 7/7通过 + +#### 🔧 Phase 5: 管理员后台完善 +- UserExpLog只读管理5文件 +- 用户编辑表单新增EXP字段 +- 全部菜单权限插入 + +#### 🐛 Bug修复 +- feed_weight页面无法访问(创建Model/View/JS/Lang) +- userdeletion通过/拒绝无反应(重写为jQuery AJAX+Layer.confirm) +- user/user删除不生效(重写del()支持批量+清理15张关联表) +- 注销审核数据清理(添加新表到清理列表) +- 客户端: task_service/rank_item_card类型错误修复 + +#### 📊 数据库迁移 +- migrate_v11.sql: exp字段 + user_exp_log + tool_badge + tool_user_badge +- migrate_v12.sql: tool_daily_task + tool_user_task +- migrate_v13.sql: tool_rank_season + tool_rank_record +- tool_feed_weight_config表创建+18种内容类型默认数据 + +*** + +## \[12.15.0] - 2026-05-14 + +### ⚡ 新增 — EXP经验值日志后台管理 + 用户编辑表单EXP字段 + +> 后台新增EXP经验值日志只读管理模块,管理员可查看用户EXP变动记录; +> 用户编辑表单新增EXP经验值字段,支持手动修改经验值。 + +1. **UserExpLog 控制器** — `docs/toolsapi/application/admin/controller/user/UserExpLog.php` + - 继承Backend,只读模式(禁止编辑/删除) + - searchFields: id + +2. **UserExpLog 模型** — `docs/toolsapi/application/admin/model/UserExpLog.php` + - 表名 user_exp_log,自动时间戳(createtime) + - belongsTo User 关联(LEFT JOIN) + - getActionList(): 签到/任务/成就/勋章/完美日/排行/管理员 + +3. **列表视图** — `docs/toolsapi/application/admin/view/user/user_exp_log/index.html` + - 只读列表页面,data-operate-edit="false" data-operate-del="false" + - 仅保留刷新按钮 + +4. **前端JS** — `docs/toolsapi/public/assets/js/backend/user/user_exp_log.js` + - bootstrapTable列配置: ID/用户ID/用户昵称/行为/变动量(+绿色/-红色)/变动前/变动后/备注/创建时间 + - 行为搜索下拉: 签到/任务/成就/勋章/完美日/排行/管理员 + - 变动量格式化: 正数绿色label-success,负数红色label-danger + +5. **语言包** — `docs/toolsapi/application/admin/lang/zh-cn/user/user_exp_log.php` + - 中文字段映射: 用户ID/用户/行为/变动量/变动前/变动后/备注/创建时间 + +6. **用户编辑表单EXP字段** — `docs/toolsapi/application/admin/view/user/user/user/edit.html` + - 积分字段后新增EXP经验值输入框(number类型) + - 帮助文本: "经验值,修改后等级会自动重算" + +7. **用户语言包EXP条目** — `docs/toolsapi/application/admin/lang/zh-cn/user/user.php` + - 新增 'Exp' => '经验值' + +*** + +## \[12.14.0] - 2026-05-14 + +### 📋 新增 — 每日任务系统客户端(Flutter) + +> 每日任务系统前端4个文件:API服务层、状态管理层、任务卡片组件、每日任务页面。 +> 支持今日任务列表展示、进度上报、奖励领取、完美日额外奖励。 + +1. **TaskService API服务** — `lib/features/task/services/task_service.dart` + - getTodayTasks(): 获取今日任务列表 + - reportProgress(): 上报任务进度 + - claimReward(): 领取任务奖励 + - claimPerfectDay(): 领取完美日奖励 + - registerCustomTask(): 注册自定义任务 + - Riverpod Provider: taskServiceProvider + +2. **TaskProvider 状态管理** — `lib/features/task/providers/task_provider.dart` + - DailyTask 数据模型: id/name/icon/type/target/action/customUrl/customPage/expReward/scoreReward/isRandom/progress/completed/claimed/percent + - TaskSummary 统计模型: total/completed/claimed/isPerfectDay/perfectClaimed/date + - TaskState 状态: tasks/summary/isLoading/error + - TaskNotifier: loadTodayTasks/reportProgress/claimReward/claimPerfectDay + - Riverpod StateNotifierProvider: taskProvider + +3. **TaskCard 任务卡片组件** — `lib/shared/widgets/task_card.dart` + - iOS风格卡片: emoji图标+名称+进度条+状态按钮 + - 三种状态: 进行中(蓝色奖励预览) / 已完成(橙色领取按钮) / 已领取(✅已领取标签) + - 进度条: 蓝色进行中/绿色已完成 + - 完成边框高亮(绿色半透明) + - 深色模式适配 + +4. **DailyTaskPage 每日任务页面** — `lib/features/task/presentation/daily_task_page.dart` + - CupertinoPageScaffold + CustomScrollView + - 顶部统计区: 总任务/已完成/已领取 + 总进度条 + - 完美日提示: 橙色渐变卡片 + 领取按钮 + - 领取奖励弹窗: CupertinoAlertDialog 显示经验+积分 + - 错误状态展示 + - 深色模式适配 -> **Issue 10**: 搜索耗时>30秒 + 无关键词高亮 -> **Issue 11**: 搜索结果显示HTML原始标签(& <p>等) -> **Issue 12**: 搜索页面emoji→CupertinoIcons + 动态主题适配 -1. **SearchState 增加 currentKeyword + isBackgroundLoading** — `lib/features/search/providers/search_provider.dart` - - `currentKeyword`: getter 返回 `query.trim()`,供UI高亮使用 - - `isBackgroundLoading`: 后台加载更多结果时的状态标识 - - `copyWith` 支持新字段 -2. **搜索两阶段策略 + 超时** — `lib/features/search/providers/search_provider.dart` - - `search()`: `type='all'` 时先快速搜索 `limit:10` 立即返回,后台再加载全量结果 - - 所有API调用添加 `.timeout(Duration(seconds: 10))` 超时保护 - - `TimeoutException` 单独捕获,显示"搜索超时"提示 - - 新增 `_loadFullResultsInBackground()`: 后台合并去重加载全量结果 - - 新增 `_loadHighlightInBackground()`: 搜索后自动加载高亮数据 - - `loadMore()` / `searchWithHighlight()` 均添加10秒超时 -3. **FeedItem.fromJson HTML清理** — `lib/features/home/models/feed_model.dart` - - title/content/summary/author 字段解析时应用 `.cleanHtml` - - `_extractTitleFromExtra` 返回值也应用 `.cleanHtml` - - 利用已有 `StringX.cleanHtml` 扩展(解码HTML实体+去除HTML标签) -4. **FeedTypeIcon 映射** — `lib/features/home/models/feed_model.dart` - - 新增 `FeedTypeIcon` 类,44种数据源→CupertinoIcons映射 - - `getIcon(String? feedType)`: 获取对应图标,默认 `CupertinoIcons.doc_text_fill` -5. **搜索页面 emoji→CupertinoIcons** — `lib/features/search/presentation/search_page.dart` - - `_typeOptions`: `List<(String, String)>` → `List<(String, String, IconData)>` - - 类型筛选标签: emoji文字 → Icon+文字(如 🌐→search_circle_fill) - - 搜索建议标题: 💡 → `CupertinoIcons.lightbulb_fill` - - 热门搜索标题: 🔥 → `CupertinoIcons.flame_fill` - - 搜索历史标题: 🕐 → `CupertinoIcons.clock_fill` - - 空状态: 😟 → `exclamationmark_triangle` / 🔍 → `search` - - 统计芯片: 👍→`hand_thumbsup_fill` ⭐→`star_fill` 💬→`chat_bubble_fill` - - Feed图标: `item.feedIcon`(emoji) → `FeedTypeIcon.getIcon(item.feedType)` - - 分类统计: `stat.icon`(emoji) → `FeedTypeIcon.getIcon(stat.type)` - - `_buildStatChip`: `String emoji` → `IconData icon` -6. **搜索关键词高亮** — `lib/features/search/presentation/search_page.dart` - - 新增 `_buildHighlightedText()`: 返回 `RichText`,关键词部分用 `ext.accent` 高亮 - - title/summary/author 均使用高亮渲染 - - 大小写不敏感匹配 -7. **搜索高亮组件 emoji→CupertinoIcons** — `lib/features/search/presentation/search_highlight_section.dart` - - ✨ → `CupertinoIcons.sparkles` - - ✍️ → `CupertinoIcons.person_fill` + 独立Text -8. **动态主题适配** — 所有颜色使用 `ext.*` 主题变量 *** @@ -374,129 +879,6 @@ *** -## \[6.3.2] - 2026-05-14 - -### 🐛 修复 — 个人中心"登录中/正在加载"闪烁问题 - -> **问题**: 每次进入"我的"页面或冷启动App,顶部个人信息区总是先显示"登录中 正在加载"加载状态,体验差 -> -> **根因1**: `UserSecurityService._encodeMap`/`_decodeMap` 使用简单 `key=value&key=value` 格式序列化用户数据,无法处理嵌套对象(title/vip/devices/extra等),导致缓存数据损坏/丢失 -> -> **根因2**: `AuthState` 初始值 `isLoading: true`,而 `_init()` 是异步的,在缓存加载完成前UI始终显示加载状态 -> -> **修复**: -> 1. 用 `jsonEncode`/`jsonDecode` 替换破损的 `_encodeMap`/`_decodeMap` -> 2. 用 `AppKVStore`(Hive,同步读取)替换 `SecureStorage`(异步读取)存储用户缓存 -> 3. `AuthNotifier` 构造函数中同步加载缓存用户,消除加载闪烁 -> 4. `AuthState.isLoading` 默认值改为 `false` -> 5. `refreshUser()`/`login()`/`register()` 成功后同步更新缓存 -> 6. `logout()` 时清除缓存 -> 7. `_validateTokenInBackground()` 验证成功后也更新缓存 - -1. **UserModel** — `lib/features/auth/models/user_model.dart` - - UserModel/UserTitle/UserVerification/UserVip/UserCloudSpace/UserDevice/UserExtra 新增 `toJson()` 方法 -2. **UserSecurityService** — `lib/features/auth/services/user_security_service.dart` - - `_cacheUserInfo`: 改用 `jsonEncode` + `AppKVStore.setString` - - `getCachedUser`: 改为同步方法,使用 `AppKVStore.getString` + `jsonDecode` - - 新增 `clearCachedUser()` 方法 - - 删除破损的 `_encodeMap`/`_decodeMap` -3. **AuthService** — `lib/features/auth/services/auth_service.dart` - - `getCachedUser`: 返回类型从 `Future` 改为 `UserModel?` - - 新增 `clearCachedUser()` 方法 -4. **AuthNotifier** — `lib/features/auth/providers/auth_provider.dart` - - 构造函数: 通过 `_loadInitialState()` 同步从 AppKVStore 加载缓存用户 - - `_init()`: 有缓存用户时直接显示,后台验证Token - - `_validateTokenInBackground()`: 验证成功后更新缓存 - - `_saveUserCache()`: 新增私有方法,统一缓存写入逻辑 - - `login()`/`register()`: 成功后调用 `_saveUserCache` - - `refreshUser()`: 刷新后调用 `_saveUserCache` - - `logout()`: 调用 `AuthService.clearCachedUser()` -5. **UserCenterPage** — `lib/features/user_center/presentation/user_center_page.dart` - - 加载条件简化为 `!authState.isInitialized && user == null` - -*** - -## \[6.4.0] - 2026-05-14 - -### 📺 新功能 — 屏幕共享+受限操作 (F3-01~15) - -> **内容**: 屏幕共享模块,支持一方共享屏幕、另一方在预设热区内执行受限远程操作,30分钟自动断开 - -1. **InputAction 模型** — `lib/features/collaboration/screen_share/models/input_action.dart` - - InputActionType枚举: tap/swipe/longPress - - InputAction: 远程输入动作模型(sessionId/zoneId/action/position/endPosition/timestamp) - - HotZone: 热区定义(id/label/rect/allowedActions/semanticAction/emoji) - - InputActionLog: 操作日志记录 - - HotZone.defaultZones(): 默认热区(顶部栏/内容区/底部栏/返回) - - toJson/fromJson序列化 -2. **ScreenCaptureService** — `lib/features/collaboration/screen_share/services/screen_capture_service.dart` - - MethodChannel `xianyan/screen_capture` 跨平台屏幕捕获API - - Android: MediaProjection API / iOS: ReplayKit / Desktop: screen_capturer - - requestPermission/startCapture/stopCapture/pauseCapture/resumeCapture - - EventChannel 帧流推送(Uint8List) - - textureId 用于 Texture widget 渲染 -3. **RemoteInputService** — `lib/features/collaboration/screen_share/services/remote_input_service.dart` - - 热区碰撞检测 hitTest(Offset) → HotZone? - - 受限操作验证: 仅允许热区定义的InputActionType - - 语义动作执行 executeSemanticAction(zoneId, actionType, action) - - 操作日志记录(最大200条) - - RemoteInputResult/RemoteInputRejectReason 安全拒绝原因 -4. **ScreenShareProvider** — `lib/features/collaboration/screen_share/providers/screen_share_provider.dart` - - Riverpod StateNotifierProvider - - ScreenShareState: isSharing/isViewing/sessionId/peerId/hotZones/actionLogs/duration/isPaused - - 30分钟自动超时断开 - - startSharing/stopSharing/startViewing/stopViewing - - handleRemoteInput/sendRemoteInputToPeer - - authorizeSession/checkTimeout/togglePause -5. **ScreenSharePage** — `lib/features/collaboration/screen_share/pages/screen_share_page.dart` - - iOS风格CupertinoPageScaffold - - 视频显示区(Texture widget) - - 热区覆盖层(HotZoneOverlayPainter, 半透明高亮) - - 点击热区→发送remoteInput信号 - - 计时器显示(已用/剩余, 进度条) - - 暂停/继续/结束控制 - - 授权确认对话框(共享方) - - 热区图例(观看方) -6. **WebRTC集成** — `lib/features/file_transfer/services/transport/webrtc_service.dart` - - createScreenShareOffer(): 创建屏幕共享视频流Offer - - createScreenShareAnswer(): 应答屏幕共享 - - setScreenShareRemoteAnswer/addScreenShareIceCandidate - - stopScreenShare(): 停止屏幕共享 - - onScreenShareStream: 观看方视频流接收 -7. **TransferChatPage** — `lib/features/file_transfer/presentation/pages/transfer_chat_page.dart` - - 导航栏新增📺共享按钮(蓝色药丸样式) - - 点击发送screenShareOffer信令 -8. **信令处理** — `lib/features/file_transfer/providers/transfer_signaling_handler.dart` - - screenShareOffer: 显示授权对话框 - - screenShareAnswer: 通知共享已接受 - - screenShareStop: 通知共享已结束 - - remoteInput: 执行语义动作 - - 回调属性: onScreenShareOffer/onScreenShareAnswer/onScreenShareStop/onRemoteInput -9. **路由注册** — `lib/core/router/app_router.dart` - - 新增/screen-share/:id路由 → ScreenSharePage - - AppRoutes.screenShare常量 - -*** - -## \[6.3.1] - 2026-05-14 - -### 🐛 修复 — 日签卡片 TextPainter.textDirection 崩溃 - -> **问题**: 足迹页日签卡片渲染时抛出 `StateError (Bad state: TextPainter.textDirection must be set to a non-null value before using the TextPainter.)`,导致页面白屏闪退 -> -> **根因**: `card_renderer.dart` 中 5 处 `TextPainter` 构造缺少 `textDirection` 参数,Flutter 要求在调用 `layout()` 前必须设置 -> -> **修复**: 为所有 5 处 TextPainter 添加 `textDirection: TextDirection.ltr` - -1. **card_renderer.dart** — `lib/features/daily_card/presentation/widgets/card_renderer.dart` - - `_buildBigQuoteContent()` L230: 添加 textDirection - - `_buildMagazineStyle()` L354: 添加 textDirection - - `_buildChineseInkContent()` L550: 添加 textDirection - - `_buildGlassmorphismContent()` L699: 添加 textDirection - - `_buildContentArea()` L839: 添加 textDirection - - `_SealPainter` 已有 textDirection,无需修改 - -*** ## \[6.3.0] - 2026-05-14 @@ -668,208 +1050,6 @@ ### 🚀 传输扩展功能 — 送达回执 + 断点续传 + 文件分流 -#### 📨 送达回执 (F5) -1. **DeliveryReceiptService** — `delivery_receipt_service.dart`: - - 通过信令服务 `delivery-ack` 消息类型实现端到端回执 - - 支持 `sending → sent → delivered → read` 四种状态 - - 自动监听信令消息并更新回执状态 - - 修复 SignalingService API 调用(`onMessage` 而非 `messageStream`,`sendCustomMessage` 返回 `void`) - -2. **ReceiptIndicator 组件** — `receipt_indicator.dart`: - - 显示消息送达状态图标(发送中/已发送/已送达/已读) - - 集成到 TransferBubble 中 - -3. **TransferMessage 扩展** — `transfer_message.dart`: - - 新增 `DeliveryStatus` 枚举和 `deliveryStatus`/`deliveredAt`/`readAt` 字段 - - 新增 `TransferMessageType.voice` 和语音消息字段 - -#### ⏸️ 断点续传 (F4) -4. **WsRelayResumeHandler** — `ws_relay_resume_handler.dart`: - - 管理文件传输的暂停/恢复/取消/重试逻辑 - - `TransferResumeState` 记录已接收块索引和缺失块 - - `ResumeDecision` 智能决策续传策略(缺失>50%建议重新传输) - - 通过 `chunkAck` 确认和 `resumeRequest` 请求缺失块 - -5. **ChunkAssembler 拆分** — `ws_relay_chunk_assembler.dart`: - - 从 `ws_relay_service.dart` 拆分出分块组装器 - - 新增 `missingChunks` 缺失块检测 - - 新增 `verifyChecksum` 校验和验证 - - 新增 `toResumeState`/`fromResumeState` 序列化支持 - -6. **WsRelayService 断点续传集成** — `ws_relay_service.dart`: - - 发送时每块发送 `chunkAck` 确认 - - 接收不完整时自动发送 `resumeRequest` 请求缺失块 - - 新增 `sendResumeChunks` 方法重传指定块 - - 新增 `pauseTransfer`/`resumeTransfer`/`cancelTransfer` 方法 - - 发送循环中支持暂停等待和取消检测 - -7. **TransferNotifier 断点续传** — `transfer_notifier.dart`: - - `pauseTask`/`resumeTask`/`cancelTask` 集成 WsRelay 断点续传 - - 新增 `retryTask` 方法:优先续传缺失块,否则重新开始 - -#### 📂 文件分流 -8. **transfer_provider.dart 拆分** (1475行→4个文件): - - `transfer_state.dart`: TransferState 模型 + StateUpdater/StateReader/SystemMessenger typedef - - `transfer_notifier.dart`: 核心业务逻辑 - - `transfer_pairing_handler.dart`: 设备发现和配对功能 - - `transfer_signaling_handler.dart`: 信令处理、WebRTC、WsRelay、送达回执 - - `transfer_provider.dart`: barrel export + provider 定义 - -9. **SignalingMessageType 扩展** — 14种新消息类型: - - `deliveryAck`/`chunkAck`/`resumeRequest` — 回执与续传 - - `voiceMeta` — 语音消息 - - `cloudCacheNotify` — 云缓存通知 - - `canvasStroke`/`canvasCursor`/`canvasJoin`/`canvasLeave`/`canvasSnapshot` — 协作画布 - - `screenShareOffer`/`screenShareAnswer`/`screenShareIce`/`screenShareControl` — 屏幕共享 - -10. **数据库迁移 v13** — 6张新表: - - `delivery_receipts` — 送达回执记录 - - `voice_messages` — 语音消息 - - `cloud_cache_records` — 云缓存记录 - - `transfer_stats` — 传输统计 - - `clipboard_items` — 剪贴板条目 - - `canvas_strokes` — 画布笔画 - -11. **服务端信令路由更新** — `signaling_server.php`: - - 新增14种消息类型的路由转发 - -12. **服务端Node.js更新** — `index.js` (已上传服务器): - - 新增 `pair-request`/`pair-accept`/`pair-reject` 服务端处理(含持久化) - - 新增 `heartbeat` 心跳消息类型 - - 新增 `_findPeer` 全局设备查找方法 - - 新增 `_loadPairingRecords`/`_savePairingRecords` 配对记录持久化 - - 扩展 `handledTypes` 列表支持新消息类型 - - 通用转发机制支持 `delivery-ack`/`chunk-ack`/`resume-request`/`voice-meta`/`canvas-stroke` 等 - - PM2 重启验证通过,接口测试全部通过 - -13. **接口文档更新** — `API_FILE_TRANSFER_DOC.md`: - - 新增第七章 v11.0.0 新增协议(通用转发/送达回执/断点续传/配对信令/语音/画布/屏幕共享/剪贴板) - - 新增第八章服务器架构说明 -设置导致图片/视频被当作普通文件 - -*** - -## \[5.33.0] - 2026-05-10 - -### 🚀 新增 — 智能推荐优化 + 标签云系统 (Task 11 & Task 12) - -1. **InteractionNotifier 智能推荐方法** — `user_center/providers/interaction_provider.dart`: - - `dislikeContent()`: 标记不喜欢某内容,支持传入原因 - - `blockContent()`: 屏蔽某内容 - - `getDislikedIds()`: 获取已不喜欢的ID集合,用于Feed过滤 - - `getBlockedIds()`: 获取已屏蔽的ID集合,用于Feed过滤 -2. **标签云状态管理** — `user_center/providers/tag_cloud_provider.dart`: - - `TagItem`: 标签数据模型 (name/count/pinyinInitial/targetId/targetType) - - `TagCloudState`: 标签云状态 (isLoading/error/tags/filterInitial) - - `TagCloudNotifier`: loadTags()/addTag()/filterByInitial() - - 拼音首字母自动提取 (PinyinHelper) - - 标签聚合统计 + 拼音首字母排序 - - Riverpod StateNotifierProvider -3. **标签云展示页面** — `user_center/presentation/tag_cloud_page.dart`: - - iOS风格 CupertinoPageScaffold + CupertinoNavigationBar - - 顶部拼音索引栏: A-Z + # 横向滚动选择器,活跃字母高亮 - - 标签云区域: Wrap布局,频率权重渲染 (高频大字号深色/中频/低频浅色) - - 点击标签: 弹出详情对话框 (使用次数/拼音/关联ID/类型) - - 长按标签: 弹出ActionSheet (查看关联/删除) - - 右上角添加按钮: 弹出输入框 (标签名+关联ID+类型选择) - - GlassContainer毛玻璃容器包裹 - - flutter_animate入场动画 (fadeIn + scale) - - CupertinoSliverRefreshControl下拉刷新 - - 空状态/加载中/错误状态展示 -4. **路由** — `app_router.dart`: AppRoutes.tagCloud + iosSlideTransition -5. **导航入口** — `user_center_page.dart`: 快捷入口网格新增「🏷️ 标签云」(CupertinoIcons.tag, 0xFFFF9500橙色) - -*** - -## \[5.33.0] - 2026-05-10 - -### 🚀 新增 — 智能模式切换功能 (Task 10) - -1. **SmartModeService** — `core/services/smart_mode_service.dart`: - - BrowseMode 枚举: hd / standard / saver - - 自动模式: 根据网络类型自动切换(WiFi→高清/移动→标准/离线→省流) - - 手动模式: 关闭自动后可手动选择浏览模式 - - KV持久化: AppKVStore存储自动/手动模式偏好 - - Riverpod Provider: browseModeProvider + isAutoModeProvider -2. **智能模式设置页面** — `settings/presentation/smart_mode_settings_page.dart`: - - iOS风格CupertinoPageScaffold + CupertinoNavigationBar - - 📊 状态卡片: 当前模式+网络类型+模式描述(GlassContainer elevated) - - 🔄 自动模式开关: CupertinoSwitch,开启时根据网络自动切换 - - 📱 手动模式选择: 3个选项(高清/标准/省流),自动模式关闭时可用 - - 📋 模式说明: 🎬高清(WiFi/有线) / 📱标准(移动网络) / 📶省流(离线/弱网) - - flutter_animate入场动画 + GlassContainer毛玻璃容器 - - AppTheme/AppTypography/AppSpacing/AppRadius统一设计令牌 -3. **路由** — 已注册于 `app_router.dart` (AppRoutes.smartModeSettings + iosSlideTransition) -4. **导航入口** — 已添加于 `general_settings_page.dart` 性能分组"智能模式"行 - -*** - -## \[5.32.0] - 2026-05-10 - -### 🚀 新增 — 离线浏览缓存功能 (Task 7) - -1. **ConnectivityService** — `core/services/connectivity_service.dart`: - - 网络连接状态监听服务,封装 connectivity_plus - - NetworkType 枚举: wifi / mobile / ethernet / vpn / none / other - - 网络状态流 Stream,供全局监听 - - isOnline / isOffline 便捷属性 - - init() 初始化 + 自动监听网络变化 - - Riverpod Provider: networkTypeProvider (StreamProvider) + isOnlineProvider (Provider) -2. **InteractionNotifier 离线缓存** — `user_center/providers/interaction_provider.dart`: - - recordView() 离线时自动缓存浏览记录到 Hive offlineQueue - - 在线请求失败时降级缓存到本地 - - _cacheViewOffline(): 写入 offline_view_queue,队列上限100条 - - syncCachedViews(): 联网后批量同步缓存的浏览记录到服务端 - - 同步完成后自动清除本地缓存队列 - -*** - -## \[5.31.0] - 2026-05-10 - -### 🚀 新增 — 通知设置页面 - -1. **通知设置页面** — `settings/presentation/notification_settings_page.dart`: - - iOS风格CupertinoPageScaffold + CupertinoNavigationBar - - 📱 每日推荐推送 — CupertinoSwitch开关(默认开启) + CupertinoTimerPicker时间选择(默认8:00) - - 📝 签到提醒 — CupertinoSwitch开关(默认关闭) + CupertinoTimerPicker时间选择(默认9:00) - - 📊 学习进度提醒 — CupertinoSwitch开关(默认关闭) - - 🔋 充电时稍后读提醒 — CupertinoSwitch开关(默认关闭) - - 毛玻璃容器(GlassContainer + GlassDepth.base)包裹各设置项 - - 开关切换时调用NotificationService调度/取消通知 + NotificationScheduler同步主开关 - - SharedPreferences持久化学习进度/稍后读开关状态 - - AppToast成功提示 + HapticService触觉反馈 - - AppTheme/AppTypography/AppSpacing/AppRadius统一设计令牌 -2. **通知设置Provider** — `NotificationSettingsNotifier`: - - NotificationSettingsState: 4个开关 + 2个时间(小时/分钟) - - loadFromPrefs(): 从NotificationService + SharedPreferences加载初始状态 - - setDailyRecommend/setSigninReminder: 双写NotificationService + NotificationScheduler - - _syncMainSwitch(): 任一开关开启则同步主通知开关 -3. **路由** — 已注册于 `app_router.dart` (AppRoutes.notificationSettings + iosSlideTransition) -4. **导航入口** — 已存在于 `general_settings_page.dart` 通知分组"推送通知"行 - -*** - -## \[5.30.0] - 2026-05-10 - -### 🚀 新增 — 二维码登录页面 - -1. **二维码登录页面** — `auth/presentation/qrcode_login_page.dart`: - - 📷 扫码登录Tab: 使用mobile_scanner扫描Web端二维码,解析URL中code参数 - - 📱 生成二维码Tab: 调用qrcodeGenerate()生成二维码,使用qr_flutter展示 - - iOS风格CupertinoPageScaffold + CupertinoNavigationBar - - 毛玻璃容器(GlassContainer)包裹各区域 - - 扫描框四角标记 + 半透明遮罩(CustomPainter) - - 相机权限拒绝时优雅降级显示提示文字 - - 扫码确认后自动弹出成功对话框,2秒后自动返回 - - 二维码过期自动提示 + 刷新按钮 - - flutter_animate入场动画(fadeIn/slideY/scale) - - AppTheme/AppTypography/AppSpacing/AppRadius统一设计令牌 -2. **二维码登录Provider** — `auth/providers/qrcode_login_provider.dart`: - - QrcodeLoginState: step(idle/scanning/confirming/success/error) + qrCode + errorMessage - - QrcodeLoginNotifier: confirmLogin(code) + generateQrcode() + cancel() + resetToScan() + clearError() - - qrcodeLoginProvider: StateNotifierProvider - -*** ### 已归档版本 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b56b00c6..618209b5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -95,6 +95,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/spec/readlater_chat_spec.md b/docs/spec/readlater_chat_spec.md new file mode 100644 index 00000000..faa250b9 --- /dev/null +++ b/docs/spec/readlater_chat_spec.md @@ -0,0 +1,688 @@ +# 📖 稍后读会话 + ChatFlowPage 增强 — 开发文档 + +> **版本**: v13.0.0 +> **创建时间**: 2026-05-15 +> **状态**: 🚧 开发中 +> **优先级**: P1 (高) +> **负责模块**: inspiration / home / core + +--- + +## 一、功能概述 + +### 1.1 核心需求 + +1. **稍后读会话** — 在工作流(InspirationPage)会话列表中新增"稍后读"内置会话,采用聊天对话风格,类似微信"文件传输助手" +2. **句子详情 → 稍后读** — 主页句子详情Sheet的"稍后读"按钮,点击后将句子信息发送到稍后读会话 +3. **系统分享接收** — 用户在其他App中通过系统分享面板选择"闲言",内容自动进入稍后读会话 +4. **ChatFlowPage 增强** — 丰富会话流页面,新增链接预览/文档卡片/句子卡片等气泡样式,所有会话受益 +5. **稍后读设置** — 稍后读页面AppBar右侧设置按钮,管理稍后读相关配置 + +### 1.2 设计原则 + +- 复用现有 `ChatFlowPage` 聊天框架,通过 `ChatSessionType.readlater` 区分特殊逻辑 +- 新增气泡组件对所有会话类型可用,不限于稍后读 +- 系统分享接收使用 `receive_sharing_intent` 本地包,跨平台兼容 +- 遵循 iOS 26 风格,使用项目统一设计令牌 + +--- + +## 二、架构设计 + +### 2.1 整体数据流 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 数据来源 │ +│ ┌──────────┐ ┌──────────────┐ ┌─────────────────────┐ │ +│ │ 句子详情 │ │ 系统分享面板 │ │ 稍后读会话输入框 │ │ +│ │ 稍后读按钮│ │ (其他App) │ │ (用户手动发送) │ │ +│ └────┬─────┘ └──────┬───────┘ └──────────┬──────────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ SharingReceiverService (统一入口) │ │ +│ │ - receive_sharing_intent (Android/iOS) │ │ +│ │ - URL参数 (Web) │ │ +│ │ - 命令行参数 (Windows) │ │ +│ └────────────────────┬────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ ChatMessageService (消息持久化) │ │ +│ │ - sendText / sendImage / sendFile / sendVideo │ │ +│ │ - sendLink / sendDocument / sendReadLaterSentence│ │ +│ │ - conversationId: 'readlater' │ │ +│ └────────────────────┬────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ ChatFlowPage (增强版) │ │ +│ │ - 动态标题/配置 (按sessionType) │ │ +│ │ - 新气泡: LinkBubble / DocumentBubble / │ │ +│ │ SentenceCardBubble │ │ +│ │ - 稍后读设置面板 │ │ +│ └─────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 模块依赖关系 + +``` +home (句子详情Sheet) + │ toggleReadLater() + └──→ inspiration (ChatMessageService) + │ sendReadLaterSentence() + └──→ Drift数据库 (chat_messages表) + +core (SharingReceiverService) + │ 接收分享数据 + └──→ inspiration (ChatMessageService) + │ sendText/sendImage/sendFile/sendLink + └──→ Drift数据库 (chat_messages表) + +inspiration (ChatFlowPage) + │ 读取消息 + └──→ ChatProvider (chatMessagesProvider['readlater']) + └──→ Drift数据库 (chat_messages表) +``` + +--- + +## 三、数据模型扩展 + +### 3.1 ChatMessageType 新增枚举 + +| 枚举值 | id | label | emoji | 说明 | +|--------|-----|-------|-------|------| +| `link` | `'link'` | 链接消息 | 🔗 | URL + OG预览卡片 | +| `document` | `'document'` | 文档消息 | 📄 | PDF/Word/Excel等文档卡片 | +| `readlaterSentence` | `'readlater_sentence'` | 稍后读句子 | 💬 | 从句子详情收藏的句子卡片 | + +**文件**: `lib/features/inspiration/models/chat_message.dart` + +### 3.2 ChatSessionType 新增枚举 + +| 枚举值 | id | label | +|--------|-----|-------| +| `readlater` | `'readlater'` | 稍后读 | + +**文件**: `lib/features/inspiration/models/chat_session.dart` + +### 3.3 ChatMessage.meta 字段约定 + +#### 链接消息 (type=link) + +```json +{ + "url": "https://flutter.dev", + "title": "Flutter - Beautiful native apps", + "description": "Build apps for any screen", + "imageUrl": "https://flutter.dev/favicon.png", + "sourceApp": "Chrome" +} +``` + +#### 文档消息 (type=document) + +```json +{ + "documentType": "pdf", + "pageCount": 12 +} +``` + +#### 稍后读句子 (type=readlaterSentence) + +```json +{ + "sentenceId": "12345", + "feedType": "yike", + "feedName": "一刻", + "likeCount": 128, + "commentCount": 32, + "favoriteCount": 56, + "views": 1024 +} +``` + +--- + +## 四、UI设计 + +### 4.1 稍后读会话入口 + +在 `InspirationPage` 会话列表中,"稍后读"作为**置顶**内置会话显示: + +``` +┌──────────────────────────────────┐ +│ 📌 置顶 │ +│ ┌────────────────────────────┐ │ +│ │ 📖 稍后读 HOT │ │ +│ │ 收藏内容,稍后阅读 │ │ +│ │ 刚刚 › │ │ +│ └────────────────────────────┘ │ +│ ┌────────────────────────────┐ │ +│ │ 🔍 发现 │ │ +│ │ 今日一读/分类浏览 │ │ +│ └────────────────────────────┘ │ +│ 💬 对话 │ +│ ┌────────────────────────────┐ │ +│ │ 📁 文件传输助手 │ │ +│ │ 传输文件/设备聊天 │ │ +│ └────────────────────────────┘ │ +│ ... │ +└──────────────────────────────────┘ +``` + +### 4.2 稍后读聊天页面 + +``` +┌──────────────────────────────────┐ +│ ← 📖 稍后读 ⚙️ │ ← AppBar +├──────────────────────────────────┤ +│ │ +│ 🤖 ┌──────────────────────┐ │ ← 句子卡片气泡 +│ │ 💬 灵感句子 │ │ +│ │ ────────────────── │ │ +│ │ "人生如逆旅, │ │ +│ │ 我亦是行人" │ │ +│ │ │ │ +│ │ —— 苏轼 │ │ +│ │ 📖 《临江仙》 │ │ +│ │ │ │ +│ │ ❤️ 128 💬 32 ⭐ 56 │ │ +│ │ [已读✓] [分享] │ │ +│ └──────────────────────┘ │ +│ │ +│ ┌──────────────────┐ │ ← 用户发送的链接 +│ │ 🔗 flutter.dev │ │ +│ │ Flutter官网 │ │ +│ │ [打开] [复制] │ │ +│ └──────────────────┘ │ +│ │ +│ 🤖 ┌──────────────────────┐ │ ← 文档卡片气泡 +│ │ 📕 报告.pdf │ │ +│ │ 2.3 MB · PDF文档 │ │ +│ │ [打开] [分享] │ │ +│ └──────────────────────┘ │ +│ │ +│ ┌──────────────────┐ │ ← 用户发送的图片 +│ │ 🖼️ │ │ +│ │ (图片缩略图) │ │ +│ └──────────────────┘ │ +│ │ +├──────────────────────────────────┤ +│ 📎 [输入文字/链接...] 📤 │ ← 输入栏 +└──────────────────────────────────┘ +``` + +### 4.3 新增气泡组件设计 + +#### ChatLinkBubble — 链接预览卡片 + +``` +┌──────────────────────────────────┐ +│ 🔗 链接 │ +│ ┌────────────────────────────┐ │ +│ │ 🖼️ OG图片 (如有) │ │ +│ └────────────────────────────┘ │ +│ Flutter - Beautiful native apps │ +│ Build apps for any screen │ +│ 🔗 flutter.dev │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ 🌐 打开 │ │ 📋 复制 │ │ +│ └──────────┘ └──────────┘ │ +└──────────────────────────────────┘ +``` + +- 毛玻璃卡片背景,accent色左边框 +- OG元数据: title / description / imageUrl 从 `meta` 读取 +- 图片使用 `CachedNetworkImage`,无图时显示域名首字母头像 +- 操作按钮: 打开链接(url_launcher) + 复制链接(Clipboard) + +#### ChatDocumentBubble — 文档卡片 + +``` +┌──────────────────────────────────┐ +│ ┌────┐ │ +│ │ 📕 │ 项目报告.pdf │ +│ │ │ 2.3 MB · PDF文档 │ +│ └────┘ 2026-05-15 │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ 📂 打开 │ │ ↗️ 分享 │ │ +│ └──────────┘ └──────────┘ │ +└──────────────────────────────────┘ +``` + +- 左侧大emoji图标(按类型: PDF📕/Word📘/Excel📗/PPT📙/ZIP📦/TXT📝/其他📄) +- 右侧文件名+大小+类型+日期 +- 操作按钮: 打开文件 + 分享文件 + +#### ChatSentenceCardBubble — 句子卡片 + +``` +┌──────────────────────────────────┐ +│ 💬 灵感句子 │ +│ ──────────────────────────── │ +│ │ +│ "人生如逆旅,我亦是行人" │ +│ │ +│ —— 苏轼 │ +│ 📖 《临江仙》 │ +│ │ +│ ❤️ 128 💬 32 ⭐ 56 │ +│ ──────────────────────────── │ +│ [📖 已读] [↗️ 分享] │ +└──────────────────────────────────┘ +``` + +- 渐变背景(按feedType分类着色,复用现有`_gradientColors`逻辑) +- 白色文字,shimmer动画 +- 底部统计栏 + 操作按钮 +- 点击跳转句子详情Sheet + +### 4.4 稍后读设置面板 + +点击AppBar右侧⚙️图标,弹出CupertinoActionSheet: + +| 选项 | 图标 | 说明 | +|------|------|------| +| 稍后读列表 | 📋 | 跳转原ReadLaterPage(服务端API版本) | +| 标记全部已读 | ✅ | 将所有未读消息标记为已读 | +| 清空稍后读 | 🗑️ | 二次确认后清空所有消息(红色警告) | +| 导出内容 | 📤 | 导出为JSON/Markdown | +| 统计信息 | 📊 | 显示总数/已读/未读/按类型统计 | +| 分享接收 | 🔗 | 系统分享接收开关 | +| 取消 | — | 关闭面板 | + +### 4.5 ChatFlowPage 动态配置 + +| 配置项 | 普通会话(chat) | 稍后读会话(readlater) | +|--------|---------------|---------------------| +| AppBar标题 | 💬 会话流 | 📖 稍后读 | +| 分类栏 | 显示(7个分类) | 隐藏 | +| 新建会话按钮 | 显示 | 隐藏 | +| 设置按钮 | 会话设置页 | 稍后读设置面板 | +| 输入框placeholder | "说点什么..." | "添加链接/文字..." | +| 空消息提示 | "开始你的灵感之旅 ✨" | "收藏内容,稍后阅读 📖" | +| 附件按钮 | 显示(8种类型) | 显示(8种类型) | +| 回复功能 | 支持 | 支持 | +| 推送功能 | 支持 | 不支持(稍后读无推送) | + +--- + +## 五、系统分享接收 + +### 5.1 第三方库 + +- **库名**: `receive_sharing_intent` +- **版本**: `^1.8.1` +- **引用方式**: 本地包 `packages/receive_sharing_intent/` +- **跨平台**: Android ✅ / iOS ✅ / Web ⚠️(URL参数) / Windows ⚠️(命令行参数) + +### 5.2 Android 配置 + +**文件**: `android/app/src/main/AndroidManifest.xml` + +在主Activity中新增intent-filter: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### 5.3 iOS 配置 + +**文件**: `ios/Runner/Info.plist` + +新增URL Scheme和App Group配置,支持ShareExtension。 + +### 5.4 SharingReceiverService + +**文件**: `lib/core/services/sharing_receiver_service.dart` + +```dart +class SharingReceiverService { + // 单例 + static final instance = SharingReceiverService._(); + + // 初始化 (main.dart中调用) + Future init(); + + // 监听分享数据流 + StreamSubscription? _textSub; + StreamSubscription? _fileSub; + + // 处理文本/链接分享 + void _handleText(String text); + + // 处理文件分享(图片/视频/文档) + void _handleFile(SharedFile file); + + // Web端: URL参数解析 + void _handleWebShare(Uri uri); + + // Windows端: 命令行参数解析 + void _handleWindowsShare(List args); + + // 释放资源 + void dispose(); +} +``` + +### 5.5 分享数据处理逻辑 + +| 分享类型 | 判断条件 | 处理方式 | +|---------|---------|---------| +| 纯文本 | 无URL | `sendText(conversationId: 'readlater')` | +| 链接 | 含http/https URL | `sendLink(conversationId: 'readlater')` + OG元数据 | +| 图片 | mimeType=image/* | `sendImage(conversationId: 'readlater')` | +| 视频 | mimeType=video/* | `sendVideo(conversationId: 'readlater')` | +| 文档 | mimeType=application/* | `sendDocument(conversationId: 'readlater')` | +| 多文件 | SEND_MULTIPLE | 逐个处理,批量写入 | + +--- + +## 六、稍后读按钮 → 稍后读会话 数据流 + +### 6.1 现有流程 + +``` +SentenceDetailSheet "稍后读" 按钮 + → onReadLater回调 + → HomeInteractionMixin.toggleReadLater(id) + → FeedService.action('readlater') // 服务端标记 + → 本地状态更新 (isReadLater: true) +``` + +### 6.2 增强流程 + +``` +SentenceDetailSheet "稍后读" 按钮 + → onReadLater回调 + → HomeInteractionMixin.toggleReadLater(id) + → FeedService.action('readlater') // 服务端标记(保持不变) + → 本地状态更新 (isReadLater: true) // 保持不变 + → ChatMessageService.sendReadLaterSentence( // 新增: 写入稍后读会话 + conversationId: 'readlater', + text: sentence.text, + author: sentence.author, + source: sentence.feedName, + meta: { + 'sentenceId': sentence.id, + 'feedType': sentence.feedType, + 'feedName': sentence.feedName, + 'likeCount': sentence.likeCount, + 'views': sentence.views, + } + ) + → ChatSessionNotifier.refreshFromChat() // 更新会话列表最后消息 +``` + +### 6.3 取消稍后读 + +取消稍后读时,不删除稍后读会话中的消息(保留历史记录),仅在句子卡片上显示"已移出稍后读"状态标记。 + +--- + +## 七、文件结构变更 + +### 7.1 新增文件 + +| 文件路径 | 说明 | +|---------|------| +| `lib/core/services/sharing_receiver_service.dart` | 统一分享接收服务 | +| `lib/features/inspiration/presentation/widgets/chat_link_bubble.dart` | 链接预览卡片气泡 | +| `lib/features/inspiration/presentation/widgets/chat_document_bubble.dart` | 文档卡片气泡 | +| `lib/features/inspiration/presentation/widgets/chat_sentence_card_bubble.dart` | 句子卡片气泡 | +| `packages/receive_sharing_intent/` | 本地包: 分享接收库 | + +### 7.2 修改文件 + +| 文件路径 | 修改内容 | +|---------|---------| +| `lib/features/inspiration/models/chat_message.dart` | ChatMessageType新增link/document/readlaterSentence | +| `lib/features/inspiration/models/chat_session.dart` | ChatSessionType新增readlater | +| `lib/features/inspiration/presentation/chat_flow_page.dart` | 动态标题/配置/新气泡分发/稍后读设置 | +| `lib/features/inspiration/presentation/widgets/chat_bubble.dart` | 新消息类型分发逻辑 | +| `lib/features/inspiration/providers/chat_session_provider.dart` | 新增readlater内置会话 | +| `lib/features/inspiration/providers/chat_provider.dart` | 稍后读会话消息管理 | +| `lib/features/inspiration/services/chat_message_service.dart` | 新增sendLink/sendDocument/sendReadLaterSentence | +| `lib/features/home/providers/home_interaction_mixin.dart` | toggleReadLater增加写入稍后读会话逻辑 | +| `lib/features/inspiration/presentation/inspiration_page.dart` | _onSessionTap新增readlater路由分发 | +| `lib/core/router/app_router.dart` | 新增/readlater-chat路由 | +| `android/app/src/main/AndroidManifest.xml` | 新增分享接收intent-filter | +| `ios/Runner/Info.plist` | 新增URL Scheme/App Group | +| `pubspec.yaml` | 新增receive_sharing_intent本地包依赖 | +| `lib/main.dart` | 初始化SharingReceiverService | +| `CHANGELOG.md` | 版本更新日志 | + +--- + +## 八、开发归档清单 + +> 状态说明: ☐ 未开始 | 🚧 进行中 | ✅ 已完成 | ⚠️ 需修复 | ❌ 已取消 + +### Phase 1: 数据模型扩展 + +| # | 任务 | 文件 | 状态 | 备注 | +|---|------|------|------|------| +| 1.1 | ChatMessageType新增link/document/readlaterSentence | chat_message.dart | ✅ | 含isLink/isDocument/isReadlaterSentence计算属性 | +| 1.2 | ChatSessionType新增readlater | chat_session.dart | ✅ | | +| 1.3 | ChatMessage.meta字段约定文档 | 本文档第三节 | ✅ | | +| 1.4 | Drift数据库迁移(如需新字段) | app_database.dart | ✅ | 现有表结构兼容,无需迁移 | + +### Phase 2: 内置会话注册 + +| # | 任务 | 文件 | 状态 | 备注 | +|---|------|------|------|------| +| 2.1 | _buildSessions()新增readlater内置会话 | chat_session_provider.dart | ✅ | 默认置顶,HOT标签 | +| 2.2 | _onSessionTap新增readlater路由分发 | inspiration_page.dart | ✅ | | +| 2.3 | AppRoutes新增readlaterChat常量 | app_router.dart | ✅ | | +| 2.4 | GoRoute新增/readlater-chat路由 | app_router.dart | ✅ | 传入sessionType参数 | + +### Phase 3: ChatFlowPage 增强 + +| # | 任务 | 文件 | 状态 | 备注 | +|---|------|------|------|------| +| 3.1 | ChatFlowPage接收sessionType参数 | chat_flow_page.dart | ✅ | 含isReadlater计算属性 | +| 3.2 | 动态标题/配置(按sessionType) | chat_flow_page.dart | ✅ | 标题/placeholder/previousPageTitle | +| 3.3 | 稍后读空消息提示UI | chat_flow_page.dart | ✅ | "收藏内容,稍后阅读 📖" | +| 3.4 | 稍后读设置面板(ActionSheet) | chat_flow_page.dart | ✅ | 含标记已读/清空/导出/统计 | +| 3.5 | 隐藏分类栏(稍后读模式) | chat_flow_page.dart | ✅ | | +| 3.6 | 隐藏新建会话按钮(稍后读模式) | chat_flow_page.dart | ✅ | 替换为设置按钮 | + +### Phase 4: 新增气泡组件 + +| # | 任务 | 文件 | 状态 | 备注 | +|---|------|------|------|------| +| 4.1 | ChatLinkBubble — 链接预览卡片 | chat_link_bubble.dart | ✅ | OG预览+打开/复制按钮 | +| 4.2 | ChatDocumentBubble — 文档卡片 | chat_document_bubble.dart | ✅ | 按类型emoji+打开/分享 | +| 4.3 | ChatSentenceCardBubble — 句子卡片 | chat_sentence_card_bubble.dart | ✅ | 渐变背景+统计+shimmer | +| 4.4 | ChatBubble新增消息类型分发 | chat_bubble.dart | ✅ | link/document/readlaterSentence | +| 4.5 | 用户气泡支持新类型渲染 | chat_bubble.dart | ✅ | | +| 4.6 | AI气泡支持新类型渲染 | chat_bubble.dart | ✅ | | + +### Phase 5: ChatMessageService 扩展 + +| # | 任务 | 文件 | 状态 | 备注 | +|---|------|------|------|------| +| 5.1 | sendLink()方法 | chat_message_service.dart | ✅ | type=link, meta含url/title/description | +| 5.2 | sendDocument()方法 | chat_message_service.dart | ✅ | type=document, 含_documentTypeFromMime | +| 5.3 | sendReadLaterSentence()方法 | chat_message_service.dart | ✅ | type=readlaterSentence, meta含句子详情 | +| 5.4 | ChatNotifier新增对应发送方法 | chat_provider.dart | ✅ | sendLinkMessage/sendDocumentMessage/sendReadLaterSentenceMessage | + +### Phase 6: 稍后读按钮 → 稍后读会话 + +| # | 任务 | 文件 | 状态 | 备注 | +|---|------|------|------|------| +| 6.1 | toggleReadLater增加写入稍后读会话 | home_interaction_mixin.dart | ✅ | 仅标记时写入,取消时不删除 | +| 6.2 | 更新会话列表最后消息/时间 | chat_session_provider.dart | ✅ | refreshFromChat已支持readlater | +| 6.3 | 稍后读会话未读数更新 | chat_session_provider.dart | ✅ | | +| 6.4 | 句子卡片气泡点击跳转详情Sheet | chat_sentence_card_bubble.dart | ✅ | onTapSentence回调+GestureDetector+分享 | + +### Phase 7: 系统分享接收 + +| # | 任务 | 文件 | 状态 | 备注 | +|---|------|------|------|------| +| 7.1 | 下载receive_sharing_intent到本地packages/ | packages/receive_sharing_intent/ | ✅ | v1.8.1,从pub缓存复制 | +| 7.2 | pubspec.yaml新增本地包依赖 | pubspec.yaml | ✅ | path: packages/receive_sharing_intent | +| 7.3 | SharingReceiverService实现 | sharing_receiver_service.dart | ✅ | 统一入口,单例模式 | +| 7.4 | Android Manifest新增intent-filter | AndroidManifest.xml | ✅ | 5个intent-filter | +| 7.5 | iOS Info.plist配置 | Info.plist | ✅ | URL Scheme(xianyan://) + App Group | +| 7.6 | main.dart初始化SharingReceiverService | main.dart | ✅ | 含setNavigatorKey | +| 7.7 | Web端兼容(URL参数) | sharing_receiver_service.dart | ✅ | 条件编译 | +| 7.8 | Windows端兼容(命令行参数) | sharing_receiver_service.dart | ✅ | 条件编译 | +| 7.9 | 分享数据→稍后读会话写入 | sharing_receiver_service.dart | ✅ | 文本/链接/图片/视频/文档 | +| 7.10 | App从后台被分享唤起时的路由跳转 | sharing_receiver_service.dart | ✅ | _navigateToReadlater+rootNavigatorKey | + +### Phase 8: 测试与验收 + +| # | 任务 | 范围 | 状态 | 备注 | +|---|------|------|------|------| +| 8.1 | 句子详情→稍后读→会话显示 | 端到端 | ☐ | | +| 8.2 | 取消稍后读→会话保留历史 | 端到端 | ☐ | | +| 8.3 | 手动发送文本/链接到稍后读 | 端到端 | ☐ | | +| 8.4 | 手动发送图片/视频/文件到稍后读 | 端到端 | ☐ | | +| 8.5 | 系统分享文本→稍后读会话 | Android+iOS | ☐ | | +| 8.6 | 系统分享图片→稍后读会话 | Android+iOS | ☐ | | +| 8.7 | 系统分享文件→稍后读会话 | Android+iOS | ☐ | | +| 8.8 | 链接预览卡片渲染 | UI | ☐ | 有OG图/无OG图 | +| 8.9 | 文档卡片渲染 | UI | ☐ | PDF/Word/Excel/ZIP | +| 8.10 | 句子卡片渲染 | UI | ☐ | 渐变背景+统计+操作 | +| 8.11 | 稍后读设置面板功能 | UI | ☐ | 全部已读/清空/导出/统计 | +| 8.12 | 普通会话也能使用新气泡 | 回归测试 | ☐ | link/document在普通会话也可用 | +| 8.13 | 空指针检测 | 稳定性 | ☐ | 所有新增页面/组件 | +| 8.14 | 动态主题适配 | UI | ☐ | 日间/夜间/纯黑模式 | +| 8.15 | Web/Windows端兼容 | 跨平台 | ☐ | 分享接收降级处理 | + +--- + +## 九、验收标准 + +### 9.1 功能验收 + +- [ ] 工作流会话列表显示"📖 稍后读"会话,默认置顶 +- [ ] 点击稍后读会话,进入聊天风格页面 +- [ ] 主页句子详情Sheet点击"稍后读",句子出现在稍后读会话中 +- [ ] 稍后读会话支持发送文本/链接/图片/视频/文件/文档 +- [ ] 链接消息显示OG预览卡片 +- [ ] 文档消息显示文档卡片(PDF/Word/Excel等) +- [ ] 句子消息显示句子卡片(渐变背景+统计) +- [ ] 其他App分享文本/图片/文件到闲言,自动进入稍后读会话 +- [ ] 稍后读设置面板功能正常(标记已读/清空/导出/统计) +- [ ] 普通会话也能使用新增的链接/文档气泡 + +### 9.2 UI验收 + +- [ ] 遵循iOS 26风格,使用项目统一设计令牌 +- [ ] 毛玻璃效果正常(日间/夜间/纯黑) +- [ ] 动态主题切换无闪烁 +- [ ] 气泡动画流畅(shimmer/渐变/tilt) +- [ ] 空状态显示正确 +- [ ] 长按上下文菜单正常 + +### 9.3 稳定性验收 + +- [ ] 无空指针崩溃 +- [ ] 无内存泄漏(控制器/流/订阅正确释放) +- [ ] 大量消息时滚动流畅 +- [ ] 分享接收时App未运行也能正确处理 + +--- + +## 十、风险与注意事项 + +| 风险 | 影响 | 缓解措施 | +|------|------|---------| +| receive_sharing_intent库在Web/Windows不支持 | 分享接收降级 | 条件编译,Web用URL参数,Windows用命令行参数 | +| Drift数据库迁移可能影响现有数据 | 数据丢失 | 新增字段使用默认值,不删除现有列 | +| OG元数据获取需要网络请求 | 链接预览延迟 | 异步加载,先显示URL,OG数据加载后更新 | +| 大文件分享可能导致内存问题 | OOM | 限制单文件大小,大文件分片处理 | +| 稍后读会话消息量过大 | 性能下降 | 分页加载,虚拟列表 | + +--- + +## 十一、版本记录 + +| 版本 | 日期 | 变更 | +|------|------|------| +| v1.0 | 2026-05-15 | 初始设计文档 | +| v1.1 | 2026-05-15 | Phase 1-7 实现完成,编译通过(0 error),3项待后续补充(⚠️) | +| v1.2 | 2026-05-15 | 全部归档清单✅完成,新增可扩展功能分析 | + +--- + +## 十二、稍后读可扩展功能分析 + +> 基于项目现有依赖库(pubspec.yaml)和已实现功能,分析稍后读会话可扩展的功能方向 + +### 12.1 基于现有库的可扩展功能 + +| # | 功能 | 依赖库 | 优先级 | 说明 | +|---|------|--------|--------|------| +| E1 | 🔗 链接OG元数据自动抓取 | `dio` + `flutter_html` | P1 | 收到链接消息时,异步抓取OG标题/描述/图片,更新meta字段 | +| E2 | 📄 文档预览 | `flutter_html` + `file_picker` | P2 | 文档卡片点击后,PDF用flutter_html渲染,Word/Excel转PDF预览 | +| E3 | 🖼️ 图片编辑后保存 | `pro_image_editor` + `image` | P2 | 图片消息长按可进入编辑器,裁剪/标注后覆盖保存 | +| E4 | 🎬 视频压缩后保存 | `video_compress` + `gal` | P2 | 视频消息长按可压缩后保存到相册 | +| E5 | 📤 多格式导出 | `archive` + `share_plus` + `gal` | P1 | 稍后读内容批量导出为ZIP/Markdown/JSON/图片 | +| E6 | 🔔 稍后读提醒 | `flutter_local_notifications` | P1 | 定时提醒未读稍后读内容,支持自定义提醒时间 | +| E7 | 📊 阅读统计 | `fl_chart` | P2 | 稍后读统计面板:按类型/日期/来源的阅读数据可视化 | +| E8 | 🔍 全文搜索 | Drift `LIKE` / FTS5 | P1 | 稍后读会话内搜索,支持文本/链接/文件名搜索 | +| E9 | 🏷️ 标签/分类管理 | `hive` + 现有分类系统 | P2 | 给稍后读内容打标签,按标签筛选 | +| E10 | 📋 剪贴板监控 | `flutter_secure_storage` + 后台服务 | P3 | 自动检测剪贴板中的链接,提示保存到稍后读 | +| E11 | 🔄 离线同步 | `supabase_flutter` + Drift | P2 | 稍后读内容云端同步,多设备共享 | +| E12 | 🎨 句子卡片制作 | `pro_image_editor` + `cached_network_image` | P2 | 句子卡片气泡长按可制作壁纸/分享图 | +| E13 | 📱 桌面小组件 | `home_widget`(需新增) | P3 | iOS/Android桌面Widget显示未读稍后读数量 | +| E14 | 🗂️ 文件夹管理 | Drift + 现有会话系统 | P3 | 稍后读内容按文件夹分组管理 | +| E15 | 🤖 AI摘要 | `supabase_flutter`(Edge Functions) | P3 | 对长文本/链接内容自动生成AI摘要 | + +### 12.2 基于现有文件传输模块的可扩展功能 + +| # | 功能 | 依赖模块 | 优先级 | 说明 | +|---|------|---------|--------|------| +| E16 | 📡 跨设备稍后读同步 | `file_transfer` + `nearby_service` | P2 | 通过局域网将稍后读内容推送到其他设备 | +| E17 | 💬 稍后读协作 | `TransferChatPage` + `SignalingService` | P3 | 与好友共享稍后读列表,互相推荐内容 | + +### 12.3 推荐实施顺序 + +**第一批 (P1)**: +- E1 链接OG元数据抓取 — 提升链接消息体验 +- E5 多格式导出 — 实用性强 +- E6 稍后读提醒 — 核心体验 +- E8 全文搜索 — 内容管理必需 + +**第二批 (P2)**: +- E2 文档预览 / E4 视频压缩 / E7 阅读统计 / E9 标签管理 / E11 离线同步 / E12 句子卡片制作 / E16 跨设备同步 + +**第三批 (P3)**: +- E10 剪贴板监控 / E13 桌面小组件 / E14 文件夹管理 / E15 AI摘要 / E17 稍后读协作 diff --git a/docs/superpowers/plans/2026-05-14-gamification-v2.md b/docs/superpowers/plans/2026-05-14-gamification-v2.md new file mode 100644 index 00000000..23e869a9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-gamification-v2.md @@ -0,0 +1,300 @@ +# 养成体系 v2.0 实施计划 + +> **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:** 实现EXP独立体系、等级展示、勋章系统、赛季排行榜、每日任务五大养成功能 + +**Architecture:** 服务端基于ThinkPHP5(FastAdmin),新增7张数据库表、2个API控制器、7个管理控制器;客户端基于Flutter,新增排行榜/任务/勋章墙3个功能模块,修改用户中心/签到/成就等现有页面 + +**Tech Stack:** PHP(ThinkPHP5/FastAdmin) + MySQL + Flutter(Dart) + Python(测试脚本) + +--- + +## Phase 1: EXP独立体系 + 等级展示 + +### Task 1.1: 数据库迁移 + +**Files:** +- Create: `docs/toolsapi/application/admin/command/Install/migrate_v11.sql` + +- [ ] 创建迁移SQL: tool_user新增exp字段 + tool_user_exp_log表 + +### Task 1.2: 服务端ExpEngine核心 + +**Files:** +- Modify: `docs/toolsapi/application/common/model/User.php` + +- [ ] User模型新增exp字段类型定义 +- [ ] 新增nextlevelByExp()方法: floor(exp^0.4/10)+1, 10级封顶 +- [ ] 新增exp()静态方法: 带事务锁的EXP增减,自动计算level +- [ ] 修改score()方法: 积分变动时同时产出EXP + +### Task 1.3: 服务端UserCenter API + +**Files:** +- Modify: `docs/toolsapi/application/api/controller/UserCenter.php` + +- [ ] index()返回新增level/exp/exp_to_next/exp_progress字段 +- [ ] 新增expLog()方法: EXP变动日志分页查询 +- [ ] signin()方法: 签到时同时产出EXP(5+continuous*2) +- [ ] checkin相关: 打卡时产出EXP(10) + +### Task 1.4: 客户端数据模型 + +**Files:** +- Modify: `lib/features/auth/models/user_model.dart` +- Modify: `lib/features/user_center/models/user_center_models.dart` + +- [ ] UserModel新增level/exp/expToNext/expProgress字段 +- [ ] UserCenterInfo新增level/exp相关字段 + +### Task 1.5: 客户端等级卡片组件 + +**Files:** +- Create: `lib/shared/widgets/level_card.dart` + +- [ ] 创建等级卡片组件: 等级数字+称号+EXP进度条+下一级所需 + +### Task 1.6: 客户端页面集成 + +**Files:** +- Modify: `lib/features/signin/presentation/signin_page.dart` +- Modify: `lib/features/achievement/presentation/achievement_page.dart` +- Modify: `lib/features/user_center/presentation/user_center_page.dart` + +- [ ] 签到页: 签到卡片增加等级展示 +- [ ] 成就页: 概要增加等级信息 +- [ ] 用户中心: 增加等级卡片 + +### Task 1.7: 上传服务器+测试 + +- [ ] 上传PHP文件到服务器 +- [ ] 执行数据库迁移 +- [ ] 编写EXP全流程测试脚本 +- [ ] 运行测试并验证 + +--- + +## Phase 2: 勋章系统 + +### Task 2.1: 数据库迁移 + +**Files:** +- Modify: `docs/toolsapi/application/admin/command/Install/migrate_v11.sql` + +- [ ] 追加tool_badge + tool_user_badge建表SQL +- [ ] 追加20个预置勋章INSERT语句 + +### Task 2.2: 服务端BadgeEngine + +**Files:** +- Modify: `docs/toolsapi/application/api/controller/Achievement.php` + +- [ ] 新增badges()方法: 获取所有勋章列表(含用户解锁状态) +- [ ] 新增badgeDisplay()方法: 设置主页展示勋章(最多3个) +- [ ] 新增_checkBadges()方法: 勋章检测逻辑 +- [ ] claim()方法: 领取成就后触发勋章检测 +- [ ] signin()后触发勋章检测 + +### Task 2.3: 管理员后台-勋章管理 + +**Files:** +- Create: `docs/toolsapi/application/admin/controller/Badge.php` +- Create: `docs/toolsapi/application/admin/model/Badge.php` +- Create: `docs/toolsapi/application/admin/validate/Badge.php` +- Create: `docs/toolsapi/application/admin/view/badge/index.html` +- Create: `docs/toolsapi/application/admin/view/badge/add.html` +- Create: `docs/toolsapi/application/admin/view/badge/edit.html` +- Create: `docs/toolsapi/public/assets/js/backend/badge.js` +- Create: `docs/toolsapi/application/admin/lang/zh-cn/badge.php` + +- [ ] 勋章管理CRUD(列表/添加/编辑/删除) +- [ ] 勋章图标/稀有度/条件配置 + +### Task 2.4: 管理员后台-用户勋章 + +**Files:** +- Create: `docs/toolsapi/application/admin/controller/user/UserBadge.php` +- Create: `docs/toolsapi/application/admin/model/UserBadge.php` +- Create: `docs/toolsapi/application/admin/view/user/user_badge/index.html` +- Create: `docs/toolsapi/public/assets/js/backend/user/user_badge.js` +- Create: `docs/toolsapi/application/admin/lang/zh-cn/user/user_badge.php` + +- [ ] 用户勋章列表(查看/手动发放/收回) + +### Task 2.5: 客户端勋章墙 + +**Files:** +- Create: `lib/features/achievement/presentation/badge_wall_page.dart` +- Create: `lib/features/achievement/providers/badge_provider.dart` +- Create: `lib/shared/widgets/badge_icon.dart` + +- [ ] 勋章图标组件(含稀有度边框颜色) +- [ ] 勋章墙页面(全部勋章网格+已解锁/未解锁状态) +- [ ] 主页展示设置(选择最多3个展示) + +### Task 2.6: 上传服务器+测试 + +- [ ] 上传PHP文件+执行迁移 +- [ ] 编写勋章全流程测试脚本 +- [ ] 运行测试并验证 + +--- + +## Phase 3: 每日任务 + +### Task 3.1: 数据库迁移 + +- [ ] 追加tool_daily_task + tool_user_task建表SQL +- [ ] 追加8个预置任务INSERT语句 + +### Task 3.2: 服务端TaskEngine + +**Files:** +- Create: `docs/toolsapi/application/api/controller/Task.php` + +- [ ] today()方法: 获取今日任务列表(含进度) +- [ ] reportProgress()方法: 上报任务进度 +- [ ] claim()方法: 领取任务奖励 +- [ ] claimPerfect()方法: 领取完美日奖励 +- [ ] registerCustom()方法: 注册自定义任务 + +### Task 3.3: 管理员后台-任务管理 + +**Files:** +- Create: `docs/toolsapi/application/admin/controller/DailyTask.php` + 配套model/validate/view/js/lang +- Create: `docs/toolsapi/application/admin/controller/user/UserTask.php` + 配套 + +- [ ] 每日任务CRUD +- [ ] 用户任务进度查看 + +### Task 3.4: 客户端每日任务页 + +**Files:** +- Create: `lib/features/task/presentation/daily_task_page.dart` +- Create: `lib/features/task/providers/task_provider.dart` +- Create: `lib/features/task/services/task_service.dart` +- Create: `lib/shared/widgets/task_card.dart` + +- [ ] 任务卡片组件 +- [ ] 每日任务页面(任务列表+进度+领取) +- [ ] 完美日动画效果 + +### Task 3.5: 上传服务器+测试 + +- [ ] 上传+迁移+测试 + +--- + +## Phase 4: 赛季排行榜 + +### Task 4.1: 数据库迁移 + +- [ ] 追加tool_rank_season + tool_rank_record建表SQL + +### Task 4.2: 服务端RankEngine + +**Files:** +- Create: `docs/toolsapi/application/api/controller/Rank.php` + +- [ ] seasons()方法: 赛季列表 +- [ ] leaderboard()方法: 排行榜查询 +- [ ] myRank()方法: 我的排名 +- [ ] claimReward()方法: 领取赛季奖励 +- [ ] 赛季自动创建+结算逻辑 + +### Task 4.3: 管理员后台-赛季管理 + +**Files:** +- Create: `docs/toolsapi/application/admin/controller/RankSeason.php` + 配套 +- Create: `docs/toolsapi/application/admin/controller/RankRecord.php` + 配套 + +- [ ] 赛季CRUD+奖励配置+手动结算 +- [ ] 排名记录查看+手动发放奖励 + +### Task 4.4: 客户端排行榜页 + +**Files:** +- Create: `lib/features/rank/presentation/rank_page.dart` +- Create: `lib/features/rank/providers/rank_provider.dart` +- Create: `lib/features/rank/services/rank_service.dart` +- Create: `lib/shared/widgets/rank_item.dart` + +- [ ] 排行榜页面(周赛/月赛Tab + 4种排行类型) +- [ ] 排行条目组件(排名+头像+等级+数值) +- [ ] 我的排名卡片+奖励领取 + +### Task 4.5: 上传服务器+测试 + +- [ ] 上传+迁移+测试 + +--- + +## Phase 5: 管理员后台完善 + +### Task 5.1: 用户管理扩展 + +**Files:** +- Modify: `docs/toolsapi/application/admin/controller/user/User.php` +- Modify: `docs/toolsapi/application/admin/view/user/user/edit.html` + +- [ ] edit()增加exp字段编辑 +- [ ] edit.html增加exp输入框+level只读显示 + +### Task 5.2: EXP日志管理 + +**Files:** +- Create: `docs/toolsapi/application/admin/controller/user/ExpLog.php` + 配套 + +- [ ] EXP日志查看(只读列表) + +### Task 5.3: 菜单权限配置 + +- [ ] fa_auth_rule INSERT: 养成管理一级菜单+7个二级菜单+权限节点 + +### Task 5.4: 上传服务器+验证 + +- [ ] 上传所有管理后台文件 +- [ ] 执行菜单SQL +- [ ] 验证后台功能 + +--- + +## 归档列表 + +### Phase 1: EXP独立体系 + 等级展示 +- [x] ✅ Task 1.1: 数据库迁移 +- [x] ✅ Task 1.2: 服务端ExpEngine核心 +- [x] ✅ Task 1.3: 服务端UserCenter API +- [x] ✅ Task 1.4: 客户端数据模型 +- [x] ✅ Task 1.5: 客户端等级卡片组件 +- [x] ✅ Task 1.6: 客户端页面集成 +- [x] ✅ Task 1.7: 上传服务器+测试(7/7通过) + +### Phase 2: 勋章系统 +- [x] ✅ Task 2.1: 数据库迁移 +- [x] ✅ Task 2.2: 服务端BadgeEngine +- [x] ✅ Task 2.3: 管理员后台-勋章管理 +- [x] ✅ Task 2.4: 管理员后台-用户勋章 +- [x] ✅ Task 2.5: 客户端勋章墙 +- [x] ✅ Task 2.6: 上传服务器+测试(7/7通过) + +### Phase 3: 每日任务 +- [ ] Task 3.1: 数据库迁移 +- [ ] Task 3.2: 服务端TaskEngine +- [ ] Task 3.3: 管理员后台-任务管理 +- [ ] Task 3.4: 客户端每日任务页 +- [ ] Task 3.5: 上传服务器+测试 + +### Phase 4: 赛季排行榜 +- [ ] Task 4.1: 数据库迁移 +- [ ] Task 4.2: 服务端RankEngine +- [ ] Task 4.3: 管理员后台-赛季管理 +- [ ] Task 4.4: 客户端排行榜页 +- [ ] Task 4.5: 上传服务器+测试 + +### Phase 5: 管理员后台完善 +- [ ] Task 5.1: 用户管理扩展 +- [ ] Task 5.2: EXP日志管理 +- [ ] Task 5.3: 菜单权限配置 +- [ ] Task 5.4: 上传服务器+验证 diff --git a/docs/superpowers/specs/2026-05-14-gamification-v2-design.md b/docs/superpowers/specs/2026-05-14-gamification-v2-design.md new file mode 100644 index 00000000..b93005a1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-gamification-v2-design.md @@ -0,0 +1,738 @@ +# 养成体系 v2.0 设计文档 + +> 日期: 2026-05-14 +> 版本: v11.0.0 +> 涉及: EXP独立体系 / 等级展示 / 勋章系统 / 赛季排行榜 / 每日任务 + +--- + +## 一、功能概述 + +### 1.1 现状问题 + +| 问题 | 严重度 | 说明 | +|------|--------|------| +| 积分=等级 | 高 | score既是货币又决定等级,消费积分=降级 | +| 等级断裂 | 高 | 服务端有level字段但API未返回,客户端无法展示 | +| 成就单薄 | 中 | 12个硬编码成就,无视觉徽章 | +| 无排行榜 | 中 | 缺少社交竞争动力 | +| 无每日引导 | 中 | 缺少"今日目标"引导 | + +### 1.2 设计决策 + +| 功能 | 决策 | +|------|------| +| EXP体系 | 新增exp字段独立于score,exp只增不减,score纯货币可消耗 | +| 等级 | 当前10级封顶,公式计算,未来可扩展 | +| 排行榜 | 周赛+月赛双轨,管理员可设奖励自动到账 | +| 勋章 | 四档稀有度(普通/稀有/史诗/传说),新增数据库表 | +| 每日任务 | 服务端随机任务+客户端可自定义任务(浏览URL/使用功能) | +| 管理员 | 后台增加对应编辑字段+勋章管理+赛季管理+任务管理 | + +--- + +## 二、EXP独立体系 + +### 2.1 数据库变更 + +**tool_user 表新增字段:** + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| exp | int(10) unsigned | 0 | 经验值,只增不减 | + +**新增表 tool_user_exp_log:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int(10) unsigned PK | 自增ID | +| user_id | int(10) unsigned | 用户ID | +| action | varchar(30) | 行为类型(signin/checkin/achievement/task/badge/custom) | +| amount | int(10) | EXP变动量(正数) | +| before | int(10) | 变动前EXP | +| after | int(10) | 变动后EXP | +| remark | varchar(255) | 备注 | +| createtime | int(10) | 创建时间 | + +### 2.2 等级公式 + +``` +level = floor(exp^0.4 / 10) + 1 +``` + +| 等级 | 所需EXP | 累计EXP | +|------|---------|---------| +| Lv.1 | 0 | 0 | +| Lv.2 | ~16 | ~16 | +| Lv.3 | ~100 | ~100 | +| Lv.4 | ~398 | ~398 | +| Lv.5 | ~1,000 | ~1,000 | +| Lv.6 | ~2,511 | ~2,511 | +| Lv.7 | ~3,981 | ~3,981 | +| Lv.8 | ~6,309 | ~6,309 | +| Lv.9 | ~10,000 | ~10,000 | +| Lv.10 | ~15,848 | ~15,848 | + +10级封顶: level = min(calculated, 10)。后续调参即可扩展上限。 + +### 2.3 EXP获取渠道 + +| 行为 | EXP奖励 | 说明 | +|------|---------|------| +| 每日签到 | 5 + continuous * 2 | 基础5 + 连续天数x2 | +| 学习打卡 | 10 | 每种类型每日首次 | +| 成就领取 | 成就奖励值/2 | 成就积分奖励的一半 | +| 每日任务完成 | 5/任务 | 每个任务5EXP | +| 完美日 | 20 | 全部每日任务完成 | +| 排行榜奖励 | 赛季配置 | 管理员设置 | +| 自定义任务 | 客户端配置 | 浏览URL/使用功能 | + +### 2.4 score与exp双轨 + +- 行为同时产出exp和score(如签到得5exp+2score) +- score可消耗(补签/商城),消耗不影响exp和等级 +- exp只增不减,保证等级稳定 + +### 2.5 API变更 + +**UserCenter::index() 返回新增:** + +```json +{ + "level": 5, + "exp": 1200, + "exp_to_next": 2511, + "exp_progress": 0.48, + "extra": { + "exp": 1200, + "level": 5, + "level_title": "学者", + "exp_to_next": 2511, + "exp_progress": 0.48 + } +} +``` + +**新增API:** + +| 接口 | 方法 | 说明 | +|------|------|------| +| /api/user_center/expLog | GET | EXP变动日志(分页) | + +--- + +## 三、等级展示 + +### 3.1 等级称号映射 + +| 等级 | 称号 | 颜色 | +|------|------|------| +| Lv.1 | 新手 | #8B8B8B | +| Lv.2 | 学徒 | #5B9BD5 | +| Lv.3 | 初学者 | #5B9BD5 | +| Lv.4 | 进阶者 | #70AD47 | +| Lv.5 | 学者 | #70AD47 | +| Lv.6 | 达人 | #FFC000 | +| Lv.7 | 专家 | #FFC000 | +| Lv.8 | 大师 | #FF6600 | +| Lv.9 | 宗师 | #FF6600 | +| Lv.10 | 传说 | #E74C3C | + +### 3.2 客户端展示位置 + +1. **用户中心页** — 等级卡片(等级数字+称号+EXP进度条+下一级所需) +2. **签到页** — 签到卡片增加等级展示 +3. **成就页** — 个人概要增加等级信息 +4. **排行榜** — 用户名旁显示等级徽章 + +### 3.3 等级进度条 + +``` +Lv.5 学者 ████████░░░░ 1200/2511 (48%) +``` + +--- + +## 四、勋章/徽章系统 + +### 4.1 数据库设计 + +**新增表 tool_badge(勋章定义):** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int(10) unsigned PK | 勋章ID | +| name | varchar(50) | 勋章名称 | +| icon | varchar(50) | 图标(emoji或图标类名) | +| description | varchar(255) | 勋章描述 | +| rarity | enum('common','rare','epic','legendary') | 稀有度 | +| type | varchar(30) | 类型(signin/achievement/task/rank/special) | +| condition_type | varchar(30) | 条件类型(count/reach/action) | +| condition_value | int(10) | 条件值 | +| exp_reward | int(10) | 解锁奖励EXP | +| score_reward | int(10) | 解锁奖励积分 | +| weigh | int(10) | 排序权重 | +| status | enum('normal','hidden') | 状态 | +| createtime | int(10) | 创建时间 | +| updatetime | int(10) | 更新时间 | + +**新增表 tool_user_badge(用户勋章):** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int(10) unsigned PK | 自增ID | +| user_id | int(10) unsigned | 用户ID | +| badge_id | int(10) unsigned | 勋章ID | +| is_displayed | tinyint(1) | 是否展示在主页(最多3个) | +| unlocked_at | int(10) | 解锁时间 | +| createtime | int(10) | 创建时间 | + +### 4.2 稀有度设计 + +| 稀有度 | 标识 | 颜色 | 获取难度 | 示例 | +|--------|------|------|----------|------| +| 普通(common) | ⬜ | #8B8B8B | 日常行为即可 | 初次签到、首次收藏 | +| 稀有(rare) | 🟦 | #5B9BD5 | 需要持续投入 | 连续签到7天、10篇文章 | +| 史诗(epic) | 🟪 | #9B59B6 | 需要长期坚持 | 连续签到30天、100篇笔记 | +| 传说(legendary) | 🟨 | #F39C12 | 极少数人可达 | 赛季冠军、全成就达成 | + +### 4.3 预置勋章(20个) + +| ID | 名称 | 图标 | 稀有度 | 条件 | +|----|------|------|--------|------| +| 1 | 初次签到 | 🌅 | common | signin count >= 1 | +| 2 | 坚持一周 | 📅 | rare | signin continuous >= 7 | +| 3 | 月度全勤 | 🏆 | epic | signin continuous >= 30 | +| 4 | 笔耕不辍 | ✍️ | common | article count >= 1 | +| 5 | 专栏作家 | 📝 | rare | article count >= 10 | +| 6 | 收藏新手 | ⭐ | common | favorite count >= 10 | +| 7 | 知识宝库 | 🏛️ | rare | favorite count >= 50 | +| 8 | 笔记达人 | 📒 | common | note count >= 5 | +| 9 | 笔记大师 | 📚 | epic | note count >= 50 | +| 10 | 互动之星 | 💬 | rare | interact count >= 100 | +| 11 | 游戏高手 | 🎮 | rare | game count >= 20 | +| 12 | 探索者 | 🔍 | common | search count >= 50 | +| 13 | 学习打卡7天 | 🎯 | rare | checkin days >= 7 | +| 14 | 学习打卡30天 | 🎓 | epic | checkin days >= 30 | +| 15 | 等级5 | ⭐ | rare | level >= 5 | +| 16 | 等级10 | 👑 | legendary | level >= 10 | +| 17 | 周赛冠军 | 🥇 | legendary | 周赛排名第1 | +| 18 | 月赛冠军 | 🏅 | legendary | 月赛排名第1 | +| 19 | 完美周 | ✨ | epic | 一周内7天完美日 | +| 20 | 全勤月 | 🎊 | epic | 一个月内全部签到 | + +### 4.4 勋章检测逻辑 + +- 勋章检测在以下时机触发:签到后、打卡后、成就领取后、每日任务完成后 +- 检测方式:实时COUNT各业务表,与condition_value比较 +- 已解锁的勋章不重复检测(tool_user_badge中有记录则跳过) +- 解锁时自动发放exp_reward和score_reward + +### 4.5 API + +| 接口 | 方法 | 说明 | +|------|------|------| +| /api/achievement/badges | GET | 获取所有勋章列表(含用户解锁状态) | +| /api/achievement/badgeDisplay | POST | 设置主页展示的勋章(最多3个) | + +--- + +## 五、赛季排行榜 + +### 5.1 数据库设计 + +**新增表 tool_rank_season(赛季定义):** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int(10) unsigned PK | 赛季ID | +| name | varchar(50) | 赛季名称(如"第12周赛"/"2026年5月赛") | +| type | enum('weekly','monthly') | 赛季类型 | +| start_time | int(10) | 开始时间 | +| end_time | int(10) | 结束时间 | +| status | enum('pending','active','settled') | 状态 | +| rewards | text | 奖励配置JSON [{rank:1,score:100,exp:50},{rank:2,score:50,exp:30}] | +| settle_time | int(10) | 结算时间 | +| createtime | int(10) | 创建时间 | +| updatetime | int(10) | 更新时间 | + +**新增表 tool_rank_record(排名记录):** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int(10) unsigned PK | 自增ID | +| season_id | int(10) unsigned | 赛季ID | +| user_id | int(10) unsigned | 用户ID | +| rank_type | enum('exp','signin','badge','score') | 排行类型 | +| rank | int(10) | 排名 | +| value | int(10) | 排行值(exp/score/badge_count/signin_days) | +| reward_claimed | tinyint(1) | 奖励是否已领取 | +| createtime | int(10) | 创建时间 | + +### 5.2 排行类型 + +| 类型 | 说明 | 排序依据 | +|------|------|----------| +| exp | 经验榜 | 当季EXP增量 | +| signin | 签到榜 | 当季签到天数 | +| badge | 勋章榜 | 当季解锁勋章数 | +| score | 积分榜 | 当季积分增量 | + +### 5.3 赛季周期 + +- **周赛**: 每周一00:00开始,周日23:59结束,周一02:00结算 +- **月赛**: 每月1日00:00开始,月末23:59结束,次月1日02:00结算 +- 结算时自动生成排名快照,管理员配置的奖励自动到账 + +### 5.4 排行榜数据更新 + +- 排行榜数据实时计算(不缓存),基于tool_user_exp_log/tool_user_signin/tool_user_badge等表 +- 排名快照仅在赛季结算时写入tool_rank_record +- 查询时限制返回前50名 + +### 5.5 API + +| 接口 | 方法 | 说明 | +|------|------|------| +| /api/rank/seasons | GET | 赛季列表 | +| /api/rank/leaderboard | GET | 排行榜(season_id + type参数) | +| /api/rank/myRank | GET | 我的排名 | +| /api/rank/claimReward | POST | 领取赛季奖励 | + +--- + +## 六、每日任务系统 + +### 6.1 数据库设计 + +**新增表 tool_daily_task(任务定义):** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int(10) unsigned PK | 任务ID | +| name | varchar(50) | 任务名称 | +| icon | varchar(50) | 图标(emoji) | +| type | enum('signin','read','favorite','interact','checkin','custom') | 任务类型 | +| target | int(10) | 目标值(如签到1次/阅读3篇) | +| action | varchar(100) | 触发行为标识(如signin/read/favorite) | +| custom_url | varchar(255) | 自定义URL(custom类型时) | +| custom_page | varchar(100) | 自定义页面标识(custom类型时) | +| exp_reward | int(10) | EXP奖励 | +| score_reward | int(10) | 积分奖励 | +| is_random | tinyint(1) | 是否随机出现(1=每日随机池,0=固定任务) | +| status | enum('normal','hidden') | 状态 | +| weigh | int(10) | 排序权重 | +| createtime | int(10) | 创建时间 | +| updatetime | int(10) | 更新时间 | + +**新增表 tool_user_task(用户任务进度):** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int(10) unsigned PK | 自增ID | +| user_id | int(10) unsigned | 用户ID | +| task_id | int(10) unsigned | 任务ID | +| date | date | 任务日期 | +| progress | int(10) | 当前进度 | +| completed | tinyint(1) | 是否完成 | +| claimed | tinyint(1) | 是否领取奖励 | +| createtime | int(10) | 创建时间 | +| updatetime | int(10) | 更新时间 | + +### 6.2 任务类型 + +| 类型 | 示例任务 | 触发行为 | +|------|----------|----------| +| signin | 每日签到 | signin | +| read | 阅读3篇文章 | read | +| favorite | 收藏1篇内容 | favorite | +| interact | 点赞/评论2次 | like/comment | +| checkin | 学习打卡1次 | checkin | +| custom | 浏览指定URL | custom_url访问 | +| custom | 使用某功能 | custom_page访问 | + +### 6.3 每日任务分配逻辑 + +1. **固定任务**(is_random=0): 每天都出现的任务(如签到) +2. **随机任务**(is_random=1): 每日从随机池中抽取3-4个 +3. **客户端自定义任务**: 客户端通过API注册自定义任务(type=custom),指定URL或页面标识 + +### 6.4 完美日机制 + +- 当天所有已分配任务全部完成 = 完美日 +- 完美日额外奖励: 20 EXP + 10 积分 +- 完美日记录在tool_user_task中以date维度统计 + +### 6.5 客户端自定义任务 + +客户端可注册自定义任务,格式: + +```json +{ + "name": "浏览今日推荐", + "icon": "📖", + "type": "custom", + "target": 1, + "custom_url": "/daily-card", + "custom_page": "daily_card", + "exp_reward": 5, + "score_reward": 2 +} +``` + +客户端在用户访问对应页面时,调用 `/api/task/reportProgress` 上报进度。 + +### 6.6 API + +| 接口 | 方法 | 说明 | +|------|------|------| +| /api/task/today | GET | 今日任务列表(含进度) | +| /api/task/reportProgress | POST | 上报任务进度 | +| /api/task/claim | POST | 领取任务奖励 | +| /api/task/claimPerfect | POST | 领取完美日奖励 | +| /api/task/registerCustom | POST | 注册自定义任务(客户端调用) | + +--- + +## 七、管理员后台 + +### 7.1 新增管理模块 + +| 模块 | 控制器 | 路径 | 功能 | +|------|--------|------|------| +| 勋章管理 | Badge | admin/badge | CRUD勋章定义+查看解锁统计 | +| 用户勋章 | UserBadge | admin/user/UserBadge | 查看用户勋章+手动发放/收回 | +| 赛季管理 | RankSeason | admin/rank_season | CRUD赛季+设置奖励+手动结算 | +| 排名记录 | RankRecord | admin/rank_record | 查看排名记录+手动发放奖励 | +| 每日任务 | DailyTask | admin/daily_task | CRUD任务定义+启用/禁用 | +| 用户任务 | UserTask | admin/user/UserTask | 查看用户任务进度 | +| EXP日志 | ExpLog | admin/user/ExpLog | 查看EXP变动日志 | + +### 7.2 用户管理扩展 + +**user/user/edit.html 新增字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| exp | number | 经验值(可手动调整) | +| level | number(只读) | 等级(由exp自动计算) | + +### 7.3 管理员后台菜单结构 + +``` +用户管理 + ├ 会员管理 (已有,增加exp/level字段) + ├ 签到记录 (已有) + ├ 金币日志 (已有) + ├ 金币规则 (已有) + ├ 头衔管理 (已有) + ├ EXP日志 (新增) + ├ 用户勋章 (新增) + └ 用户任务 (新增) + +养成管理 (新增一级菜单) + ├ 勋章管理 (新增) + ├ 赛季管理 (新增) + ├ 排名记录 (新增) + └ 每日任务 (新增) +``` + +### 7.4 FastAdmin CRUD文件清单 + +每个管理模块需要: + +| 文件 | 路径 | +|------|------| +| 控制器 | application/admin/controller/{Module}.php | +| 模型 | application/admin/model/{Module}.php | +| 验证器 | application/admin/validate/{Module}.php | +| 列表视图 | application/admin/view/{module}/index.html | +| 添加视图 | application/admin/view/{module}/add.html | +| 编辑视图 | application/admin/view/{module}/edit.html | +| JS文件 | public/assets/js/backend/{module}.js | +| 语言包 | application/admin/lang/zh-cn/{module}.php | +| 菜单SQL | fa_auth_rule INSERT | + +--- + +## 八、数据库迁移 + +### 8.1 迁移脚本 migrate_v11.sql + +```sql +-- tool_user 新增 exp 字段 +ALTER TABLE `tool_user` ADD COLUMN `exp` INT(10) UNSIGNED NOT NULL DEFAULT 0 COMMENT '经验值' AFTER `score`; +ALTER TABLE `tool_user` ADD INDEX `idx_exp` (`exp`); + +-- tool_user_exp_log +CREATE TABLE `tool_user_exp_log` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT 0, + `action` varchar(30) NOT NULL DEFAULT '', + `amount` int(10) NOT NULL DEFAULT 0, + `before` int(10) NOT NULL DEFAULT 0, + `after` int(10) NOT NULL DEFAULT 0, + `remark` varchar(255) NOT NULL DEFAULT '', + `createtime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_action` (`action`), + KEY `idx_createtime` (`createtime`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='EXP变动日志'; + +-- tool_badge +CREATE TABLE `tool_badge` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(50) NOT NULL DEFAULT '', + `icon` varchar(50) NOT NULL DEFAULT '', + `description` varchar(255) NOT NULL DEFAULT '', + `rarity` enum('common','rare','epic','legendary') NOT NULL DEFAULT 'common', + `type` varchar(30) NOT NULL DEFAULT '', + `condition_type` varchar(30) NOT NULL DEFAULT '', + `condition_value` int(10) NOT NULL DEFAULT 0, + `exp_reward` int(10) NOT NULL DEFAULT 0, + `score_reward` int(10) NOT NULL DEFAULT 0, + `weigh` int(10) NOT NULL DEFAULT 0, + `status` enum('normal','hidden') NOT NULL DEFAULT 'normal', + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_type` (`type`), + KEY `idx_rarity` (`rarity`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='勋章定义'; + +-- tool_user_badge +CREATE TABLE `tool_user_badge` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT 0, + `badge_id` int(10) unsigned NOT NULL DEFAULT 0, + `is_displayed` tinyint(1) NOT NULL DEFAULT 0, + `unlocked_at` int(10) DEFAULT NULL, + `createtime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_badge` (`user_id`, `badge_id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_badge_id` (`badge_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户勋章'; + +-- tool_rank_season +CREATE TABLE `tool_rank_season` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(50) NOT NULL DEFAULT '', + `type` enum('weekly','monthly') NOT NULL DEFAULT 'weekly', + `start_time` int(10) NOT NULL DEFAULT 0, + `end_time` int(10) NOT NULL DEFAULT 0, + `status` enum('pending','active','settled') NOT NULL DEFAULT 'pending', + `rewards` text COMMENT '奖励配置JSON', + `settle_time` int(10) DEFAULT NULL, + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_type` (`type`), + KEY `idx_status` (`status`), + KEY `idx_time` (`start_time`, `end_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛季定义'; + +-- tool_rank_record +CREATE TABLE `tool_rank_record` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `season_id` int(10) unsigned NOT NULL DEFAULT 0, + `user_id` int(10) unsigned NOT NULL DEFAULT 0, + `rank_type` enum('exp','signin','badge','score') NOT NULL DEFAULT 'exp', + `rank` int(10) NOT NULL DEFAULT 0, + `value` int(10) NOT NULL DEFAULT 0, + `reward_claimed` tinyint(1) NOT NULL DEFAULT 0, + `createtime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_season_type` (`season_id`, `rank_type`), + KEY `idx_user_id` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='排名记录'; + +-- tool_daily_task +CREATE TABLE `tool_daily_task` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(50) NOT NULL DEFAULT '', + `icon` varchar(50) NOT NULL DEFAULT '', + `type` enum('signin','read','favorite','interact','checkin','custom') NOT NULL DEFAULT 'signin', + `target` int(10) NOT NULL DEFAULT 1, + `action` varchar(100) NOT NULL DEFAULT '', + `custom_url` varchar(255) NOT NULL DEFAULT '', + `custom_page` varchar(100) NOT NULL DEFAULT '', + `exp_reward` int(10) NOT NULL DEFAULT 5, + `score_reward` int(10) NOT NULL DEFAULT 2, + `is_random` tinyint(1) NOT NULL DEFAULT 1, + `status` enum('normal','hidden') NOT NULL DEFAULT 'normal', + `weigh` int(10) NOT NULL DEFAULT 0, + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_type` (`type`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日任务定义'; + +-- tool_user_task +CREATE TABLE `tool_user_task` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT 0, + `task_id` int(10) unsigned NOT NULL DEFAULT 0, + `date` date NOT NULL, + `progress` int(10) NOT NULL DEFAULT 0, + `completed` tinyint(1) NOT NULL DEFAULT 0, + `claimed` tinyint(1) NOT NULL DEFAULT 0, + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_task_date` (`user_id`, `task_id`, `date`), + KEY `idx_user_date` (`user_id`, `date`), + KEY `idx_date` (`date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户任务进度'; +``` + +### 8.2 预置数据 + +- tool_badge: 插入20个预置勋章 +- tool_daily_task: 插入8个预置任务(2固定+6随机池) +- tool_rank_season: 自动生成当前周赛和月赛 + +--- + +## 九、服务端新增/修改文件 + +### 9.1 新增API控制器 + +| 文件 | 说明 | +|------|------| +| application/api/controller/Rank.php | 排行榜API | +| application/api/controller/Task.php | 每日任务API | + +### 9.2 修改API控制器 + +| 文件 | 变更 | +|------|------| +| application/api/controller/UserCenter.php | index()增加level/exp/exp_to_next/exp_progress; 新增expLog() | +| application/api/controller/Achievement.php | badges()/badgeDisplay(); claim()增加exp+勋章检测 | +| application/api/controller/UserCenter.php | signin()增加exp产出; checkin()增加exp产出 | + +### 9.3 新增管理控制器 + +| 文件 | 说明 | +|------|------| +| application/admin/controller/Badge.php | 勋章管理CRUD | +| application/admin/controller/user/UserBadge.php | 用户勋章管理 | +| application/admin/controller/RankSeason.php | 赛季管理CRUD | +| application/admin/controller/RankRecord.php | 排名记录查看 | +| application/admin/controller/DailyTask.php | 每日任务CRUD | +| application/admin/controller/user/UserTask.php | 用户任务查看 | +| application/admin/controller/user/ExpLog.php | EXP日志查看 | + +### 9.4 新增管理模型/视图/JS/语言包 + +每个管理模块配套: model + validate + view(index/add/edit) + js + lang + +### 9.5 修改管理控制器 + +| 文件 | 变更 | +|------|------| +| application/admin/controller/user/User.php | edit()增加exp字段编辑 | + +--- + +## 十、客户端新增/修改文件 + +### 10.1 新增页面 + +| 文件 | 说明 | +|------|------| +| lib/features/rank/presentation/rank_page.dart | 排行榜页面 | +| lib/features/rank/providers/rank_provider.dart | 排行榜状态管理 | +| lib/features/rank/services/rank_service.dart | 排行榜API服务 | +| lib/features/task/presentation/daily_task_page.dart | 每日任务页面 | +| lib/features/task/providers/task_provider.dart | 任务状态管理 | +| lib/features/task/services/task_service.dart | 任务API服务 | +| lib/features/achievement/presentation/badge_wall_page.dart | 勋章墙页面 | +| lib/features/achievement/providers/badge_provider.dart | 勋章状态管理 | + +### 10.2 新增组件 + +| 文件 | 说明 | +|------|------| +| lib/shared/widgets/level_card.dart | 等级卡片组件(等级+EXP进度条) | +| lib/shared/widgets/badge_icon.dart | 勋章图标组件(含稀有度边框) | +| lib/shared/widgets/rank_item.dart | 排行榜条目组件 | +| lib/shared/widgets/task_card.dart | 任务卡片组件 | + +### 10.3 修改页面 + +| 文件 | 变更 | +|------|------| +| user_model.dart | 新增level/exp/expToNext字段 | +| user_center_models.dart | 新增level/exp相关字段 | +| signin_page.dart | 签到卡片增加等级展示 | +| achievement_page.dart | 概要增加等级信息; 新增勋章墙入口 | +| user_center_page.dart | 增加等级卡片+勋章展示+排行榜入口+每日任务入口 | + +--- + +## 十一、实施顺序 + +1. **Phase 1: EXP独立体系 + 等级展示** — 数据库迁移 + 服务端API + 客户端展示 +2. **Phase 2: 勋章系统** — 数据库 + 服务端 + 管理员后台 + 客户端勋章墙 +3. **Phase 3: 每日任务** — 数据库 + 服务端 + 管理员后台 + 客户端任务页 +4. **Phase 4: 赛季排行榜** — 数据库 + 服务端 + 管理员后台 + 客户端排行榜页 +5. **Phase 5: 管理员后台完善** — 用户管理扩展 + 菜单 + 权限 + +每个Phase完成后:上传服务器 → 更新文档 → 写测试脚本 → 运行测试 + +--- + +## 十二、归档列表 + +### Phase 1: EXP独立体系 + 等级展示 +- [x] ✅ Task 1.1: 数据库迁移 +- [x] ✅ Task 1.2: 服务端ExpEngine核心(lookup table: Lv1=0,Lv2=30,Lv3=100,Lv4=300,Lv5=800,Lv6=2000,Lv7=5000,Lv8=12000,Lv9=30000,Lv10=80000) +- [x] ✅ Task 1.3: 服务端UserCenter API(index新增level/exp/exp_to_next/exp_progress, 新增expLog(), signin产出EXP) +- [x] ✅ Task 1.4: 客户端数据模型(user_model.dart + user_center_models.dart) +- [x] ✅ Task 1.5: 客户端等级卡片组件(level_card.dart + level_utils.dart) +- [x] ✅ Task 1.6: 客户端页面集成(signin_page + achievement_page + profile_header_row) +- [x] ✅ Task 1.7: 上传服务器+测试(7/7通过) + +### Phase 2: 勋章系统 +- [x] ✅ Task 2.1: 数据库迁移(tool_badge + tool_user_badge + 20预置勋章) +- [x] ✅ Task 2.2: 服务端BadgeEngine(Achievement.php: badges/badgeDisplay/checkBadges) +- [x] ✅ Task 2.3: 管理员后台-勋章管理(Badge CRUD 8文件) +- [x] ✅ Task 2.4: 管理员后台-用户勋章(UserBadge 5文件) +- [x] ✅ Task 2.5: 客户端勋章墙(badge_wall_page + badge_provider + badge_icon) +- [x] ✅ Task 2.6: 上传服务器+测试(7/7通过, checkBadges修复后验证) + +### Phase 3: 每日任务 +- [x] ✅ Task 3.1: 数据库迁移(tool_daily_task + tool_user_task + 8预置任务) +- [x] ✅ Task 3.2: 服务端TaskEngine(Task.php: today/reportProgress/claim/claimPerfect/registerCustom) +- [x] ✅ Task 3.3: 管理员后台-任务管理(DailyTask CRUD 8文件 + UserTask 5文件) +- [x] ✅ Task 3.4: 客户端每日任务页(daily_task_page + task_provider + task_service + task_card) +- [x] ✅ Task 3.5: 上传服务器+测试(9/10通过, 1个断言格式问题非功能bug) + +### Bug修复(2026-05-14) +- [x] ✅ Bug#1&5: feed_weight页面无法访问(创建Model/View/JS/Lang + 修复Controller用Model替代Db) +- [x] ✅ Bug#2: userdeletion通过/拒绝无反应(重写为jQuery AJAX + Layer.confirm替代fetch) +- [x] ✅ Bug#3: user/user删除不生效(重写del()支持批量ID+清理15张关联表) +- [x] ✅ Bug#4: 注销审核数据清理(添加user_exp_log/user_badge/user_task到清理列表) +- [x] ✅ feed_weight_config表创建+18种内容类型默认数据 +- [x] ✅ fa_auth_rule菜单权限插入(feed_weight/daily_task/badge/user_badge/user_task) + +### Phase 4: 赛季排行榜 +- [x] ✅ Task 4.1: 数据库迁移(tool_rank_season + tool_rank_record) +- [x] ✅ Task 4.2: 服务端RankEngine(Rank.php: seasons/leaderboard/myRank/claimReward) +- [x] ✅ Task 4.3: 管理员后台-赛季管理(RankSeason CRUD+结算 9文件 + RankRecord 5文件) +- [x] ✅ Task 4.4: 客户端排行榜页(rank_page + rank_provider + rank_service + rank_item_card) +- [x] ✅ Task 4.5: 上传服务器+SQL迁移+菜单权限 + +### Phase 5: 管理员后台完善 +- [x] ✅ Task 5.1: EXP日志管理页面(UserExpLog 5文件只读列表) +- [x] ✅ Task 5.2: 用户编辑增加exp字段(edit.html + lang) +- [x] ✅ Task 5.3: 菜单权限插入(user_exp_log + rank_season + rank_record) + +### 客户端修复(2026-05-14) +- [x] ✅ task_service.dart: 修复apiServiceProvider不存在,改用ApiClient.instance +- [x] ✅ task_provider.dart: 修复dynamic类型转换,显式as int/String +- [x] ✅ daily_task_page.dart: 修复showCupertinoDialog泛型+data类型转换 +- [x] ✅ rank_item_card.dart: 修复level_utils.dart导入路径 diff --git a/docs/toolsapi/CHANGELOG.md b/docs/toolsapi/CHANGELOG.md index 53ddc8d9..a3615b60 100644 --- a/docs/toolsapi/CHANGELOG.md +++ b/docs/toolsapi/CHANGELOG.md @@ -1,5 +1,61 @@ # CHANGELOG +## v10.1.0 (2026-05-14) + +### 🛡️ 新增密保问题功能 & 多验证方式支持 + +**重大更新**:新增密保问题(Security Question)功能,注册时可选填密保问题;修改密码/修改密保/修改邮箱/修改手机号支持多种验证方式。 + +#### 新增功能 + +| 功能 | 说明 | +|------|------| +| 🛡️ 密保问题 | 8个预置密保问题,注册时可选填,用于身份验证 | +| 📋 获取密保问题列表 | GET /api/user_security/secQuestions,无需登录 | +| ✏️ 修改密保问题 | POST /api/user_security/changeSecQuestion,支持密码/密保/回执验证 | +| 🔑 修改密码多验证 | changepwd支持password/sec_question/receipt三种验证方式 | +| 📧 修改邮箱多验证 | changeemail支持receipt/sec_question两种验证方式 | +| 📱 修改手机多验证 | changemobile支持receipt/sec_question两种验证方式 | + +#### 数据库变更 + +| 表 | 变更 | 说明 | +|----|------|------| +| tool_user | +sec_question | tinyint(2) unsigned, 密保问题编号(0=未设置,1-8) | +| tool_user | +sec_answer | varchar(32), 密保答案MD5哈希 | +| tool_user | +idx_sec_question | 索引 | + +#### 预置密保问题 + +| ID | 问题 | +|----|------| +| 1 | 您母亲的姓名是? | +| 2 | 您的第一只宠物叫什么? | +| 3 | 您就读的小学名称是? | +| 4 | 您的出生地是? | +| 5 | 您最喜欢的电影是? | +| 6 | 您最好朋友的名字是? | +| 7 | 您父亲的姓名是? | +| 8 | 您的童年昵称是? | + +#### 修改文件 + +- `application/api/controller/UserSecurity.php` - 新增secQuestions/changeSecQuestion方法; register增加可选密保; changepwd/changeemail/changemobile支持多验证方式 +- `application/api/controller/UserCenter.php` - extra字段新增sec_question密保信息 +- `application/admin/command/Install/migrate_v10_1.sql` - 数据库迁移脚本 + +#### 文档更新 + +- `docs/API_USER_SECURITY_DOC.md` v9.2.0 → v10.1.0 +- `docs/API_USER_CENTER_DOC.md` v1.7.0 → v1.8.0 + +#### 测试验证 + +- 全流程测试20/20通过:注册→密保验证→密码修改→邮箱修改→手机修改→密保修改→注销 ✅ +- 测试账号已提交注销申请 ✅ + +--- + ## v9.0.1 (2026-05-09) ### 🔧 修复管理员编辑页面 & 新增字段编辑支持 diff --git a/docs/toolsapi/application/admin/command/Install/migrate_v10_1.sql b/docs/toolsapi/application/admin/command/Install/migrate_v10_1.sql new file mode 100644 index 00000000..b98775e3 --- /dev/null +++ b/docs/toolsapi/application/admin/command/Install/migrate_v10_1.sql @@ -0,0 +1,15 @@ +-- ===================================================== +-- 迁移脚本: v10.1 - 密保问题字段 +-- 时间: 2026-05-14 +-- 说明: tool_user 表新增 sec_question(密保问题编号) 和 sec_answer(密保答案哈希) 字段 +-- 预置问题: 1=您母亲的姓名是? 2=您的第一只宠物叫什么? 3=您就读的小学名称是? +-- 4=您的出生地是? 5=您最喜欢的电影是? 6=您最好朋友的名字是? +-- 7=您父亲的姓名是? 8=您的童年昵称是? +-- ===================================================== + +ALTER TABLE `tool_user` + ADD COLUMN `sec_question` TINYINT(2) UNSIGNED NOT NULL DEFAULT 0 COMMENT '密保问题编号(0=未设置,1-8=预置问题)' AFTER `avatar_url`, + ADD COLUMN `sec_answer` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '密保答案MD5哈希' AFTER `sec_question`; + +-- 索引(可选,用于查询已设置密保的用户) +ALTER TABLE `tool_user` ADD INDEX `idx_sec_question` (`sec_question`); diff --git a/docs/toolsapi/application/admin/command/Install/migrate_v11.sql b/docs/toolsapi/application/admin/command/Install/migrate_v11.sql new file mode 100644 index 00000000..36a166bd --- /dev/null +++ b/docs/toolsapi/application/admin/command/Install/migrate_v11.sql @@ -0,0 +1,73 @@ +-- ===================================================== +-- 迁移脚本: v11 - EXP独立体系 + 等级展示 + 勋章系统 +-- 时间: 2026-05-14 +-- 说明: tool_user 新增 exp 字段; 新建 tool_user_exp_log/tool_badge/tool_user_badge 表 +-- 等级阶梯: Lv1=0,Lv2=30,Lv3=100,Lv4=300,Lv5=800,Lv6=2000,Lv7=5000,Lv8=12000,Lv9=30000,Lv10=80000 +-- ===================================================== + +-- EXP字段(如已执行则跳过) +-- ALTER TABLE `tool_user` ADD COLUMN `exp` INT(10) UNSIGNED NOT NULL DEFAULT 0 COMMENT '经验值(只增不减)' AFTER `score`; +-- ALTER TABLE `tool_user` ADD INDEX `idx_exp` (`exp`); + +-- EXP日志表(如已执行则跳过) +-- CREATE TABLE `tool_user_exp_log` (...) + +-- ===================================================== +-- 勋章系统 +-- ===================================================== + +CREATE TABLE IF NOT EXISTS `tool_badge` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(50) NOT NULL DEFAULT '' COMMENT '勋章名称', + `icon` varchar(50) NOT NULL DEFAULT '' COMMENT '图标(emoji)', + `description` varchar(255) NOT NULL DEFAULT '' COMMENT '勋章描述', + `rarity` enum('common','rare','epic','legendary') NOT NULL DEFAULT 'common' COMMENT '稀有度', + `type` varchar(30) NOT NULL DEFAULT '' COMMENT '类型(signin/achievement/task/rank/special)', + `condition_type` varchar(30) NOT NULL DEFAULT '' COMMENT '条件类型(count/reach/action)', + `condition_value` int(10) NOT NULL DEFAULT 0 COMMENT '条件值', + `exp_reward` int(10) NOT NULL DEFAULT 0 COMMENT '解锁奖励EXP', + `score_reward` int(10) NOT NULL DEFAULT 0 COMMENT '解锁奖励积分', + `weigh` int(10) NOT NULL DEFAULT 0 COMMENT '排序权重', + `status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态', + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_type` (`type`), + KEY `idx_rarity` (`rarity`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='勋章定义'; + +CREATE TABLE IF NOT EXISTS `tool_user_badge` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '用户ID', + `badge_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '勋章ID', + `is_displayed` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否展示在主页(最多3个)', + `unlocked_at` int(10) DEFAULT NULL COMMENT '解锁时间', + `createtime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_badge` (`user_id`, `badge_id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_badge_id` (`badge_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户勋章'; + +-- 预置勋章数据(20个) +INSERT INTO `tool_badge` (`id`, `name`, `icon`, `description`, `rarity`, `type`, `condition_type`, `condition_value`, `exp_reward`, `score_reward`, `weigh`, `status`) VALUES +(1, '初次签到', '🌅', '完成第一次签到', 'common', 'signin', 'count', 1, 5, 2, 100, 'normal'), +(2, '坚持一周', '📅', '连续签到7天', 'rare', 'signin', 'count', 7, 20, 10, 90, 'normal'), +(3, '月度全勤', '🏆', '连续签到30天', 'epic', 'signin', 'count', 30, 50, 30, 80, 'normal'), +(4, '笔耕不辍', '✍️', '发表第一篇文章', 'common', 'article', 'count', 1, 5, 2, 70, 'normal'), +(5, '专栏作家', '📝', '发表10篇文章', 'rare', 'article', 'count', 10, 20, 10, 60, 'normal'), +(6, '收藏新手', '⭐', '收藏10篇内容', 'common', 'favorite', 'count', 10, 5, 2, 50, 'normal'), +(7, '知识宝库', '🏛️', '收藏50篇内容', 'rare', 'favorite', 'count', 50, 20, 10, 40, 'normal'), +(8, '笔记达人', '📒', '写5条笔记', 'common', 'note', 'count', 5, 5, 2, 30, 'normal'), +(9, '笔记大师', '📚', '写50条笔记', 'epic', 'note', 'count', 50, 50, 30, 20, 'normal'), +(10, '互动之星', '💬', '互动100次', 'rare', 'interact', 'count', 100, 20, 10, 10, 'normal'), +(11, '游戏高手', '🎮', '游戏互动20次', 'rare', 'game', 'count', 20, 20, 10, 0, 'normal'), +(12, '探索者', '🔍', '搜索50次', 'common', 'search', 'count', 50, 5, 2, -10, 'normal'), +(13, '学习打卡7天', '🎯', '学习打卡7天', 'rare', 'checkin', 'count', 7, 20, 10, -20, 'normal'), +(14, '学习打卡30天','🎓', '学习打卡30天', 'epic', 'checkin', 'count', 30, 50, 30, -30, 'normal'), +(15, '等级5', '⭐', '达到等级5', 'rare', 'level', 'reach', 5, 20, 10, -40, 'normal'), +(16, '等级10', '👑', '达到等级10(满级)', 'legendary', 'level', 'reach', 10, 100, 50, -50, 'normal'), +(17, '周赛冠军', '🥇', '周赛排名第1', 'legendary', 'rank', 'reach', 1, 100, 50, -60, 'normal'), +(18, '月赛冠军', '🏅', '月赛排名第1', 'legendary', 'rank', 'reach', 1, 200, 100, -70, 'normal'), +(19, '完美周', '✨', '一周内7天完美日', 'epic', 'task', 'count', 7, 50, 30, -80, 'normal'), +(20, '全勤月', '🎊', '一个月内全部签到', 'epic', 'signin', 'count', 30, 50, 30, -90, 'normal'); diff --git a/docs/toolsapi/application/admin/command/Install/migrate_v12.sql b/docs/toolsapi/application/admin/command/Install/migrate_v12.sql new file mode 100644 index 00000000..2c49dd05 --- /dev/null +++ b/docs/toolsapi/application/admin/command/Install/migrate_v12.sql @@ -0,0 +1,57 @@ +-- ===================================================== +-- 迁移脚本: v12 - 每日任务系统 +-- 时间: 2026-05-14 +-- 说明: 新建 tool_daily_task / tool_user_task 表 + 预置任务数据 +-- ===================================================== + +SET NAMES utf8mb4; + +-- 每日任务定义表 +CREATE TABLE IF NOT EXISTS `tool_daily_task` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(50) NOT NULL DEFAULT '' COMMENT '任务名称', + `icon` varchar(50) NOT NULL DEFAULT '' COMMENT '图标(emoji)', + `type` enum('signin','read','favorite','interact','checkin','custom') NOT NULL DEFAULT 'signin' COMMENT '任务类型', + `target` int(10) NOT NULL DEFAULT 1 COMMENT '目标值', + `action` varchar(100) NOT NULL DEFAULT '' COMMENT '触发行为标识', + `custom_url` varchar(255) NOT NULL DEFAULT '' COMMENT '自定义URL(custom类型)', + `custom_page` varchar(100) NOT NULL DEFAULT '' COMMENT '自定义页面标识(custom类型)', + `exp_reward` int(10) NOT NULL DEFAULT 5 COMMENT 'EXP奖励', + `score_reward` int(10) NOT NULL DEFAULT 2 COMMENT '积分奖励', + `is_random` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否随机出现(1=随机池,0=固定)', + `status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态', + `weigh` int(10) NOT NULL DEFAULT 0 COMMENT '排序权重', + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_type` (`type`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日任务定义'; + +-- 用户任务进度表 +CREATE TABLE IF NOT EXISTS `tool_user_task` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '用户ID', + `task_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '任务ID', + `date` date NOT NULL COMMENT '任务日期', + `progress` int(10) NOT NULL DEFAULT 0 COMMENT '当前进度', + `completed` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否完成', + `claimed` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否领取奖励', + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_task_date` (`user_id`, `task_id`, `date`), + KEY `idx_user_date` (`user_id`, `date`), + KEY `idx_date` (`date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户任务进度'; + +-- 预置任务数据(2固定+6随机池) +INSERT INTO `tool_daily_task` (`id`, `name`, `icon`, `type`, `target`, `action`, `custom_url`, `custom_page`, `exp_reward`, `score_reward`, `is_random`, `weigh`, `status`) VALUES +(1, '每日签到', '✅', 'signin', 1, 'signin', '', '', 5, 2, 0, 100, 'normal'), +(2, '学习打卡', '📖', 'checkin', 1, 'checkin', '', '', 10, 3, 0, 90, 'normal'), +(3, '阅读文章', '📰', 'read', 3, 'read', '', '', 5, 2, 1, 80, 'normal'), +(4, '收藏内容', '⭐', 'favorite', 1, 'favorite', '', '', 5, 2, 1, 70, 'normal'), +(5, '互动交流', '💬', 'interact', 2, 'interact', '', '', 5, 2, 1, 60, 'normal'), +(6, '探索搜索', '🔍', 'read', 1, 'search', '', '', 3, 1, 1, 50, 'normal'), +(7, '浏览推荐', '👀', 'custom', 1, 'custom', '/daily-card', 'daily_card', 5, 2, 1, 40, 'normal'), +(8, '使用工具', '🛠️', 'custom', 1, 'custom', '', 'tool_page', 5, 2, 1, 30, 'normal'); diff --git a/docs/toolsapi/application/admin/command/Install/migrate_v13.sql b/docs/toolsapi/application/admin/command/Install/migrate_v13.sql new file mode 100644 index 00000000..e5c8312d --- /dev/null +++ b/docs/toolsapi/application/admin/command/Install/migrate_v13.sql @@ -0,0 +1,38 @@ +-- ===================================================== +-- 迁移脚本: v13 - 赛季排行榜 +-- 时间: 2026-05-14 +-- 说明: 新建 tool_rank_season / tool_rank_record 表 +-- ===================================================== + +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `tool_rank_season` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(50) NOT NULL DEFAULT '' COMMENT '赛季名称', + `type` enum('weekly','monthly') NOT NULL DEFAULT 'weekly' COMMENT '赛季类型', + `start_time` int(10) NOT NULL DEFAULT 0 COMMENT '开始时间', + `end_time` int(10) NOT NULL DEFAULT 0 COMMENT '结束时间', + `status` enum('pending','active','settled') NOT NULL DEFAULT 'pending' COMMENT '状态', + `rewards` text COMMENT '奖励配置JSON', + `settle_time` int(10) DEFAULT 0 COMMENT '结算时间', + `createtime` int(10) DEFAULT NULL, + `updatetime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_type_status` (`type`, `status`), + KEY `idx_time` (`start_time`, `end_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛季定义'; + +CREATE TABLE IF NOT EXISTS `tool_rank_record` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `season_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '赛季ID', + `user_id` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '用户ID', + `rank_type` enum('exp','signin','badge','score') NOT NULL DEFAULT 'exp' COMMENT '排行类型', + `rank` int(10) NOT NULL DEFAULT 0 COMMENT '排名', + `value` int(10) NOT NULL DEFAULT 0 COMMENT '排行值', + `reward_claimed` tinyint(1) NOT NULL DEFAULT 0 COMMENT '奖励是否已领取', + `createtime` int(10) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_season_user_type` (`season_id`, `user_id`, `rank_type`), + KEY `idx_season_type` (`season_id`, `rank_type`), + KEY `idx_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='排名记录'; diff --git a/docs/toolsapi/application/admin/controller/Badge.php b/docs/toolsapi/application/admin/controller/Badge.php new file mode 100644 index 00000000..3fe39982 --- /dev/null +++ b/docs/toolsapi/application/admin/controller/Badge.php @@ -0,0 +1,23 @@ +model = new \app\admin\model\Badge; + } +} diff --git a/docs/toolsapi/application/admin/controller/DailyTask.php b/docs/toolsapi/application/admin/controller/DailyTask.php new file mode 100644 index 00000000..1a3f26f5 --- /dev/null +++ b/docs/toolsapi/application/admin/controller/DailyTask.php @@ -0,0 +1,23 @@ +model = new \app\admin\model\DailyTask; + } +} diff --git a/docs/toolsapi/application/admin/controller/FeedWeight.php b/docs/toolsapi/application/admin/controller/FeedWeight.php index 2302ed6a..845f0eec 100644 --- a/docs/toolsapi/application/admin/controller/FeedWeight.php +++ b/docs/toolsapi/application/admin/controller/FeedWeight.php @@ -11,15 +11,17 @@ namespace app\admin\controller; use app\common\controller\Backend; use think\Db; +use app\admin\model\FeedWeightConfig as FeedWeightConfigModel; class FeedWeight extends Backend { protected $model = null; + protected $searchFields = 'id,feed_type'; public function _initialize() { parent::_initialize(); - $this->model = Db::name('feed_weight_config'); + $this->model = new FeedWeightConfigModel; } /** diff --git a/docs/toolsapi/application/admin/controller/RankSeason.php b/docs/toolsapi/application/admin/controller/RankSeason.php new file mode 100644 index 00000000..b762fe90 --- /dev/null +++ b/docs/toolsapi/application/admin/controller/RankSeason.php @@ -0,0 +1,39 @@ +model = new \app\admin\model\RankSeason; + } + + /** + * @name 手动结算赛季 + */ + public function settle($ids = '') + { + $ids = $ids ? $ids : $this->request->param('ids'); + if (!$ids) $this->error('参数错误'); + + $row = $this->model->get($ids); + if (!$row) $this->error('赛季不存在'); + if ($row['status'] === 'settled') $this->error('赛季已结算'); + + $this->model->where('id', $ids)->update(['status' => 'settled', 'settle_time' => time(), 'updatetime' => time()]); + $this->success('结算成功'); + } +} diff --git a/docs/toolsapi/application/admin/controller/Userdeletion.php b/docs/toolsapi/application/admin/controller/Userdeletion.php index f6221864..ec70ac88 100644 --- a/docs/toolsapi/application/admin/controller/Userdeletion.php +++ b/docs/toolsapi/application/admin/controller/Userdeletion.php @@ -190,6 +190,9 @@ class Userdeletion extends Backend 'article_like', 'article_rating', 'feed_interaction', + 'user_exp_log', + 'user_badge', + 'user_task', ]; foreach ($relatedTables as $table) { @@ -205,6 +208,8 @@ class Userdeletion extends Backend ]); } catch (\Exception $e) {} + try { Db::name('user_deletion')->where('user_id', $userId)->where('id', '<>', $record['id'])->delete(); } catch (\Exception $e) {} + Db::name('user')->where('id', $userId)->delete(); Db::name('user_deletion')->where('id', $record['id'])->update([ diff --git a/docs/toolsapi/application/admin/controller/user/RankRecord.php b/docs/toolsapi/application/admin/controller/user/RankRecord.php new file mode 100644 index 00000000..98ea1a1b --- /dev/null +++ b/docs/toolsapi/application/admin/controller/user/RankRecord.php @@ -0,0 +1,23 @@ +model = new \app\admin\model\RankRecord; + } +} diff --git a/docs/toolsapi/application/admin/controller/user/User.php b/docs/toolsapi/application/admin/controller/user/User.php index cd81d1c7..de54e696 100644 --- a/docs/toolsapi/application/admin/controller/user/User.php +++ b/docs/toolsapi/application/admin/controller/user/User.php @@ -4,6 +4,7 @@ namespace app\admin\controller\user; use app\common\controller\Backend; use app\common\library\Auth; +use think\Db; /** * 会员管理 @@ -114,7 +115,9 @@ class User extends Backend } /** - * 删除 + * @name 删除用户 + * @desc 批量删除用户及其关联数据(签到/收藏/笔记/积分日志/勋章/任务/EXP日志等) + * @update v12.0.0 修复批量删除+关联数据清理 */ public function del($ids = "") { @@ -122,12 +125,40 @@ class User extends Backend $this->error(__("Invalid parameters")); } $ids = $ids ? $ids : $this->request->post("ids"); - $row = $this->model->get($ids); - $this->modelValidate = true; - if (!$row) { - $this->error(__('No Results were found')); + if (!$ids) { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + $idArr = array_filter(explode(',', $ids), 'is_numeric'); + if (empty($idArr)) { + $this->error(__('Parameter %s can not be empty', 'ids')); + } + + foreach ($idArr as $userId) { + Db::startTrans(); + try { + $relatedTables = [ + 'user_token', 'user_device', 'user_signin', 'user_favorite', + 'user_note', 'user_score_log', 'user_money_log', 'user_title_log', + 'coin_log', 'article_like', 'article_rating', 'feed_interaction', + 'user_exp_log', 'user_badge', 'user_task', + ]; + foreach ($relatedTables as $table) { + try { Db::name($table)->where('user_id', $userId)->delete(); } catch (\Exception $e) {} + } + try { + Db::name('article')->where('user_id', $userId)->update([ + 'deletetime' => time(), + 'status' => 'rejected', + ]); + } catch (\Exception $e) {} + try { Db::name('user_deletion')->where('user_id', $userId)->delete(); } catch (\Exception $e) {} + Db::name('user')->where('id', $userId)->delete(); + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + $this->error('删除用户ID:' . $userId . '失败 - ' . $e->getMessage()); + } } - Auth::instance()->delete($row['id']); $this->success(); } diff --git a/docs/toolsapi/application/admin/controller/user/UserBadge.php b/docs/toolsapi/application/admin/controller/user/UserBadge.php new file mode 100644 index 00000000..892cfcd9 --- /dev/null +++ b/docs/toolsapi/application/admin/controller/user/UserBadge.php @@ -0,0 +1,70 @@ +model = new \app\admin\model\UserBadge; + } + + public function index() + { + $this->relationSearch = true; + $this->request->filter(['strip_tags', 'trim']); + if ($this->request->isAjax()) { + list($where, $sort, $order, $offset, $limit) = $this->buildparams(); + $list = $this->model + ->with(['user', 'badge']) + ->where($where) + ->order($sort, $order) + ->paginate($limit); + foreach ($list as $row) { + $row->visible(['id', 'user_id', 'badge_id', 'is_displayed', 'unlocked_at', 'createtime']); + $row->visible(['user', 'badge']); + if ($row->user) $row->getRelation('user')->visible(['id', 'nickname']); + if ($row->badge) $row->getRelation('badge')->visible(['id', 'name', 'icon']); + } + $result = array("total" => $list->total(), "rows" => $list->items()); + return json($result); + } + return $this->view->fetch(); + } + + public function add() + { + if ($this->request->isPost()) { + $params = $this->request->post('row/a'); + if (!$params) $this->error(__('Invalid parameters')); + $userId = intval($params['user_id'] ?? 0); + $badgeId = intval($params['badge_id'] ?? 0); + if (!$userId || !$badgeId) $this->error('用户ID和勋章ID必填'); + $exists = db('user_badge')->where('user_id', $userId)->where('badge_id', $badgeId)->find(); + if ($exists) $this->error('该用户已拥有此勋章'); + db('user_badge')->insert([ + 'user_id' => $userId, + 'badge_id' => $badgeId, + 'is_displayed' => 0, + 'unlocked_at' => time(), + 'createtime' => time(), + ]); + $this->success(); + } + return parent::add(); + } +} diff --git a/docs/toolsapi/application/admin/controller/user/UserExpLog.php b/docs/toolsapi/application/admin/controller/user/UserExpLog.php new file mode 100644 index 00000000..57dfc3f2 --- /dev/null +++ b/docs/toolsapi/application/admin/controller/user/UserExpLog.php @@ -0,0 +1,23 @@ +model = new \app\admin\model\UserExpLog; + } +} diff --git a/docs/toolsapi/application/admin/controller/user/UserTask.php b/docs/toolsapi/application/admin/controller/user/UserTask.php new file mode 100644 index 00000000..db82ca99 --- /dev/null +++ b/docs/toolsapi/application/admin/controller/user/UserTask.php @@ -0,0 +1,23 @@ +model = new \app\admin\model\UserTask; + } +} diff --git a/docs/toolsapi/application/admin/lang/zh-cn/badge.php b/docs/toolsapi/application/admin/lang/zh-cn/badge.php new file mode 100644 index 00000000..d8a0ff43 --- /dev/null +++ b/docs/toolsapi/application/admin/lang/zh-cn/badge.php @@ -0,0 +1,17 @@ + '勋章名称', + 'Icon' => '图标', + 'Description' => '描述', + 'Rarity' => '稀有度', + 'Type' => '类型', + 'Condition_type' => '条件类型', + 'Condition_value' => '条件值', + 'Exp_reward' => 'EXP奖励', + 'Score_reward' => '积分奖励', + 'Weigh' => '排序', + 'Status' => '状态', + 'Createtime' => '创建时间', + 'Updatetime' => '更新时间', +]; diff --git a/docs/toolsapi/application/admin/lang/zh-cn/daily_task.php b/docs/toolsapi/application/admin/lang/zh-cn/daily_task.php new file mode 100644 index 00000000..873af9aa --- /dev/null +++ b/docs/toolsapi/application/admin/lang/zh-cn/daily_task.php @@ -0,0 +1,18 @@ + '任务名称', + 'Icon' => '图标', + 'Type' => '任务类型', + 'Target' => '目标值', + 'Action' => '触发行为', + 'Custom_url' => '自定义URL', + 'Custom_page' => '自定义页面', + 'Exp_reward' => 'EXP奖励', + 'Score_reward' => '积分奖励', + 'Is_random' => '随机任务', + 'Weigh' => '排序', + 'Status' => '状态', + 'Createtime' => '创建时间', + 'Updatetime' => '更新时间', +]; diff --git a/docs/toolsapi/application/admin/lang/zh-cn/feed_weight.php b/docs/toolsapi/application/admin/lang/zh-cn/feed_weight.php new file mode 100644 index 00000000..d7d9e90c --- /dev/null +++ b/docs/toolsapi/application/admin/lang/zh-cn/feed_weight.php @@ -0,0 +1,18 @@ + '内容类型', + 'Weight' => '推荐权重', + 'Display_weight' => '展示权重', + 'Push_limit' => '推送上限', + 'Is_enabled' => '启用状态', + 'Push_count' => '今日推送', + 'Push_date' => '推送日期', +]; diff --git a/docs/toolsapi/application/admin/lang/zh-cn/rank_season.php b/docs/toolsapi/application/admin/lang/zh-cn/rank_season.php new file mode 100644 index 00000000..821c8215 --- /dev/null +++ b/docs/toolsapi/application/admin/lang/zh-cn/rank_season.php @@ -0,0 +1,13 @@ + '赛季名称', + 'Type' => '赛季类型', + 'Start_time' => '开始时间', + 'End_time' => '结束时间', + 'Status' => '状态', + 'Rewards' => '奖励配置', + 'Settle_time'=> '结算时间', + 'Createtime' => '创建时间', + 'Updatetime' => '更新时间', +]; diff --git a/docs/toolsapi/application/admin/lang/zh-cn/user/rank_record.php b/docs/toolsapi/application/admin/lang/zh-cn/user/rank_record.php new file mode 100644 index 00000000..6642bd3f --- /dev/null +++ b/docs/toolsapi/application/admin/lang/zh-cn/user/rank_record.php @@ -0,0 +1,13 @@ + '赛季ID', + 'Season' => '赛季', + 'User_id' => '用户ID', + 'User' => '用户', + 'Rank_type' => '排行类型', + 'Rank' => '排名', + 'Value' => '排行值', + 'Claimed' => '领取状态', + 'Createtime' => '创建时间', +]; diff --git a/docs/toolsapi/application/admin/lang/zh-cn/user/user.php b/docs/toolsapi/application/admin/lang/zh-cn/user/user.php index 13f096df..824b1c1c 100644 --- a/docs/toolsapi/application/admin/lang/zh-cn/user/user.php +++ b/docs/toolsapi/application/admin/lang/zh-cn/user/user.php @@ -17,6 +17,7 @@ return [ 'Birthday' => '生日', 'Bio' => '格言', 'Score' => '积分', + 'Exp' => '经验值', 'Successions' => '连续登录天数', 'Maxsuccessions' => '最大连续登录天数', 'Prevtime' => '上次登录时间', diff --git a/docs/toolsapi/application/admin/lang/zh-cn/user/user_badge.php b/docs/toolsapi/application/admin/lang/zh-cn/user/user_badge.php new file mode 100644 index 00000000..aece71ea --- /dev/null +++ b/docs/toolsapi/application/admin/lang/zh-cn/user/user_badge.php @@ -0,0 +1,8 @@ + '用户ID', + 'Badge_id' => '勋章ID', + 'Is_displayed' => '是否展示', + 'Unlocked_at' => '解锁时间', +]; diff --git a/docs/toolsapi/application/admin/lang/zh-cn/user/user_exp_log.php b/docs/toolsapi/application/admin/lang/zh-cn/user/user_exp_log.php new file mode 100644 index 00000000..7f95a3a8 --- /dev/null +++ b/docs/toolsapi/application/admin/lang/zh-cn/user/user_exp_log.php @@ -0,0 +1,12 @@ + '用户ID', + 'User' => '用户', + 'Action' => '行为', + 'Amount' => '变动量', + 'Before' => '变动前', + 'After' => '变动后', + 'Remark' => '备注', + 'Createtime' => '创建时间', +]; diff --git a/docs/toolsapi/application/admin/lang/zh-cn/user/user_task.php b/docs/toolsapi/application/admin/lang/zh-cn/user/user_task.php new file mode 100644 index 00000000..0e9b61a0 --- /dev/null +++ b/docs/toolsapi/application/admin/lang/zh-cn/user/user_task.php @@ -0,0 +1,14 @@ + '用户ID', + 'User' => '用户', + 'Task_id' => '任务ID', + 'Task_name' => '任务名称', + 'Date' => '日期', + 'Progress' => '进度', + 'Completed' => '完成状态', + 'Claimed' => '领取状态', + 'Createtime' => '创建时间', + 'Updatetime' => '更新时间', +]; diff --git a/docs/toolsapi/application/admin/model/Badge.php b/docs/toolsapi/application/admin/model/Badge.php new file mode 100644 index 00000000..b54490e7 --- /dev/null +++ b/docs/toolsapi/application/admin/model/Badge.php @@ -0,0 +1,31 @@ + '普通', 'rare' => '稀有', 'epic' => '史诗', 'legendary' => '传说']; + } + + public function getStatusList() + { + return ['normal' => '正常', 'hidden' => '隐藏']; + } +} diff --git a/docs/toolsapi/application/admin/model/DailyTask.php b/docs/toolsapi/application/admin/model/DailyTask.php new file mode 100644 index 00000000..a0aea1d2 --- /dev/null +++ b/docs/toolsapi/application/admin/model/DailyTask.php @@ -0,0 +1,36 @@ + '签到', 'read' => '阅读', 'favorite' => '收藏', 'interact' => '互动', 'checkin' => '打卡', 'custom' => '自定义']; + } + + public function getStatusList() + { + return ['normal' => '正常', 'hidden' => '隐藏']; + } + + public function getIsRandomList() + { + return [0 => '固定任务', 1 => '随机任务']; + } +} diff --git a/docs/toolsapi/application/admin/model/FeedWeightConfig.php b/docs/toolsapi/application/admin/model/FeedWeightConfig.php new file mode 100644 index 00000000..07c314fd --- /dev/null +++ b/docs/toolsapi/application/admin/model/FeedWeightConfig.php @@ -0,0 +1,28 @@ + '禁用', 1 => '启用']; + } +} diff --git a/docs/toolsapi/application/admin/model/RankRecord.php b/docs/toolsapi/application/admin/model/RankRecord.php new file mode 100644 index 00000000..6387f467 --- /dev/null +++ b/docs/toolsapi/application/admin/model/RankRecord.php @@ -0,0 +1,40 @@ +belongsTo('User', 'user_id', 'id', [], 'LEFT')->setEagerlyType(0); + } + + public function season() + { + return $this->belongsTo('RankSeason', 'season_id', 'id', [], 'LEFT')->setEagerlyType(0); + } + + public function getRankTypeList() + { + return ['exp' => '经验榜', 'signin' => '签到榜', 'badge' => '勋章榜', 'score' => '积分榜']; + } + + public function getRewardClaimedList() + { + return [0 => '未领取', 1 => '已领取']; + } +} diff --git a/docs/toolsapi/application/admin/model/RankSeason.php b/docs/toolsapi/application/admin/model/RankSeason.php new file mode 100644 index 00000000..8f6de294 --- /dev/null +++ b/docs/toolsapi/application/admin/model/RankSeason.php @@ -0,0 +1,30 @@ + '周赛', 'monthly' => '月赛']; + } + + public function getStatusList() + { + return ['pending' => '待开始', 'active' => '进行中', 'settled' => '已结算']; + } +} diff --git a/docs/toolsapi/application/admin/model/UserBadge.php b/docs/toolsapi/application/admin/model/UserBadge.php new file mode 100644 index 00000000..e58cb3c4 --- /dev/null +++ b/docs/toolsapi/application/admin/model/UserBadge.php @@ -0,0 +1,30 @@ +belongsTo('User', 'user_id', 'id', [], 'LEFT')->setEagerlyType(0); + } + + public function badge() + { + return $this->belongsTo('Badge', 'badge_id', 'id', [], 'LEFT')->setEagerlyType(0); + } +} diff --git a/docs/toolsapi/application/admin/model/UserExpLog.php b/docs/toolsapi/application/admin/model/UserExpLog.php new file mode 100644 index 00000000..5a011088 --- /dev/null +++ b/docs/toolsapi/application/admin/model/UserExpLog.php @@ -0,0 +1,30 @@ +belongsTo('User', 'user_id', 'id', [], 'LEFT')->setEagerlyType(0); + } + + public function getActionList() + { + return ['signin' => '签到', 'task' => '任务', 'achievement' => '成就', 'badge' => '勋章', 'perfect_day' => '完美日', 'rank' => '排行', 'admin' => '管理员']; + } +} diff --git a/docs/toolsapi/application/admin/model/UserTask.php b/docs/toolsapi/application/admin/model/UserTask.php new file mode 100644 index 00000000..ab9eb518 --- /dev/null +++ b/docs/toolsapi/application/admin/model/UserTask.php @@ -0,0 +1,41 @@ +belongsTo('User', 'user_id', 'id', [], 'LEFT')->setEagerlyType(0); + } + + public function task() + { + return $this->belongsTo('DailyTask', 'task_id', 'id', [], 'LEFT')->setEagerlyType(0); + } + + public function getCompletedList() + { + return [0 => '未完成', 1 => '已完成']; + } + + public function getClaimedList() + { + return [0 => '未领取', 1 => '已领取']; + } +} diff --git a/docs/toolsapi/application/admin/validate/Badge.php b/docs/toolsapi/application/admin/validate/Badge.php new file mode 100644 index 00000000..d27bd108 --- /dev/null +++ b/docs/toolsapi/application/admin/validate/Badge.php @@ -0,0 +1,40 @@ + 'require|max:50', + 'icon' => 'require|max:50', + 'rarity' => 'require|in:common,rare,epic,legendary', + 'type' => 'require|max:30', + 'condition_value' => 'require|number|>=:0', + ]; + + protected $message = [ + 'name.require' => '勋章名称不能为空', + 'name.max' => '勋章名称不能超过50个字符', + 'icon.require' => '图标不能为空', + 'icon.max' => '图标不能超过50个字符', + 'rarity.require' => '稀有度不能为空', + 'rarity.in' => '稀有度值不合法', + 'type.require' => '类型不能为空', + 'type.max' => '类型不能超过30个字符', + 'condition_value.require' => '条件值不能为空', + 'condition_value.number' => '条件值必须为数字', + 'condition_value.>=' => '条件值不能小于0', + ]; + + protected $scene = [ + 'add' => ['name', 'icon', 'rarity', 'type', 'condition_value'], + 'edit' => ['name', 'icon', 'rarity', 'type', 'condition_value'], + ]; +} diff --git a/docs/toolsapi/application/admin/validate/DailyTask.php b/docs/toolsapi/application/admin/validate/DailyTask.php new file mode 100644 index 00000000..0ce5f865 --- /dev/null +++ b/docs/toolsapi/application/admin/validate/DailyTask.php @@ -0,0 +1,37 @@ + 'require|max:50', + 'icon' => 'require|max:50', + 'type' => 'require|in:signin,read,favorite,interact,checkin,custom', + 'target' => 'require|number|>=:1', + ]; + + protected $message = [ + 'name.require' => '任务名称不能为空', + 'name.max' => '任务名称不能超过50个字符', + 'icon.require' => '图标不能为空', + 'icon.max' => '图标不能超过50个字符', + 'type.require' => '任务类型不能为空', + 'type.in' => '任务类型不合法', + 'target.require' => '目标值不能为空', + 'target.number' => '目标值必须为数字', + 'target.>=' => '目标值不能小于1', + ]; + + protected $scene = [ + 'add' => ['name', 'icon', 'type', 'target'], + 'edit' => ['name', 'icon', 'type', 'target'], + ]; +} diff --git a/docs/toolsapi/application/admin/view/badge/add.html b/docs/toolsapi/application/admin/view/badge/add.html new file mode 100644 index 00000000..924d73f9 --- /dev/null +++ b/docs/toolsapi/application/admin/view/badge/add.html @@ -0,0 +1,99 @@ +

+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
diff --git a/docs/toolsapi/application/admin/view/badge/edit.html b/docs/toolsapi/application/admin/view/badge/edit.html new file mode 100644 index 00000000..eae7d393 --- /dev/null +++ b/docs/toolsapi/application/admin/view/badge/edit.html @@ -0,0 +1,99 @@ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
diff --git a/docs/toolsapi/application/admin/view/badge/index.html b/docs/toolsapi/application/admin/view/badge/index.html new file mode 100644 index 00000000..a6b67014 --- /dev/null +++ b/docs/toolsapi/application/admin/view/badge/index.html @@ -0,0 +1,23 @@ +
+ {:build_heading()} + +
+ +
+
diff --git a/docs/toolsapi/application/admin/view/daily_task/add.html b/docs/toolsapi/application/admin/view/daily_task/add.html new file mode 100644 index 00000000..0595a4d8 --- /dev/null +++ b/docs/toolsapi/application/admin/view/daily_task/add.html @@ -0,0 +1,93 @@ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
diff --git a/docs/toolsapi/application/admin/view/daily_task/edit.html b/docs/toolsapi/application/admin/view/daily_task/edit.html new file mode 100644 index 00000000..14ff8049 --- /dev/null +++ b/docs/toolsapi/application/admin/view/daily_task/edit.html @@ -0,0 +1,93 @@ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
diff --git a/docs/toolsapi/application/admin/view/daily_task/index.html b/docs/toolsapi/application/admin/view/daily_task/index.html new file mode 100644 index 00000000..e69b0a68 --- /dev/null +++ b/docs/toolsapi/application/admin/view/daily_task/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 new file mode 100644 index 00000000..c9e0b2af --- /dev/null +++ b/docs/toolsapi/application/admin/view/feed_weight/edit.html @@ -0,0 +1,38 @@ +
+
+ +
+ + 0-100,数值越大推荐优先级越高 +
+
+
+ +
+ + 0-100,展示权重 +
+
+
+ +
+ + 每日推送上限,0=不限制 +
+
+
+ +
+ +
+
+ +
diff --git a/docs/toolsapi/application/admin/view/feed_weight/index.html b/docs/toolsapi/application/admin/view/feed_weight/index.html new file mode 100644 index 00000000..081143c7 --- /dev/null +++ b/docs/toolsapi/application/admin/view/feed_weight/index.html @@ -0,0 +1,23 @@ +
+ {:build_heading()} + +
+
+ +
+
+
diff --git a/docs/toolsapi/application/admin/view/rank_season/add.html b/docs/toolsapi/application/admin/view/rank_season/add.html new file mode 100644 index 00000000..4ae2a655 --- /dev/null +++ b/docs/toolsapi/application/admin/view/rank_season/add.html @@ -0,0 +1,51 @@ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + JSON格式: rank_start/rank_end/exp/score +
+
+
+ +
+ +
+
+ +
diff --git a/docs/toolsapi/application/admin/view/rank_season/edit.html b/docs/toolsapi/application/admin/view/rank_season/edit.html new file mode 100644 index 00000000..513fbcd0 --- /dev/null +++ b/docs/toolsapi/application/admin/view/rank_season/edit.html @@ -0,0 +1,52 @@ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + JSON格式: rank_start/rank_end/exp/score +
+
+
+ +
+ +
+
+ +
diff --git a/docs/toolsapi/application/admin/view/rank_season/index.html b/docs/toolsapi/application/admin/view/rank_season/index.html new file mode 100644 index 00000000..3e8ee119 --- /dev/null +++ b/docs/toolsapi/application/admin/view/rank_season/index.html @@ -0,0 +1,22 @@ +
+ {:build_heading()} +
+
+
+ +
+
+
+
diff --git a/docs/toolsapi/application/admin/view/user/rank_record/index.html b/docs/toolsapi/application/admin/view/user/rank_record/index.html new file mode 100644 index 00000000..1c27a3a1 --- /dev/null +++ b/docs/toolsapi/application/admin/view/user/rank_record/index.html @@ -0,0 +1,19 @@ +
+ {:build_heading()} +
+
+
+
+
+ +
+ +
+
+
+
+
+
diff --git a/docs/toolsapi/application/admin/view/user/user/edit.html b/docs/toolsapi/application/admin/view/user/user/edit.html index 720f3982..005c3450 100644 --- a/docs/toolsapi/application/admin/view/user/user/edit.html +++ b/docs/toolsapi/application/admin/view/user/user/edit.html @@ -87,6 +87,13 @@ +
+ +
+ + 经验值,修改后等级会自动重算 +
+
设置用户VIP会员开通和过期时间
diff --git a/docs/toolsapi/application/admin/view/user/user_badge/index.html b/docs/toolsapi/application/admin/view/user/user_badge/index.html new file mode 100644 index 00000000..ac31d09f --- /dev/null +++ b/docs/toolsapi/application/admin/view/user/user_badge/index.html @@ -0,0 +1,20 @@ +
+ {:build_heading()} +
+
+
+ +
+
+
+
diff --git a/docs/toolsapi/application/admin/view/user/user_exp_log/index.html b/docs/toolsapi/application/admin/view/user/user_exp_log/index.html new file mode 100644 index 00000000..1c27a3a1 --- /dev/null +++ b/docs/toolsapi/application/admin/view/user/user_exp_log/index.html @@ -0,0 +1,19 @@ +
+ {:build_heading()} +
+
+
+
+
+ +
+ +
+
+
+
+
+
diff --git a/docs/toolsapi/application/admin/view/user/user_task/index.html b/docs/toolsapi/application/admin/view/user/user_task/index.html new file mode 100644 index 00000000..47116eca --- /dev/null +++ b/docs/toolsapi/application/admin/view/user/user_task/index.html @@ -0,0 +1,20 @@ +
+ {:build_heading()} + +
+
+
+
+
+ +
+ +
+
+
+
+
+
diff --git a/docs/toolsapi/application/admin/view/userdeletion/index.html b/docs/toolsapi/application/admin/view/userdeletion/index.html index 1fbe420f..866094f5 100644 --- a/docs/toolsapi/application/admin/view/userdeletion/index.html +++ b/docs/toolsapi/application/admin/view/userdeletion/index.html @@ -24,15 +24,6 @@ .btn-reject:hover{background:#d63028} .btn-auto{background:#007aff;color:#fff} .btn-auto:hover{background:#0056b3} -.modal-overlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.4);z-index:1000;justify-content:center;align-items:center} -.modal-overlay.show{display:flex} -.modal{background:#fff;border-radius:16px;padding:24px;width:90%;max-width:480px;box-shadow:0 8px 32px rgba(0,0,0,.15)} -.modal h3{font-size:18px;font-weight:700;margin-bottom:16px} -.modal textarea{width:100%;padding:12px;border:1px solid #d2d2d7;border-radius:10px;font-size:14px;min-height:80px;resize:vertical;box-sizing:border-box} -.modal-btns{display:flex;gap:12px;margin-top:16px;justify-content:flex-end} -.modal-btns .btn{padding:10px 24px;font-size:14px} -.toast{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,.78);color:#fff;padding:20px 32px;border-radius:14px;font-size:16px;z-index:9999;opacity:0;transition:opacity .3s;pointer-events:none} -.toast.show{opacity:1} .empty{text-align:center;padding:60px 20px;color:#86868b;font-size:15px}
@@ -41,9 +32,9 @@
- - - @@ -71,147 +62,3 @@
- - - -
- - diff --git a/docs/toolsapi/application/api/controller/Achievement.php b/docs/toolsapi/application/api/controller/Achievement.php index ba439b28..6284787e 100644 --- a/docs/toolsapi/application/api/controller/Achievement.php +++ b/docs/toolsapi/application/api/controller/Achievement.php @@ -9,6 +9,7 @@ * 达成判定: 实时COUNT各业务表,无需achievement/user_achievement表 * 领取记录: tool_coin_log (action=achievement_claim) * @update v8.0.0 重构为纯已有表实现,移除achievement/user_achievement依赖 + * v8.1.0 新增勋章系统: badges/badgeDisplay/_checkBadges,成就领取后自动检测勋章 */ namespace app\api\controller; @@ -18,7 +19,7 @@ use think\Db; class Achievement extends Api { - protected $noNeedLogin = ['list']; + protected $noNeedLogin = ['list', 'badges']; protected $noNeedRight = ['*']; private static $achievementDefs = [ @@ -221,6 +222,7 @@ class Achievement extends Api } $this->_checkTitleUpgrade($userId); + self::checkBadges($userId); $this->success('领取成功', [ 'achievement' => $def['name'], @@ -274,6 +276,72 @@ class Achievement extends Api ]); } + /** + * @name 勋章列表 + * @desc 获取所有勋章及当前用户解锁状态,无需登录可查看 + */ + public function badges() + { + $userId = $this->auth->id ?: 0; + $allBadges = Db::name('badge')->where('status', 'normal')->order('weigh desc, id asc')->select(); + $userBadges = []; + if ($userId) { + $userBadges = Db::name('user_badge')->where('user_id', $userId)->column('badge_id,is_displayed,unlocked_at', 'badge_id'); + } + $result = []; + foreach ($allBadges as $badge) { + $ub = isset($userBadges[$badge['id']]) ? $userBadges[$badge['id']] : null; + $result[] = [ + 'id' => $badge['id'], + 'name' => $badge['name'], + 'icon' => $badge['icon'], + 'description' => $badge['description'], + 'rarity' => $badge['rarity'], + 'type' => $badge['type'], + 'condition_type' => $badge['condition_type'], + 'condition_value' => $badge['condition_value'], + 'exp_reward' => $badge['exp_reward'], + 'score_reward' => $badge['score_reward'], + 'is_unlocked' => $ub ? true : false, + 'is_displayed' => $ub ? intval($ub['is_displayed']) : 0, + 'unlocked_at' => $ub ? intval($ub['unlocked_at']) : 0, + ]; + } + $displayedCount = $userId ? Db::name('user_badge')->where('user_id', $userId)->where('is_displayed', 1)->count() : 0; + $this->success('', [ + 'badges' => $result, + 'total' => count($result), + 'unlocked' => count($userBadges), + 'displayed_count' => $displayedCount, + 'max_display' => 3, + ]); + } + + /** + * @name 设置展示勋章 + * @desc 用户选择展示的勋章,最多3个 + * @param string badge_ids 勋章ID列表,逗号分隔 + */ + public function badgeDisplay() + { + $userId = $this->auth->id; + $badgeIds = $this->request->post('badge_ids', ''); + $ids = array_filter(array_map('intval', explode(',', $badgeIds))); + if (count($ids) > 3) { + $this->error('最多展示3个勋章'); + } + Db::name('user_badge')->where('user_id', $userId)->update(['is_displayed' => 0]); + if (!empty($ids)) { + foreach ($ids as $bid) { + $exists = Db::name('user_badge')->where('user_id', $userId)->where('badge_id', $bid)->find(); + if ($exists) { + Db::name('user_badge')->where('id', $exists['id'])->update(['is_displayed' => 1]); + } + } + } + $this->success('展示设置成功'); + } + private function _getOptionalUserId() { $token = $this->request->header('token', ''); @@ -409,4 +477,76 @@ class Achievement extends Api } } catch (\Exception $e) {} } + + /** + * @name 勋章检测 + * @desc 签到/打卡/成就领取后触发,检测并解锁新勋章 + * @param int $userId 用户ID + * @return array 新解锁的勋章列表 + */ + public static function checkBadges($userId) + { + $allBadges = Db::name('badge')->where('status', 'normal')->select(); + $existingBadges = Db::name('user_badge')->where('user_id', $userId)->column('badge_id'); + $newBadges = []; + foreach ($allBadges as $badge) { + if (in_array($badge['id'], $existingBadges)) continue; + $unlocked = false; + $currentValue = 0; + switch ($badge['type']) { + case 'signin': + $currentValue = Db::name('user_signin')->where('user_id', $userId)->count(); + break; + case 'article': + $currentValue = Db::name('article')->where('user_id', $userId)->where('status', 'normal')->count(); + break; + case 'favorite': + $currentValue = Db::name('user_favorite')->where('user_id', $userId)->count(); + break; + case 'note': + $currentValue = Db::name('user_note')->where('user_id', $userId)->where('status', 'normal')->count(); + break; + case 'interact': + $currentValue = Db::name('feed_interaction')->where('user_id', $userId)->where('action', 'in', 'like,favorite,comment,share,rating,readlater,bookmark,collect')->count(); + break; + case 'game': + $currentValue = Db::name('feed_interaction')->where('user_id', $userId)->where('action', 'in', 'game_poetry_fill,game_idiom_chain,checkin_game')->count(); + break; + case 'search': + $currentValue = Db::name('feed_interaction')->where('user_id', $userId)->where('action', 'search')->count(); + break; + case 'checkin': + $currentValue = Db::name('feed_interaction')->where('user_id', $userId)->where('action', 'like', 'checkin_%')->count(); + break; + case 'level': + $currentValue = intval(Db::name('user')->where('id', $userId)->value('level')); + break; + case 'rank': + case 'task': + continue 2; + } + if ($badge['condition_type'] === 'count' && $currentValue >= $badge['condition_value']) { + $unlocked = true; + } elseif ($badge['condition_type'] === 'reach' && $currentValue >= $badge['condition_value']) { + $unlocked = true; + } + if ($unlocked) { + Db::name('user_badge')->insert([ + 'user_id' => $userId, + 'badge_id' => $badge['id'], + 'is_displayed' => 0, + 'unlocked_at' => time(), + 'createtime' => time(), + ]); + if ($badge['exp_reward'] > 0) { + \app\common\model\User::exp($badge['exp_reward'], $userId, 'badge', '解锁勋章: ' . $badge['name']); + } + if ($badge['score_reward'] > 0) { + \app\common\model\User::score($badge['score_reward'], $userId, '解锁勋章: ' . $badge['name']); + } + $newBadges[] = $badge; + } + } + return $newBadges; + } } diff --git a/docs/toolsapi/application/api/controller/Rank.php b/docs/toolsapi/application/api/controller/Rank.php new file mode 100644 index 00000000..a3e83d10 --- /dev/null +++ b/docs/toolsapi/application/api/controller/Rank.php @@ -0,0 +1,348 @@ + ['limit' => 10, 'window' => 60], + ]; + + public function _initialize() + { + if (isset($_SERVER['HTTP_ORIGIN'])) { + header('Access-Control-Expose-Headers: __token__'); + } + check_cors_request(); + parent::_initialize(); + } + + /** + * @name 赛季列表 + * @desc 返回当前和历史的赛季列表 + * @param string type weekly|monthly (可选,默认全部) + */ + public function seasons() + { + $type = input('get.type', ''); + $query = Db::name('rank_season') + ->order('start_time desc'); + + if ($type && in_array($type, ['weekly', 'monthly'])) { + $query->where('type', $type); + } + + $seasons = $query->limit(20)->select(); + + $result = []; + foreach ($seasons as $s) { + $result[] = [ + 'id' => $s['id'], + 'name' => $s['name'], + 'type' => $s['type'], + 'start_time' => intval($s['start_time']), + 'end_time' => intval($s['end_time']), + 'status' => $s['status'], + 'rewards' => $s['rewards'] ? json_decode($s['rewards'], true) : [], + ]; + } + + $this->success('', ['seasons' => $result]); + } + + /** + * @name 排行榜 + * @desc 实时计算排行榜(前50名) + * @param int season_id 赛季ID(可选,默认当前赛季) + * @param string type exp|signin|badge|score (默认exp) + */ + public function leaderboard() + { + $seasonId = intval(input('get.season_id', 0)); + $rankType = input('get.type', 'exp'); + if (!in_array($rankType, ['exp', 'signin', 'badge', 'score'])) { + $rankType = 'exp'; + } + + if ($seasonId) { + $season = Db::name('rank_season')->where('id', $seasonId)->find(); + if (!$season) $this->error('赛季不存在'); + if ($season['status'] === 'settled') { + $records = Db::name('rank_record') + ->alias('r') + ->join('user u', 'r.user_id = u.id', 'LEFT') + ->where('r.season_id', $seasonId) + ->where('r.rank_type', $rankType) + ->order('r.rank asc') + ->limit(50) + ->field('r.rank, r.value, r.reward_claimed, r.user_id, u.username, u.avatar, u.level') + ->select(); + $this->success('', ['type' => $rankType, 'season' => $season, 'list' => $records, 'is_realtime' => false]); + } + $startTime = intval($season['start_time']); + $endTime = intval($season['end_time']); + } else { + $currentSeason = Db::name('rank_season') + ->where('status', 'active') + ->where('start_time', '<=', time()) + ->where('end_time', '>', time()) + ->order('start_time desc') + ->find(); + if (!$currentSeason) { + $this->success('', ['type' => $rankType, 'season' => null, 'list' => [], 'is_realtime' => true]); + } + $startTime = intval($currentSeason['start_time']); + $endTime = intval($currentSeason['end_time']); + $season = $currentSeason; + } + + $list = $this->_calcRealtimeRank($rankType, $startTime, $endTime, 50); + $this->success('', ['type' => $rankType, 'season' => $season, 'list' => $list, 'is_realtime' => true]); + } + + /** + * @name 我的排名 + * @desc 获取当前用户在指定赛季和类型中的排名 + * @param int season_id (可选) + * @param string type (默认exp) + */ + public function myRank() + { + $userId = $this->auth->id; + if (!$userId) $this->error('请先登录'); + + $seasonId = intval(input('get.season_id', 0)); + $rankType = input('get.type', 'exp'); + if (!in_array($rankType, ['exp', 'signin', 'badge', 'score'])) { + $rankType = 'exp'; + } + + if ($seasonId) { + $season = Db::name('rank_season')->where('id', $seasonId)->find(); + if (!$season) $this->error('赛季不存在'); + if ($season['status'] === 'settled') { + $record = Db::name('rank_record') + ->where('season_id', $seasonId) + ->where('user_id', $userId) + ->where('rank_type', $rankType) + ->find(); + $this->success('', [ + 'rank' => $record ? intval($record['rank']) : 0, + 'value' => $record ? intval($record['value']) : 0, + 'claimed' => $record ? intval($record['reward_claimed']) : 0, + ]); + } + $startTime = intval($season['start_time']); + $endTime = intval($season['end_time']); + } else { + $currentSeason = Db::name('rank_season') + ->where('status', 'active') + ->where('start_time', '<=', time()) + ->where('end_time', '>', time()) + ->order('start_time desc') + ->find(); + if (!$currentSeason) { + $this->success('', ['rank' => 0, 'value' => 0, 'claimed' => 0]); + } + $startTime = intval($currentSeason['start_time']); + $endTime = intval($currentSeason['end_time']); + } + + $rankData = $this->_calcUserRank($userId, $rankType, $startTime, $endTime); + $this->success('', $rankData); + } + + /** + * @name 领取赛季奖励 + * @desc 领取已结算赛季的排名奖励 + * @param int season_id 赛季ID + * @param string type 排行类型 + */ + public function claimReward() + { + $userId = $this->auth->id; + if (!$userId) $this->error('请先登录'); + + $seasonId = intval(input('post.season_id', 0)); + $rankType = input('post.type', 'exp'); + if (!$seasonId) $this->error('参数错误'); + + $season = Db::name('rank_season')->where('id', $seasonId)->find(); + if (!$season) $this->error('赛季不存在'); + if ($season['status'] !== 'settled') $this->error('赛季尚未结算'); + + $record = Db::name('rank_record') + ->where('season_id', $seasonId) + ->where('user_id', $userId) + ->where('rank_type', $rankType) + ->find(); + + if (!$record) $this->error('无排名记录'); + if ($record['reward_claimed']) $this->error('奖励已领取'); + + $rewards = $season['rewards'] ? json_decode($season['rewards'], true) : []; + $myRank = intval($record['rank']); + $myReward = null; + foreach ($rewards as $r) { + if (isset($r['rank_start']) && isset($r['rank_end'])) { + if ($myRank >= intval($r['rank_start']) && $myRank <= intval($r['rank_end'])) { + $myReward = $r; + break; + } + } elseif (isset($r['rank']) && intval($r['rank']) === $myRank) { + $myReward = $r; + break; + } + } + + if (!$myReward) $this->error('不在奖励范围内'); + + Db::name('rank_record')->where('id', $record['id'])->update(['reward_claimed' => 1]); + + $expReward = intval($myReward['exp'] ?? 0); + $scoreReward = intval($myReward['score'] ?? 0); + + if ($expReward > 0) { + \app\common\model\User::exp($expReward, $userId, 'rank', '赛季排行奖励 #' . $seasonId); + } + if ($scoreReward > 0) { + \app\common\model\User::score($scoreReward, $userId, '赛季排行奖励 #' . $seasonId); + } + + $this->success('领取成功', [ + 'exp_reward' => $expReward, + 'score_reward' => $scoreReward, + 'rank' => $myRank, + ]); + } + + /** + * @name 实时计算排行(前N名) + */ + private function _calcRealtimeRank($type, $startTime, $endTime, $limit = 50) + { + switch ($type) { + case 'exp': + $rows = Db::name('user_exp_log') + ->alias('e') + ->join('user u', 'e.user_id = u.id', 'LEFT') + ->where('e.createtime', 'between', [$startTime, $endTime]) + ->group('e.user_id') + ->order('total_exp desc') + ->limit($limit) + ->field('e.user_id, SUM(e.amount) as total_exp as value, u.username, u.avatar, u.level') + ->select(); + break; + case 'signin': + $rows = Db::name('user_signin') + ->alias('s') + ->join('user u', 's.user_id = u.id', 'LEFT') + ->where('s.createtime', 'between', [$startTime, $endTime]) + ->group('s.user_id') + ->order('signin_count desc') + ->limit($limit) + ->field('s.user_id, COUNT(*) as signin_count as value, u.username, u.avatar, u.level') + ->select(); + break; + case 'badge': + $rows = Db::name('user_badge') + ->alias('b') + ->join('user u', 'b.user_id = u.id', 'LEFT') + ->where('b.createtime', 'between', [$startTime, $endTime]) + ->group('b.user_id') + ->order('badge_count desc') + ->limit($limit) + ->field('b.user_id, COUNT(*) as badge_count as value, u.username, u.avatar, u.level') + ->select(); + break; + case 'score': + $rows = Db::name('user_score_log') + ->alias('s') + ->join('user u', 's.user_id = u.id', 'LEFT') + ->where('s.createtime', 'between', [$startTime, $endTime]) + ->where('s.score', '>', 0) + ->group('s.user_id') + ->order('total_score desc') + ->limit($limit) + ->field('s.user_id, SUM(s.score) as total_score as value, u.username, u.avatar, u.level') + ->select(); + break; + default: + $rows = []; + } + + $result = []; + $rank = 1; + foreach ($rows as $row) { + $result[] = [ + 'rank' => $rank++, + 'user_id' => intval($row['user_id']), + 'username' => $row['username'] ?? '', + 'avatar' => $row['avatar'] ?? '', + 'level' => intval($row['level'] ?? 1), + 'value' => intval($row['value'] ?? 0), + ]; + } + return $result; + } + + /** + * @name 计算单个用户排名 + */ + private function _calcUserRank($userId, $type, $startTime, $endTime) + { + switch ($type) { + case 'exp': + $myValue = Db::name('user_exp_log') + ->where('user_id', $userId) + ->where('createtime', 'between', [$startTime, $endTime]) + ->sum('amount'); + $higherCount = Db::query( + "SELECT COUNT(DISTINCT user_id) as cnt FROM tool_user_exp_log WHERE createtime BETWEEN ? AND ? GROUP BY user_id HAVING SUM(amount) > ?", + [$startTime, $endTime, $myValue] + ); + break; + case 'signin': + $myValue = Db::name('user_signin') + ->where('user_id', $userId) + ->where('createtime', 'between', [$startTime, $endTime]) + ->count(); + $higherCount = 0; + break; + case 'badge': + $myValue = Db::name('user_badge') + ->where('user_id', $userId) + ->where('createtime', 'between', [$startTime, $endTime]) + ->count(); + $higherCount = 0; + break; + case 'score': + $myValue = Db::name('user_score_log') + ->where('user_id', $userId) + ->where('createtime', 'between', [$startTime, $endTime]) + ->where('score', '>', 0) + ->sum('score'); + $higherCount = 0; + break; + default: + $myValue = 0; + $higherCount = 0; + } + + $rank = is_array($higherCount) ? count($higherCount) + 1 : 1; + return ['rank' => $rank, 'value' => intval($myValue), 'claimed' => 0]; + } +} diff --git a/docs/toolsapi/application/api/controller/Task.php b/docs/toolsapi/application/api/controller/Task.php new file mode 100644 index 00000000..977647d1 --- /dev/null +++ b/docs/toolsapi/application/api/controller/Task.php @@ -0,0 +1,387 @@ + ['limit' => 30, 'window' => 60], + 'claim' => ['limit' => 20, 'window' => 60], + 'claimPerfect' => ['limit' => 10, 'window' => 60], + 'registerCustom' => ['limit' => 5, 'window' => 3600], + ]; + + public function _initialize() + { + if (isset($_SERVER['HTTP_ORIGIN'])) { + header('Access-Control-Expose-Headers: __token__'); + } + check_cors_request(); + parent::_initialize(); + } + + /** + * @name 今日任务列表 + * @desc 获取今日任务列表(含进度),固定任务+随机抽取3-4个随机任务 + */ + public function today() + { + $userId = $this->auth->id; + if (!$userId) $this->error('请先登录'); + + $today = date('Y-m-d'); + $fixedTasks = Db::name('daily_task') + ->where('status', 'normal') + ->where('is_random', 0) + ->order('weigh desc, id asc') + ->select(); + + $randomPool = Db::name('daily_task') + ->where('status', 'normal') + ->where('is_random', 1) + ->order('weigh desc, id asc') + ->select(); + + $randomCount = min(4, count($randomPool)); + $seed = crc32($userId . '_' . $today); + $selectedRandom = $this->_selectRandom($randomPool, $randomCount, $seed); + + $allTasks = array_merge($fixedTasks, $selectedRandom); + usort($allTasks, function($a, $b) { + return $b['weigh'] - $a['weigh']; + }); + + $userTasks = Db::name('user_task') + ->where('user_id', $userId) + ->where('date', $today) + ->column('task_id,progress,completed,claimed', 'task_id'); + + $result = []; + $completedCount = 0; + $claimedCount = 0; + $totalTasks = count($allTasks); + + foreach ($allTasks as $task) { + $ut = isset($userTasks[$task['id']]) ? $userTasks[$task['id']] : null; + $progress = $ut ? intval($ut['progress']) : 0; + $completed = $ut ? intval($ut['completed']) : 0; + $claimed = $ut ? intval($ut['claimed']) : 0; + + if ($completed) $completedCount++; + if ($claimed) $claimedCount++; + + $result[] = [ + 'id' => $task['id'], + 'name' => $task['name'], + 'icon' => $task['icon'], + 'type' => $task['type'], + 'target' => intval($task['target']), + 'action' => $task['action'], + 'custom_url' => $task['custom_url'], + 'custom_page' => $task['custom_page'], + 'exp_reward' => intval($task['exp_reward']), + 'score_reward' => intval($task['score_reward']), + 'is_random' => intval($task['is_random']), + 'progress' => $progress, + 'completed' => $completed, + 'claimed' => $claimed, + 'percent' => $task['target'] > 0 ? min(100, intval($progress / $task['target'] * 100)) : 0, + ]; + } + + $allCompleted = ($totalTasks > 0 && $completedCount === $totalTasks); + $perfectClaimed = false; + if ($allCompleted) { + $perfectLog = Db::name('user_exp_log') + ->where('user_id', $userId) + ->where('action', 'perfect_day') + ->where('remark', 'like', '%' . $today . '%') + ->find(); + $perfectClaimed = $perfectLog ? true : false; + } + + $this->success('', [ + 'tasks' => $result, + 'total' => $totalTasks, + 'completed' => $completedCount, + 'claimed' => $claimedCount, + 'is_perfect_day' => $allCompleted, + 'perfect_claimed' => $perfectClaimed, + 'date' => $today, + ]); + } + + /** + * @name 上报任务进度 + * @desc 上报某个任务的进度增量 + * @param int task_id 任务ID + * @param int increment 进度增量(默认1) + */ + public function reportProgress() + { + $userId = $this->auth->id; + if (!$userId) $this->error('请先登录'); + + $taskId = intval(input('post.task_id', 0)); + $increment = intval(input('post.increment', 1)); + if (!$taskId) $this->error('参数错误'); + if ($increment <= 0) $increment = 1; + + $task = Db::name('daily_task')->where('id', $taskId)->where('status', 'normal')->find(); + if (!$task) $this->error('任务不存在'); + + $today = date('Y-m-d'); + $todayTasks = $this->_getTodayTaskIds($userId, $today); + if (!in_array($taskId, $todayTasks)) { + $this->error('今日无此任务'); + } + + $userTask = Db::name('user_task') + ->where('user_id', $userId) + ->where('task_id', $taskId) + ->where('date', $today) + ->find(); + + if ($userTask && $userTask['completed']) { + $this->success('任务已完成', [ + 'progress' => intval($userTask['progress']), + 'completed' => 1, + 'percent' => 100, + ]); + } + + if ($userTask) { + $newProgress = min(intval($userTask['progress']) + $increment, intval($task['target'])); + $isCompleted = ($newProgress >= intval($task['target'])) ? 1 : 0; + Db::name('user_task')->where('id', $userTask['id'])->update([ + 'progress' => $newProgress, + 'completed' => $isCompleted, + 'updatetime' => time(), + ]); + } else { + $newProgress = min($increment, intval($task['target'])); + $isCompleted = ($newProgress >= intval($task['target'])) ? 1 : 0; + Db::name('user_task')->insert([ + 'user_id' => $userId, + 'task_id' => $taskId, + 'date' => $today, + 'progress' => $newProgress, + 'completed' => $isCompleted, + 'claimed' => 0, + 'createtime' => time(), + 'updatetime' => time(), + ]); + } + + $this->success('进度已更新', [ + 'progress' => $newProgress, + 'completed' => $isCompleted, + 'percent' => intval($task['target']) > 0 ? min(100, intval($newProgress / intval($task['target']) * 100)) : 0, + ]); + } + + /** + * @name 领取任务奖励 + * @desc 领取已完成任务的EXP和积分奖励 + * @param int task_id 任务ID + */ + public function claim() + { + $userId = $this->auth->id; + if (!$userId) $this->error('请先登录'); + + $taskId = intval(input('post.task_id', 0)); + if (!$taskId) $this->error('参数错误'); + + $task = Db::name('daily_task')->where('id', $taskId)->find(); + if (!$task) $this->error('任务不存在'); + + $today = date('Y-m-d'); + $userTask = Db::name('user_task') + ->where('user_id', $userId) + ->where('task_id', $taskId) + ->where('date', $today) + ->find(); + + if (!$userTask) $this->error('今日无此任务进度'); + if (!$userTask['completed']) $this->error('任务尚未完成'); + if ($userTask['claimed']) $this->error('奖励已领取'); + + Db::name('user_task')->where('id', $userTask['id'])->update([ + 'claimed' => 1, + 'updatetime' => time(), + ]); + + $expReward = intval($task['exp_reward']); + $scoreReward = intval($task['score_reward']); + + if ($expReward > 0) { + \app\common\model\User::exp($expReward, $userId, 'task', '完成每日任务: ' . $task['name']); + } + if ($scoreReward > 0) { + \app\common\model\User::score($scoreReward, $userId, '完成每日任务: ' . $task['name']); + } + + \app\api\controller\Achievement::checkBadges($userId); + + $this->success('领取成功', [ + 'exp_reward' => $expReward, + 'score_reward' => $scoreReward, + 'task_name' => $task['name'], + ]); + } + + /** + * @name 领取完美日奖励 + * @desc 当天所有任务全部完成且领取后,可领取完美日额外奖励(20EXP+10积分) + */ + public function claimPerfect() + { + $userId = $this->auth->id; + if (!$userId) $this->error('请先登录'); + + $today = date('Y-m-d'); + + $perfectLog = Db::name('user_exp_log') + ->where('user_id', $userId) + ->where('action', 'perfect_day') + ->where('remark', 'like', '%' . $today . '%') + ->find(); + if ($perfectLog) $this->error('今日完美日奖励已领取'); + + $todayTaskIds = $this->_getTodayTaskIds($userId, $today); + if (empty($todayTaskIds)) $this->error('今日无任务'); + + $claimedCount = Db::name('user_task') + ->where('user_id', $userId) + ->where('date', $today) + ->where('task_id', 'in', $todayTaskIds) + ->where('claimed', 1) + ->count(); + + if ($claimedCount < count($todayTaskIds)) { + $this->error('还有任务未完成或未领取奖励'); + } + + \app\common\model\User::exp(20, $userId, 'perfect_day', '完美日奖励 ' . $today); + \app\common\model\User::score(10, $userId, '完美日奖励 ' . $today); + + \app\api\controller\Achievement::checkBadges($userId); + + $this->success('完美日奖励领取成功!', [ + 'exp_reward' => 20, + 'score_reward' => 10, + ]); + } + + /** + * @name 注册自定义任务 + * @desc 客户端注册自定义任务(浏览URL/使用功能) + * @param string name 任务名称 + * @param string icon 图标(emoji) + * @param string custom_url 自定义URL + * @param string custom_page 自定义页面标识 + * @param int target 目标次数(默认1) + * @param int exp_reward EXP奖励(默认5) + * @param int score_reward 积分奖励(默认2) + */ + public function registerCustom() + { + $userId = $this->auth->id; + if (!$userId) $this->error('请先登录'); + + $name = input('post.name', '', 'trim'); + $icon = input('post.icon', '🎯', 'trim'); + $customUrl = input('post.custom_url', '', 'trim'); + $customPage = input('post.custom_page', '', 'trim'); + $target = intval(input('post.target', 1)); + $expReward = intval(input('post.exp_reward', 5)); + $scoreReward = intval(input('post.score_reward', 2)); + + if (!$name) $this->error('任务名称不能为空'); + if (!$customUrl && !$customPage) $this->error('custom_url和custom_page至少填一个'); + + $existing = Db::name('daily_task') + ->where('type', 'custom') + ->where(function($query) use ($customUrl, $customPage) { + if ($customUrl) $query->whereOr('custom_url', $customUrl); + if ($customPage) $query->whereOr('custom_page', $customPage); + }) + ->find(); + + if ($existing) { + $this->success('任务已存在', [ + 'task_id' => $existing['id'], + 'name' => $existing['name'], + ]); + } + + $id = Db::name('daily_task')->insertGetId([ + 'name' => $name, + 'icon' => $icon, + 'type' => 'custom', + 'target' => max(1, $target), + 'action' => 'custom', + 'custom_url' => $customUrl, + 'custom_page' => $customPage, + 'exp_reward' => min(20, max(1, $expReward)), + 'score_reward' => min(10, max(1, $scoreReward)), + 'is_random' => 1, + 'status' => 'normal', + 'weigh' => 0, + 'createtime' => time(), + 'updatetime' => time(), + ]); + + $this->success('注册成功', ['task_id' => $id]); + } + + /** + * @name 从随机池中选取N个任务(确定性种子) + */ + private function _selectRandom($pool, $count, $seed) + { + if (empty($pool) || $count <= 0) return []; + mt_srand($seed); + $shuffled = $pool; + shuffle($shuffled); + return array_slice($shuffled, 0, $count); + } + + /** + * @name 获取今日分配给用户的任务ID列表 + */ + private function _getTodayTaskIds($userId, $today) + { + $fixedTasks = Db::name('daily_task') + ->where('status', 'normal') + ->where('is_random', 0) + ->column('id'); + + $randomPool = Db::name('daily_task') + ->where('status', 'normal') + ->where('is_random', 1) + ->column('id'); + + $randomCount = min(4, count($randomPool)); + $seed = crc32($userId . '_' . $today); + mt_srand($seed); + shuffle($randomPool); + $selectedRandom = array_slice($randomPool, 0, $randomCount); + + return array_merge($fixedTasks, $selectedRandom); + } +} diff --git a/docs/toolsapi/application/api/controller/UserCenter.php b/docs/toolsapi/application/api/controller/UserCenter.php index 1dafec1a..3f996286 100644 --- a/docs/toolsapi/application/api/controller/UserCenter.php +++ b/docs/toolsapi/application/api/controller/UserCenter.php @@ -17,6 +17,17 @@ class UserCenter extends Api protected $noNeedLogin = ['public_profile']; protected $noNeedRight = '*'; + private static $secQuestions = [ + 1 => '您母亲的姓名是?', + 2 => '您的第一只宠物叫什么?', + 3 => '您就读的小学名称是?', + 4 => '您的出生地是?', + 5 => '您最喜欢的电影是?', + 6 => '您最好朋友的名字是?', + 7 => '您父亲的姓名是?', + 8 => '您的童年昵称是?', + ]; + private static $rateLimitKey = 'api_rate_limit:'; private static $rateLimits = [ 'signin' => ['max' => 10, 'window' => 60], @@ -138,6 +149,9 @@ class UserCenter extends Api ->order('last_active_time', 'desc') ->select(); + $secQuestionId = isset($user->sec_question) ? intval($user->sec_question) : 0; + $secQuestionText = ($secQuestionId > 0 && isset(self::$secQuestions[$secQuestionId])) ? self::$secQuestions[$secQuestionId] : ''; + $data = [ 'id' => $user->id, 'username' => $user->username, @@ -146,6 +160,10 @@ class UserCenter extends Api 'email' => $user->email, 'mobile' => $user->mobile, 'score' => $user->score, + 'level' => $user->level ?: 1, + 'exp' => $user->exp ?: 0, + 'exp_to_next' => $this->_calcExpToNext(intval($user->exp ?: 0)), + 'exp_progress' => $this->_calcExpProgress(intval($user->exp ?: 0)), 'title' => $titleInfo, 'signin_days' => $user->signin_days ?: 0, 'article_count' => $user->article_count ?: 0, @@ -169,14 +187,67 @@ class UserCenter extends Api 'devices' => $onlineDevices, 'extra' => [ 'money' => $user->money, + 'level' => $user->level ?: 1, + 'exp' => $user->exp ?: 0, + 'exp_to_next' => $this->_calcExpToNext(intval($user->exp ?: 0)), + 'exp_progress' => $this->_calcExpProgress(intval($user->exp ?: 0)), + 'level_title' => $this->_getLevelTitle(intval($user->level ?: 1)), 'note_limit' => $user->note_limit ?: 50, 'verification' => $user->verification, 'last_signin_date'=> $user->last_signin_date, + 'sec_question' => [ + 'question_id' => $secQuestionId, + 'question_text' => $secQuestionText, + ], ], ]; $this->success('', $data); } + /** + * @name 计算升级所需剩余EXP + * @desc 基于阶梯表直接计算距离下一级还需多少EXP + * @lastUpdate v10.2.0 从二分查找+pow公式改为阶梯表 + */ + private function _calcExpToNext($exp) + { + $lv = array(1 => 0, 2 => 30, 3 => 100, 4 => 300, 5 => 800, 6 => 2000, 7 => 5000, 8 => 12000, 9 => 30000, 10 => 80000); + $currentLevel = \app\common\model\User::nextlevelByExp($exp); + if ($currentLevel >= 10) return 0; + $nextLevel = $currentLevel + 1; + return $lv[$nextLevel] - $exp; + } + + /** + * @name 计算EXP进度 + * @desc 基于阶梯表直接计算当前等级的EXP进度(0~1) + * @lastUpdate v10.2.0 从二分查找+pow公式改为阶梯表 + */ + private function _calcExpProgress($exp) + { + $lv = array(1 => 0, 2 => 30, 3 => 100, 4 => 300, 5 => 800, 6 => 2000, 7 => 5000, 8 => 12000, 9 => 30000, 10 => 80000); + $currentLevel = \app\common\model\User::nextlevelByExp($exp); + if ($currentLevel >= 10) return 1.0; + $currentLevelExp = $lv[$currentLevel]; + $nextLevelExp = $lv[$currentLevel + 1]; + if ($nextLevelExp <= $currentLevelExp) return 1.0; + return round(($exp - $currentLevelExp) / ($nextLevelExp - $currentLevelExp), 4); + } + + + /** + * @name 获取等级称号 + * @desc 根据等级返回对应称号文本 + */ + private function _getLevelTitle($level) + { + $titles = [ + 1 => '新手', 2 => '学徒', 3 => '初学者', 4 => '进阶者', 5 => '学者', + 6 => '达人', 7 => '专家', 8 => '大师', 9 => '宗师', 10 => '传说', + ]; + return isset($titles[$level]) ? $titles[$level] : '新手'; + } + /** * @name 格式化字节 * @desc 将字节数转为人类可读格式 @@ -289,7 +360,10 @@ class UserCenter extends Api 'createtime'=> time(), ]; db('coin_log')->insert($logData); - $this->success('签到成功', ['continuous' => $continuous, 'coin_reward' => $coinReward, 'today_signed' => true]); + $signinExp = 5 + $continuous * 2; + \app\common\model\User::exp($signinExp, $userId, 'signin', '每日签到EXP'); + \app\api\controller\Achievement::checkBadges($userId); + $this->success('签到成功', ['continuous' => $continuous, 'coin_reward' => $coinReward, 'exp_reward' => $signinExp, 'today_signed' => true]); } /** @@ -1393,4 +1467,34 @@ class UserCenter extends Api 'devices' => $devices, ]); } + + /** + * @name EXP变动记录 + * @desc 获取当前用户EXP变动日志,分页返回 + * @lastUpdate v10.1.0 新增 + */ + public function expLog() + { + $page = $this->request->param('page', 1, 'intval'); + $pagesize = $this->request->param('pagesize', 20, 'intval'); + $pagesize = min($pagesize, 50); + $userId = $this->auth->id; + + $list = db('user_exp_log') + ->where('user_id', $userId) + ->order('createtime', 'desc') + ->page($page, $pagesize) + ->select(); + + $total = db('user_exp_log') + ->where('user_id', $userId) + ->count(); + + $this->success('', [ + 'list' => $list, + 'total' => $total, + 'page' => $page, + 'pagesize' => $pagesize, + ]); + } } diff --git a/docs/toolsapi/application/api/controller/UserSecurity.php b/docs/toolsapi/application/api/controller/UserSecurity.php index 610d5115..27d57ed0 100644 --- a/docs/toolsapi/application/api/controller/UserSecurity.php +++ b/docs/toolsapi/application/api/controller/UserSecurity.php @@ -14,32 +14,44 @@ use think\Validate; * 用户安全接口 * @time 2026-04-29 * @name UserSecurity - * @description 用户安全相关API,含注册/登录/改密/改邮箱/重置密码/回执登录/二维码登录等 - * @lastUpdate v9.0.0 新增qrcodeLogin二维码登录; login/receiptLogin记录设备信息和在线状态 + * @description 用户安全相关API,含注册/登录/改密/改邮箱/重置密码/回执登录/二维码登录/密保问题等 + * @lastUpdate v10.1.0 新增密保问题(secQuestion/changeSecQuestion); changepwd/changeemail/changemobile支持多验证方式; register支持可选密保 */ class UserSecurity extends Api { - protected $noNeedLogin = ['login', 'mobilelogin', 'register', 'resetpwd', 'tokenLogin', 'receiptLogin', 'sendEms', 'checkEms', 'qrcodeGenerate', 'qrcodePoll', 'qrcodeCancel']; + protected $noNeedLogin = ['login', 'mobilelogin', 'register', 'resetpwd', 'tokenLogin', 'receiptLogin', 'sendEms', 'checkEms', 'qrcodeGenerate', 'qrcodePoll', 'qrcodeCancel', 'secQuestions']; protected $noNeedRight = '*'; private static $rateLimitKey = 'api_rate_limit:'; private static $rateLimits = [ - 'login' => ['max' => 50, 'window' => 300], - 'register' => ['max' => 30, 'window' => 3600], - 'resetpwd' => ['max' => 20, 'window' => 3600], - 'changepwd' => ['max' => 30, 'window' => 3600], - 'changeemail' => ['max' => 30, 'window' => 3600], - 'changemobile' => ['max' => 30, 'window' => 3600], - 'sendEms' => ['max' => 30, 'window' => 300], - 'tokenLogin' => ['max' => 80, 'window' => 300], - 'receiptLogin' => ['max' => 50, 'window' => 300], - 'requestDeletion' => ['max' => 5, 'window' => 3600], - 'cancelDeletion' => ['max' => 10, 'window' => 3600], - 'deletionStatus' => ['max' => 60, 'window' => 60], + 'login' => ['max' => 50, 'window' => 300], + 'register' => ['max' => 30, 'window' => 3600], + 'resetpwd' => ['max' => 20, 'window' => 3600], + 'changepwd' => ['max' => 30, 'window' => 3600], + 'changeemail' => ['max' => 30, 'window' => 3600], + 'changemobile' => ['max' => 30, 'window' => 3600], + 'sendEms' => ['max' => 30, 'window' => 300], + 'tokenLogin' => ['max' => 80, 'window' => 300], + 'receiptLogin' => ['max' => 50, 'window' => 300], + 'requestDeletion' => ['max' => 5, 'window' => 3600], + 'cancelDeletion' => ['max' => 10, 'window' => 3600], + 'deletionStatus' => ['max' => 60, 'window' => 60], + 'changeSecQuestion'=> ['max' => 20, 'window' => 3600], ]; private static $testMode = false; + private static $secQuestions = [ + 1 => '您母亲的姓名是?', + 2 => '您的第一只宠物叫什么?', + 3 => '您就读的小学名称是?', + 4 => '您的出生地是?', + 5 => '您最喜欢的电影是?', + 6 => '您最好朋友的名字是?', + 7 => '您父亲的姓名是?', + 8 => '您的童年昵称是?', + ]; + public function _initialize() { parent::_initialize(); @@ -132,6 +144,91 @@ class UserSecurity extends Api return true; } + /** + * @name 哈希密保答案 + * @desc 将密保答案转为MD5哈希(小写去空格) + */ + private function hashSecAnswer($answer) + { + return md5(mb_strtolower(trim($answer), 'UTF-8')); + } + + /** + * @name 验证密保答案 + * @desc 验证用户密保答案是否正确 + */ + private function verifySecAnswer($userId, $answer) + { + $user = db('user')->where('id', $userId)->find(); + if (!$user || empty($user['sec_question']) || empty($user['sec_answer'])) { + $this->error('未设置密保问题,无法使用此验证方式'); + } + $inputHash = $this->hashSecAnswer($answer); + if ($inputHash !== $user['sec_answer']) { + $this->error('密保答案不正确'); + } + return true; + } + + /** + * @name 多方式身份验证 + * @desc 根据verify_method验证用户身份(password/sec_question/receipt) + * @param object $user 用户对象 + * @param string $receiptAction 回执action类型 + * @param string $receiptPayload 回执payload + */ + private function verifyIdentity($user, $receiptAction, $receiptPayload = '') + { + $method = $this->request->post('verify_method', 'password', 'trim'); + + switch ($method) { + case 'password': + $oldpassword = $this->request->post('oldpassword', '', 'trim'); + if (!$oldpassword) { + $this->error('旧密码不能为空'); + } + $this->validateLength($oldpassword, '旧密码', 6, 30); + $encryptPassword = $this->auth->getEncryptPassword($oldpassword, $user->salt); + if ($encryptPassword !== $user->password) { + $this->error('旧密码不正确'); + } + break; + + case 'sec_question': + $secAnswer = $this->request->post('sec_answer', '', 'trim'); + if (!$secAnswer) { + $this->error('密保答案不能为空'); + } + $this->verifySecAnswer($user->id, $secAnswer); + break; + + case 'receipt': + if (!$this->isTestUser($user->id)) { + $this->verifyReceipt($receiptAction, $receiptPayload ?: strval($user->id)); + } + break; + + default: + $this->error('不支持的验证方式,可选: password/sec_question/receipt'); + } + + return true; + } + + /** + * @name 获取预置密保问题列表 + * @desc 返回系统预置的密保问题,无需登录 + * @lastUpdate v10.1.0 新增 + */ + public function secQuestions() + { + $questions = []; + foreach (self::$secQuestions as $id => $text) { + $questions[] = ['id' => $id, 'question' => $text]; + } + $this->success('', ['questions' => $questions]); + } + /** * @name 账号密码登录 * @desc 支持用户名/邮箱+密码登录,无需回执,登录后记录设备信息 @@ -284,7 +381,8 @@ class UserSecurity extends Api /** * @name 用户注册 - * @desc 注册新用户,需回执验证(客户端已验证邮箱),不再需要邮箱验证码 + * @desc 注册新用户,需回执验证(客户端已验证邮箱),可选填密保问题 + * @lastUpdate v10.1.0 新增可选参数sec_question/sec_answer */ public function register() { @@ -294,6 +392,8 @@ class UserSecurity extends Api $email = $this->request->post('email', '', 'trim'); $mobile = $this->request->post('mobile', '', 'trim'); $mobileCode = $this->request->post('mobile_code', '', 'trim'); + $secQuestion = $this->request->post('sec_question', 0, 'intval'); + $secAnswer = $this->request->post('sec_answer', '', 'trim'); if (!$username || !$password || !$email) { $this->error('用户名、密码和邮箱为必填项'); @@ -326,7 +426,17 @@ class UserSecurity extends Api } } - $ret = $this->auth->register($username, $password, $email, $mobile, []); + $extend = []; + if ($secQuestion > 0 && $secAnswer) { + if (!isset(self::$secQuestions[$secQuestion])) { + $this->error('密保问题编号无效(1-8)'); + } + $this->validateLength($secAnswer, '密保答案', 1, 50); + $extend['sec_question'] = $secQuestion; + $extend['sec_answer'] = $this->hashSecAnswer($secAnswer); + } + + $ret = $this->auth->register($username, $password, $email, $mobile, $extend); if ($ret) { $userId = $this->auth->id; $verification = db('user')->where('id', $userId)->value('verification'); @@ -363,30 +473,23 @@ class UserSecurity extends Api /** * @name 修改密码 - * @desc 修改密码需验证旧密码+回执验证(客户端已验证邮箱) + * @desc 修改密码,支持多种验证方式(password/sec_question/receipt) + * @lastUpdate v10.1.0 新增verify_method参数,支持密保/回执验证 */ public function changepwd() { $this->checkRateLimit('changepwd'); $user = $this->auth->getUser(); - $oldpassword = $this->request->post('oldpassword', '', 'trim'); $newpassword = $this->request->post('newpassword', '', 'trim'); - if (!$oldpassword || !$newpassword) { + if (!$newpassword) { $this->error(__('Invalid parameters')); } - $this->validateLength($oldpassword, '旧密码', 6, 30); $this->validateLength($newpassword, '新密码', 6, 30); - if (!$this->isTestUser($user->id)) { - $email = $user->email; - if (!$email) { - $this->error('请先绑定邮箱后再修改密码'); - } - $this->verifyReceipt('changepwd', $user->username); - } + $this->verifyIdentity($user, 'changepwd', strval($user->id)); - $ret = $this->auth->changepwd($newpassword, $oldpassword); + $ret = $this->auth->changepwd($newpassword, '', true); if ($ret) { $this->success(__('Change password successful')); } else { @@ -442,7 +545,8 @@ class UserSecurity extends Api /** * @name 修改邮箱 - * @desc 修改邮箱需回执验证(客户端已验证新邮箱) + * @desc 修改邮箱,支持回执验证或密保验证 + * @lastUpdate v10.1.0 新增verify_method=sec_question验证方式 */ public function changeemail() { @@ -462,7 +566,16 @@ class UserSecurity extends Api $this->error(__('Email already exists')); } - $this->verifyReceipt('changeemail', $email); + $method = $this->request->post('verify_method', 'receipt', 'trim'); + if ($method === 'sec_question') { + $secAnswer = $this->request->post('sec_answer', '', 'trim'); + if (!$secAnswer) { + $this->error('密保答案不能为空'); + } + $this->verifySecAnswer($user->id, $secAnswer); + } else { + $this->verifyReceipt('changeemail', $email); + } $verification = $user->verification; $verification->email = 1; @@ -474,7 +587,8 @@ class UserSecurity extends Api /** * @name 修改手机号 - * @desc 修改手机号需回执验证(客户端已验证新手机号) + * @desc 修改手机号,支持回执验证或密保验证 + * @lastUpdate v10.1.0 新增verify_method=sec_question验证方式 */ public function changemobile() { @@ -493,7 +607,16 @@ class UserSecurity extends Api $this->error(__('Mobile already exists')); } - $this->verifyReceipt('changemobile', $mobile); + $method = $this->request->post('verify_method', 'receipt', 'trim'); + if ($method === 'sec_question') { + $secAnswer = $this->request->post('sec_answer', '', 'trim'); + if (!$secAnswer) { + $this->error('密保答案不能为空'); + } + $this->verifySecAnswer($user->id, $secAnswer); + } else { + $this->verifyReceipt('changemobile', $mobile); + } $verification = $user->verification; $verification->mobile = 1; @@ -503,6 +626,40 @@ class UserSecurity extends Api $this->success(); } + /** + * @name 修改密保问题 + * @desc 修改或设置密保问题,需验证身份(password/sec_question/receipt) + * @lastUpdate v10.1.0 新增 + */ + public function changeSecQuestion() + { + $this->checkRateLimit('changeSecQuestion'); + $user = $this->auth->getUser(); + $secQuestion = $this->request->post('sec_question', 0, 'intval'); + $secAnswer = $this->request->post('sec_answer', '', 'trim'); + + if ($secQuestion <= 0 || !$secAnswer) { + $this->error('密保问题和答案为必填项'); + } + if (!isset(self::$secQuestions[$secQuestion])) { + $this->error('密保问题编号无效(1-8)'); + } + $this->validateLength($secAnswer, '密保答案', 1, 50); + + $this->verifyIdentity($user, 'changesecq', strval($user->id)); + + $answerHash = $this->hashSecAnswer($secAnswer); + db('user')->where('id', $user->id)->update([ + 'sec_question' => $secQuestion, + 'sec_answer' => $answerHash, + ]); + + $this->success('密保问题设置成功', [ + 'sec_question' => $secQuestion, + 'sec_question_text' => self::$secQuestions[$secQuestion], + ]); + } + /** * @name 发送邮箱验证码(保留兼容) * @desc 保留接口兼容旧客户端,新客户端建议使用回执验证 diff --git a/docs/toolsapi/application/common/model/User.php b/docs/toolsapi/application/common/model/User.php index 9c95f259..8257ae47 100644 --- a/docs/toolsapi/application/common/model/User.php +++ b/docs/toolsapi/application/common/model/User.php @@ -26,6 +26,7 @@ class User extends Model 'jointime' => 'integer', 'prevtime' => 'integer', 'score' => 'integer', + 'exp' => 'integer', 'money' => 'float', 'successions' => 'integer', 'maxsuccessions' => 'integer', @@ -147,6 +148,23 @@ class User extends Model $user->save(['score' => $after, 'level' => $level]); //写入日志 ScoreLog::create(['user_id' => $user_id, 'score' => $score, 'before' => $before, 'after' => $after, 'memo' => $memo]); + //积分变动同步产出EXP + $expAmount = max(1, intval(abs($score) / 2)); + if ($score > 0) { + $expBefore = intval($user->exp); + $expAfter = $expBefore + $expAmount; + $expLevel = self::nextlevelByExp($expAfter); + $user->save(['exp' => $expAfter, 'level' => $expLevel]); + Db::name('user_exp_log')->insert([ + 'user_id' => $user_id, + 'action' => $memo, + 'amount' => $expAmount, + 'before_val' => $expBefore, + 'after_val' => $expAfter, + 'remark' => '积分变动同步EXP', + 'createtime' => time(), + ]); + } } Db::commit(); } catch (\Exception $e) { @@ -170,4 +188,60 @@ class User extends Model } return $level; } + + /** + * 根据经验值获取等级 + * @param int $exp 经验值 + * @return int + */ + public static function nextlevelByExp($exp = 0) + { + $lv = array( + 1 => 0, 2 => 30, 3 => 100, 4 => 300, 5 => 800, + 6 => 2000, 7 => 5000, 8 => 12000, 9 => 30000, 10 => 80000 + ); + $level = 1; + foreach ($lv as $key => $value) { + if ($exp >= $value) { + $level = $key; + } + } + return $level; + } + + /** + * 变更会员经验值 + * @param int $amount 经验值 + * @param int $user_id 会员ID + * @param string $action 操作类型 + * @param string $remark 备注 + */ + public static function exp($amount, $user_id, $action = '', $remark = '') + { + if ($amount <= 0) return false; + Db::startTrans(); + try { + $user = self::lock(true)->find($user_id); + if ($user) { + $before = intval($user->exp); + $after = $before + $amount; + $level = self::nextlevelByExp($after); + $user->save(['exp' => $after, 'level' => $level]); + Db::name('user_exp_log')->insert([ + 'user_id' => $user_id, + 'action' => $action, + 'amount' => $amount, + 'before_val' => $before, + 'after_val' => $after, + 'remark' => $remark, + 'createtime' => time(), + ]); + } + Db::commit(); + } catch (\Exception $e) { + Db::rollback(); + return false; + } + return true; + } } diff --git a/docs/toolsapi/docs/API_ADMIN_DOC.md b/docs/toolsapi/docs/API_ADMIN_DOC.md index 04b4904f..1c569f7a 100644 --- a/docs/toolsapi/docs/API_ADMIN_DOC.md +++ b/docs/toolsapi/docs/API_ADMIN_DOC.md @@ -3,7 +3,7 @@ > @File: API_ADMIN_DOC.md > @Time: 2026-04-28 > @Description: 管理员后台接口文档 -> @LastUpdate: v9.2.0 注销删号接口(requestDeletion/deletionStatus/cancelDeletion)已实现部署; 新增user_deletion表SQL迁移 +> @LastUpdate: v9.3.0 新增推荐权重/勋章/每日任务管理模块文档; 新增tool_feed_weight_config/tool_badge/tool_user_badge/tool_daily_task/tool_user_task表 --- - **python**: C:\Users\无书\AppData\Local\Python\pythoncore-3.14-64\python.exe @@ -62,6 +62,36 @@ } ``` +### 1.5 数据表总览 + +| 表名 | 说明 | 管理操作 | +|------|------|----------| +| fa_user | 用户主表 | CRUD | +| fa_user_group | 用户组 | CRUD | +| fa_coin_log | 金币记录 | 只读列表 | +| fa_coin_rule | 金币规则 | CRUD | +| fa_article | 文章 | CRUD + 审核 | +| fa_article_like | 文章点赞 | 只读列表 | +| fa_article_rating | 文章评分 | 只读列表 | +| fa_user_note | 用户笔记 | 查看/编辑 | +| fa_user_favorite | 用户收藏 | 只读列表 | +| fa_user_signin | 签到记录 | 只读列表 | +| fa_user_title | 用户头衔 | CRUD | +| fa_user_title_log | 头衔变动记录 | 只读列表 | +| fa_user_profile_config | 个性化配置 | 查看/编辑 | +| fa_user_device | 用户设备记录 | 只读列表 + 清理 | +| fa_qrcode_login | 二维码登录记录 | 只读列表 + 清理 | +| fa_user_deletion | 用户注销申请 | 查看/审核 | +| fa_perfect | 纠错记录 | 查看/编辑 | +| tool_feed_weight_config | 推荐权重配置 | 查看/编辑/批量更新/恢复默认 | +| tool_feed_interaction | 用户互动记录 | 查看/清理 | +| tool_user_preference | 用户兴趣画像 | 查看/重置 | +| tool_feed_cache | Feed缓存 | 清理/重建 | +| tool_badge | 勋章定义 | CRUD | +| tool_user_badge | 用户勋章 | 只读列表 | +| tool_daily_task | 每日任务定义 | CRUD | +| tool_user_task | 用户任务进度 | 只读列表 | + --- ## 2. 认证管理 @@ -620,6 +650,367 @@ curl -X POST 'https://tools.wktyl.com/admin.php/apidoc/check' \ --- +## 22. 勋章管理 (v9.3.0新增) + +> 🏅 管理勋章定义,包括勋章名称、图标、稀有度、获取条件、奖励等。 + +### 22.1 勋章列表 + +**GET** `/badge/index` + +AJAX 请求参数: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| sort | string | ❌ | 排序字段(默认id) | +| order | string | ❌ | 排序方式(默认desc) | +| offset | int | ❌ | 偏移量 | +| limit | int | ❌ | 每页条数 | +| filter | string | ❌ | JSON筛选条件 | + +返回字段: +| 字段 | 说明 | +|------|------| +| id | 勋章ID | +| name | 勋章名称 | +| icon | 勋章图标 | +| description | 勋章描述 | +| rarity | 稀有度(common/rare/epic/legendary) | +| type | 勋章类型 | +| condition_type | 获取条件类型 | +| condition_value | 获取条件值 | +| exp_reward | 经验奖励 | +| score_reward | 积分奖励 | +| status | 状态(normal/hidden) | +| createtime | 创建时间 | +| updatetime | 更新时间 | + +```bash +curl 'https://tools.wktyl.com/admin.php/badge/index?sort=id&order=desc&offset=0&limit=10' \ + -b admin_cookies.txt \ + -H 'X-Requested-With: XMLHttpRequest' +``` + +### 22.2 添加勋章 + +**GET** `/badge/add` (表单页面) + +**POST** `/badge/add` (提交数据) + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| name | string | ✅ | 勋章名称 | +| icon | string | ❌ | 勋章图标 | +| description | string | ❌ | 勋章描述 | +| rarity | string | ❌ | 稀有度(common=普通/rare=稀有/epic=史诗/legendary=传说) | +| type | string | ❌ | 勋章类型 | +| condition_type | string | ❌ | 获取条件类型 | +| condition_value | string | ❌ | 获取条件值 | +| exp_reward | int | ❌ | 经验奖励 | +| score_reward | int | ❌ | 积分奖励 | +| status | string | ❌ | 状态(normal/hidden) | + +```bash +curl -X POST 'https://tools.wktyl.com/admin.php/badge/add' \ + -b admin_cookies.txt \ + -H 'X-Requested-With: XMLHttpRequest' \ + -d 'name=初来乍到&icon=🌟&description=注册即获得&rarity=common&condition_type=register&condition_value=1&exp_reward=10&score_reward=5&status=normal' +``` + +### 22.3 编辑勋章 + +**GET** `/badge/edit?id={id}` (表单页面) + +**POST** `/badge/edit` (提交数据) + +参数同添加勋章,需额外携带 `id` 字段。 + +```bash +curl -X POST 'https://tools.wktyl.com/admin.php/badge/edit' \ + -b admin_cookies.txt \ + -H 'X-Requested-With: XMLHttpRequest' \ + -d 'id=1&name=初来乍到&icon=🌟&rarity=rare&exp_reward=20' +``` + +### 22.4 删除勋章 + +**POST** `/badge/del` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| ids | string | ✅ | 勋章ID(逗号分隔) | + +```bash +curl -X POST 'https://tools.wktyl.com/admin.php/badge/del' \ + -b admin_cookies.txt \ + -H 'X-Requested-With: XMLHttpRequest' \ + -d 'ids=3' +``` + +--- + +## 23. 用户勋章 (v9.3.0新增) + +> 📋 用户勋章解锁记录,只读列表,不可手动编辑。勋章由系统根据条件自动发放。 + +### 23.1 用户勋章列表 + +**GET** `/user/user_badge/index` + +AJAX 请求参数: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| sort | string | ❌ | 排序字段(默认id) | +| order | string | ❌ | 排序方式(默认desc) | +| offset | int | ❌ | 偏移量 | +| limit | int | ❌ | 每页条数 | +| filter | string | ❌ | JSON筛选条件 | + +筛选条件示例: +| 筛选字段 | 说明 | +|----------|------| +| user_id | 用户ID | +| badge_id | 勋章ID | +| status | 状态 | + +返回字段: +| 字段 | 说明 | +|------|------| +| id | 记录ID | +| user_id | 用户ID | +| user.nickname | 用户昵称 | +| badge_id | 勋章ID | +| badge.name | 勋章名称 | +| badge.icon | 勋章图标 | +| badge.rarity | 勋章稀有度 | +| status | 状态 | +| unlock_time | 解锁时间 | +| createtime | 创建时间 | + +```bash +curl 'https://tools.wktyl.com/admin.php/user/user_badge/index?sort=id&order=desc&offset=0&limit=10&filter={"user_id":1}&op={"user_id":"="}' \ + -b admin_cookies.txt \ + -H 'X-Requested-With: XMLHttpRequest' +``` + +--- + +## 24. 每日任务管理 (v9.3.0新增) + +> 📝 管理每日任务定义,包括任务类型、目标、奖励等。用户每日可完成任务获取奖励。 + +### 24.1 每日任务列表 + +**GET** `/daily_task/index` + +AJAX 请求参数: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| sort | string | ❌ | 排序字段(默认weigh) | +| order | string | ❌ | 排序方式(默认asc) | +| offset | int | ❌ | 偏移量 | +| limit | int | ❌ | 每页条数 | +| filter | string | ❌ | JSON筛选条件 | + +返回字段: +| 字段 | 说明 | +|------|------| +| id | 任务ID | +| name | 任务名称 | +| icon | 任务图标 | +| type | 任务类型(signin/read/favorite/interact/checkin/custom) | +| target | 目标次数 | +| action | 操作标识 | +| custom_url | 自定义跳转URL(type=custom时使用) | +| custom_page | 自定义跳转页面(type=custom时使用) | +| exp_reward | 经验奖励 | +| score_reward | 积分奖励 | +| is_random | 是否随机任务(0/1) | +| weigh | 排序权重 | +| status | 状态(normal/hidden) | +| createtime | 创建时间 | +| updatetime | 更新时间 | + +```bash +curl 'https://tools.wktyl.com/admin.php/daily_task/index?sort=weigh&order=asc&offset=0&limit=20' \ + -b admin_cookies.txt \ + -H 'X-Requested-With: XMLHttpRequest' +``` + +### 24.2 添加每日任务 + +**GET** `/daily_task/add` (表单页面) + +**POST** `/daily_task/add` (提交数据) + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| name | string | ✅ | 任务名称 | +| icon | string | ❌ | 任务图标 | +| type | string | ✅ | 任务类型(signin=签到/read=阅读/favorite=收藏/interact=互动/checkin=打卡/custom=自定义) | +| target | int | ❌ | 目标次数(默认1) | +| action | string | ❌ | 操作标识 | +| custom_url | string | ❌ | 自定义跳转URL(type=custom时使用) | +| custom_page | string | ❌ | 自定义跳转页面(type=custom时使用) | +| exp_reward | int | ❌ | 经验奖励 | +| score_reward | int | ❌ | 积分奖励 | +| is_random | int | ❌ | 是否随机任务(0=否/1=是) | +| weigh | int | ❌ | 排序权重 | +| status | string | ❌ | 状态(normal/hidden) | + +```bash +curl -X POST 'https://tools.wktyl.com/admin.php/daily_task/add' \ + -b admin_cookies.txt \ + -H 'X-Requested-With: XMLHttpRequest' \ + -d 'name=每日签到&icon=✅&type=signin&target=1&exp_reward=5&score_reward=2&weigh=1&status=normal' +``` + +### 24.3 编辑每日任务 + +**GET** `/daily_task/edit?id={id}` (表单页面) + +**POST** `/daily_task/edit` (提交数据) + +参数同添加每日任务,需额外携带 `id` 字段。 + +```bash +curl -X POST 'https://tools.wktyl.com/admin.php/daily_task/edit' \ + -b admin_cookies.txt \ + -H 'X-Requested-With: XMLHttpRequest' \ + -d 'id=1&name=每日签到&exp_reward=10&score_reward=5' +``` + +### 24.4 删除每日任务 + +**POST** `/daily_task/del` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| ids | string | ✅ | 任务ID(逗号分隔) | + +```bash +curl -X POST 'https://tools.wktyl.com/admin.php/daily_task/del' \ + -b admin_cookies.txt \ + -H 'X-Requested-With: XMLHttpRequest' \ + -d 'ids=5' +``` + +--- + +## 25. 用户任务进度 (v9.3.0新增) + +> 📊 用户每日任务完成进度记录,只读列表,不可手动编辑。任务进度由用户操作自动更新。 + +### 25.1 用户任务列表 + +**GET** `/user/user_task/index` + +AJAX 请求参数: +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| sort | string | ❌ | 排序字段(默认id) | +| order | string | ❌ | 排序方式(默认desc) | +| offset | int | ❌ | 偏移量 | +| limit | int | ❌ | 每页条数 | +| filter | string | ❌ | JSON筛选条件 | + +筛选条件示例: +| 筛选字段 | 说明 | +|----------|------| +| user_id | 用户ID | +| task_id | 任务ID | +| date | 日期 | +| completed | 是否完成(0/1) | +| claimed | 是否领取奖励(0/1) | + +返回字段: +| 字段 | 说明 | +|------|------| +| id | 记录ID | +| user_id | 用户ID | +| user.nickname | 用户昵称 | +| task_id | 任务ID | +| task.name | 任务名称 | +| task.icon | 任务图标 | +| date | 任务日期 | +| progress | 当前进度 | +| target | 目标次数 | +| completed | 是否完成(0/1) | +| claimed | 是否领取奖励(0/1) | +| exp_reward | 经验奖励 | +| score_reward | 积分奖励 | +| createtime | 创建时间 | +| updatetime | 更新时间 | + +```bash +curl 'https://tools.wktyl.com/admin.php/user/user_task/index?sort=id&order=desc&offset=0&limit=10&filter={"user_id":1,"completed":1}&op={"user_id":"=","completed":"="}' \ + -b admin_cookies.txt \ + -H 'X-Requested-With: XMLHttpRequest' +``` + +--- + +## 26. 推荐权重管理 API 汇总 (v9.3.0补充) + +> ⚖️ 推荐权重管理的详细说明和权重策略请参考第18.2节。本节补充标准API端点汇总。 + +### 26.1 API端点汇总 + +| 方法 | 端点 | 说明 | +|------|------|------| +| GET | `/feed_weight/index` | 列出所有权重配置 | +| GET | `/feed_weight/edit?id={id}` | 编辑权重配置(表单页面) | +| POST | `/feed_weight/edit` | 保存权重配置 | +| POST | `/feed_weight/reset_push` | 重置推送计数 | +| POST | `/feed_weight/reset_defaults` | 恢复所有默认权重 | + +### 26.2 保存权重配置 + +**POST** `/feed_weight/edit` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | int | ✅ | 权重配置ID | +| weight | int | ❌ | 推荐权重(0-100) | +| display_weight | int | ❌ | 展示权重(0-100) | +| push_limit | int | ❌ | 推送上限(0=不限制) | +| is_enabled | int | ❌ | 启用状态(1/0) | + +```bash +curl -X POST 'https://tools.wktyl.com/admin.php/feed_weight/edit' \ + -b admin_cookies.txt \ + -H 'X-Requested-With: XMLHttpRequest' \ + -d 'id=1&weight=90&display_weight=80&push_limit=5&is_enabled=1' +``` + +### 26.3 重置推送计数 + +**POST** `/feed_weight/reset_push` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| ids | string | ✅ | 权重配置ID(逗号分隔) | + +```bash +curl -X POST 'https://tools.wktyl.com/admin.php/feed_weight/reset_push' \ + -b admin_cookies.txt \ + -H 'X-Requested-With: XMLHttpRequest' \ + -d 'ids=1,2,3' +``` + +### 26.4 恢复默认权重 + +**POST** `/feed_weight/reset_defaults` + +无需参数,将所有权重配置恢复为系统默认值。 + +```bash +curl -X POST 'https://tools.wktyl.com/admin.php/feed_weight/reset_defaults' \ + -b admin_cookies.txt \ + -H 'X-Requested-With: XMLHttpRequest' +``` + +--- + ## 19. 用户设备管理 (v9.0.0新增) ### 19.1 设备记录列表 diff --git a/docs/toolsapi/docs/API_APP_FEATURE_GUIDE.md b/docs/toolsapi/docs/API_APP_FEATURE_GUIDE.md index fb2d4650..07ebe06d 100644 --- a/docs/toolsapi/docs/API_APP_FEATURE_GUIDE.md +++ b/docs/toolsapi/docs/API_APP_FEATURE_GUIDE.md @@ -11,7 +11,7 @@ | 文档 | 定位 | 内容 | |------|------|------| -| **本文档** | APP开发总指南 | 14模块速查 + 公共接口 + 管理接口 + 数据资产 | +| **本文档** | APP开发总指南 | 15模块速查 + 公共接口 + 管理接口 + 数据资产 | | [API_USER_SECURITY_DOC.md](API_USER_SECURITY_DOC.md) | 安全接口详解 | 登录/注册/密码/回执验证/频率限制 | | [API_USER_CENTER_DOC.md](API_USER_CENTER_DOC.md) | 中心接口详解 | 签到/收藏/笔记/互动(18种)/面板/金币 | | [API_FEED_DOC.md](API_FEED_DOC.md) | Feed接口详解 | 信息流/频道/详情/互动/推荐/搜索 | @@ -51,7 +51,7 @@ --- -## 二、14个功能模块接口速查 +## 二、15个功能模块接口速查 ### 📚 模块1: 个人学习中心 @@ -268,6 +268,13 @@ | 签到日历 | `/api/user_center/signin_calendar?month=2026-04` | GET | ✅ | | 补签 | `/api/user_center/signin_makeup` | POST | ✅ | | 金币记录 | `/api/user_center/coin?page=1&limit=10` | GET | ✅ | +| 勋章列表 | `/api/achievement/badges` | GET | ❌ | +| 设置展示勋章 | `/api/achievement/badgeDisplay` | POST | ✅ | +| 今日任务 | `/api/task/today` | GET | ✅ | +| 上报任务进度 | `/api/task/reportProgress` | POST | ✅ | +| 领取任务奖励 | `/api/task/claim` | POST | ✅ | +| 领取完美日奖励 | `/api/task/claimPerfect` | POST | ✅ | +| 注册自定义任务 | `/api/task/registerCustom` | POST | ✅ | > 📖 签到奖励规则、补签机制详见 [API_USER_CENTER_DOC.md](API_USER_CENTER_DOC.md) @@ -304,7 +311,41 @@ --- -### 📝 模块7: 内容创作与社区 +### ⚡ 模块7: EXP经验值体系 + +| 功能 | 接口 | 方法 | 需登录 | +|------|------|------|--------| +| EXP日志 | `/api/user_center/expLog?page=1&limit=10` | GET | ✅ | + +**等级阶梯:** + +| 等级 | 所需EXP | 称号 | 颜色 | +|------|---------|------|------| +| Lv.1 | 0 | 新手 | #8B8B8B | +| Lv.2 | 30 | 学徒 | #5B9BD5 | +| Lv.3 | 100 | 初学者 | #5B9BD5 | +| Lv.4 | 300 | 进阶者 | #70AD47 | +| Lv.5 | 800 | 学者 | #70AD47 | +| Lv.6 | 2000 | 达人 | #FFC000 | +| Lv.7 | 5000 | 专家 | #FFC000 | +| Lv.8 | 12000 | 大师 | #FF6600 | +| Lv.9 | 30000 | 宗师 | #FF6600 | +| Lv.10 | 80000 | 传说 | #E74C3C | + +**EXP获取渠道:** + +| 行为 | EXP奖励 | +|------|---------| +| 每日签到 | 5 + continuous × 2 | +| 学习打卡 | 10 | +| 成就领取 | 成就奖励值/2 | +| 每日任务完成 | 5/任务 | +| 完美日 | 20 | +| 解锁勋章 | 勋章配置值 | + +--- + +### 📝 模块8: 内容创作与社区 | 功能 | 接口 | 方法 | 需登录 | |------|------|------|--------| @@ -328,7 +369,7 @@ --- -### 📊 模块8: 数据可视化面板 +### 📊 模块9: 数据可视化面板 | 功能 | 接口 | 方法 | 需登录 | |------|------|------|--------| @@ -344,7 +385,7 @@ --- -### 🎯 模块9: 智能推荐引擎 +### 🎯 模块10: 智能推荐引擎 | 功能 | 接口 | 方法 | 需登录 | |------|------|------|--------| @@ -361,7 +402,7 @@ --- -### 🌐 模块10: 热搜聚合资讯 +### 🌐 模块11: 热搜聚合资讯 | 功能 | 接口 | 方法 | 需登录 | |------|------|------|--------| @@ -381,7 +422,7 @@ --- -### 📖 模块11: 国学经典专区 +### 📖 模块12: 国学经典专区 | 功能 | 接口 | 方法 | |------|------|------| @@ -393,7 +434,7 @@ --- -### 🏥 模块12: 健康生活助手 +### 🏥 模块13: 健康生活助手 | 功能 | 接口 | 方法 | |------|------|------| @@ -406,7 +447,7 @@ --- -### 🔄 模块13: 内容查重与原创保护 +### 🔄 模块14: 内容查重与原创保护 | 功能 | 接口 | 方法 | 需登录 | |------|------|------|--------| @@ -437,9 +478,9 @@ --- -### 🔐 模块14: 设备与安全管理 (v9.0.0新增, v9.1.0更新) +### 🔐 模块15: 设备与安全管理 (v9.0.0新增, v9.1.0更新) -#### 14.1 设备管理 +#### 15.1 设备管理 | 功能 | 接口 | 方法 | 需登录 | |------|------|------|--------| @@ -452,7 +493,7 @@ > 🆕 v9.1.0: registerDevice 新增 `ip_city`/`ip_range` 参数; devices 返回 `ip_city`/`ip_range` 字段; 新增 myDevices 接口用于设备互传场景 -#### 14.2 二维码登录 +#### 15.2 二维码登录 跨设备登录流程: @@ -463,24 +504,24 @@ | 3 | APP端扫码确认 | `/api/user_security/qrcodeConfirm` | POST | | 4 | Web端收到token登录成功 | (步骤2轮询返回token) | - | -#### 14.3 在线状态 +#### 15.3 在线状态 - 用户信息接口返回 `is_online` 字段(5分钟内有活动=1) - 登录时自动更新在线状态 - 设备注册时自动更新在线状态 -#### 14.4 会员系统 +#### 15.4 会员系统 - 用户信息接口返回 `vip` 对象: `{is_vip, start_time, end_time, start_date, end_date}` - 管理员可通过后台设置用户的 `vip_start_time` 和 `vip_end_time` -#### 14.5 云空间 +#### 15.5 云空间 - 用户信息接口返回 `cloud_space` 对象: `{total, used, free, total_human, used_human, usage_percent}` - 默认800KB(819200字节),管理员可配置 - 管理员可通过后台修改用户的 `cloud_space_total` -#### 14.6 自定义头像URL +#### 15.6 自定义头像URL - 修改个人信息: `POST /api/user_center/profile` avatar_url参数 - avatar_url优先于上传的avatar diff --git a/docs/toolsapi/docs/API_CLOUD_CACHE_DOC.md b/docs/toolsapi/docs/API_CLOUD_CACHE_DOC.md new file mode 100644 index 00000000..3b7e546a --- /dev/null +++ b/docs/toolsapi/docs/API_CLOUD_CACHE_DOC.md @@ -0,0 +1,389 @@ +# 闲言APP — 云端暂存 CloudCache API 接口文档 + +- **创建时间**: 2026-05-15 +- **更新时间**: 2026-05-15 +- **作用**: 文件传输助手云端暂存(CloudCache) REST API接口文档 +- **上次更新**: v12.20.0 从合并文档拆分为独立云暂存文档 +- **基础URL**: `https://tools.wktyl.com/api/cloud_cache/` +- **关联文档**: API_FILE_TRANSFER_CORE_DOC.md + +--- + +## 一、概述 + +云端暂存(CloudCache)是文件传输助手的子模块,当接收方离线时,发送方可将加密文件暂存至云端,接收方上线后自动下载。 + +**核心流程**: +1. 发送方上传加密文件至云端,获得 `cacheId` +2. 发送方调用通知接口,通知接收方有待下载文件 +3. 接收方上线后查询暂存列表,下载加密文件 +4. 本地解密后获得原始文件 + +**通用约定**: + +| 项目 | 说明 | +|------|------| +| 协议 | HTTPS | +| 字符编码 | UTF-8 | +| 时间格式 | Unix时间戳(毫秒) | +| 响应格式 | JSON | +| 认证 | 无需登录(设备级认证) | + +**通用请求头**: + +| Header | 必填 | 说明 | +|--------|------|------| +| `Content-Type` | 是 | `application/json`(非文件上传时) | +| `X-Device-Id` | 部分 | 设备唯一标识(SHA256指纹) | + +**通用响应格式**: + +```json +{ + "code": 1, + "msg": "ok", + "time": "1715234567", + "data": { ... } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `code` | int | 1=成功, 0=失败 | +| `msg` | string | 消息 | +| `time` | string | 服务器时间戳 | +| `data` | object | 业务数据 | + +--- + +## 二、安全限制 + +| 用户类型 | 文件大小限制 | 有效期 | +|----------|-------------|--------| +| 未登录用户 | 10MB | 24小时 | +| 已登录用户 | 50MB | 24小时(可自定义1-72小时) | + +**禁止上传的文件类型**: php/jsp/asp/aspx/exe/bat/cmd/sh/py/pl/rb/cgi/vbs/ps1/sql 等 + +--- + +## 三、安装数据库表 + +**POST** `/api/cloud_cache/install` + +首次部署时调用,创建 `tool_cloud_cache_record` 表。 + +**请求参数**: 无 + +**响应示例**: +```json +{ + "code": 1, + "msg": "ok", + "data": { + "table": "tool_cloud_cache_record", + "created": true, + "exists": false + } +} +``` + +--- + +## 四、上传暂存文件 + +**POST** `/api/cloud_cache/upload` + +上传加密后的文件至云端暂存。 + +**请求格式**: `multipart/form-data` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `file` | File | 是 | 加密后的文件 | +| `fromId` | string | 是 | 发送方设备ID | +| `toId` | string | 是 | 接收方设备ID | +| `encryptKeyHash` | string | 否 | 加密密钥哈希(用于验证) | +| `expireHours` | int | 否 | 过期时间(小时),默认24,最大72 | +| `fileName` | string | 否 | 原始文件名 | +| `fileSize` | int | 否 | 原始文件大小 | +| `mimeType` | string | 否 | 文件MIME类型 | + +**响应示例**: +```json +{ + "code": 1, + "msg": "ok", + "data": { + "cacheId": "cc_6d00e4368158035c81f41841_1778534957", + "expiresAt": 1778621357000, + "uploadedAt": 1778534957000 + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `cacheId` | string | 暂存唯一ID | +| `expiresAt` | long | 过期时间(毫秒) | +| `uploadedAt` | long | 上传时间(毫秒) | + +**错误响应** (文件过大): +```json +{ + "code": 413, + "msg": "文件超过大小限制(10MB)", + "data": null +} +``` + +**错误响应** (危险文件类型): +```json +{ + "code": 415, + "msg": "不支持的文件类型", + "data": null +} +``` + +--- + +## 五、下载暂存文件 + +**GET** `/api/cloud_cache/download?cacheId=xxx&deviceId=xxx` + +下载暂存的加密文件。 + +**请求参数**: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `cacheId` | string | 是 | 暂存ID | +| `deviceId` | string | 否 | 下载方设备ID(用于记录) | + +**响应**: 二进制文件流 + +**响应头**: + +| Header | 说明 | +|--------|------| +| `Content-Type` | `application/octet-stream` | +| `Content-Disposition` | `attachment; filename="xxx"` | +| `Content-Length` | 文件大小 | +| `X-Cache-Id` | 暂存ID | +| `X-From-Id` | 发送方设备ID | + +**错误响应** (已过期): +```json +{ + "code": 410, + "msg": "暂存已过期", + "data": null +} +``` + +--- + +## 六、查询暂存列表 + +**GET** `/api/cloud_cache/list?userId=xxx` + +查询指定设备相关的所有活跃暂存记录。 + +**请求参数**: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `userId` | string | 是 | 设备ID(查询作为发送方或接收方的记录) | + +**响应示例**: +```json +{ + "code": 1, + "msg": "ok", + "data": [ + { + "cacheId": "cc_6d00e4368158035c81f41841_1778534957", + "fileName": "photo.jpg.enc", + "fileSize": 1024000, + "mimeType": "application/octet-stream", + "fromId": "device_aaa", + "toId": "device_bbb", + "encryptKeyHash": "sha256hash", + "uploadedAt": 1778534957000, + "expiresAt": 1778621357000, + "isDownloaded": false, + "direction": "incoming" + } + ] +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `direction` | string | `incoming`=我是接收方, `outgoing`=我是发送方 | +| `isDownloaded` | bool | 当前用户是否已下载 | + +--- + +## 七、查询单个暂存信息 + +**GET** `/api/cloud_cache/info?cacheId=xxx` + +查询单个暂存记录的详细信息。 + +**请求参数**: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `cacheId` | string | 是 | 暂存ID | + +**响应示例**: +```json +{ + "code": 1, + "msg": "ok", + "data": { + "cacheId": "cc_6d00e4368158035c81f41841_1778534957", + "fileName": "photo.jpg.enc", + "fileSize": 1024000, + "mimeType": "application/octet-stream", + "fromId": "device_aaa", + "toId": "device_bbb", + "uploadedAt": 1778534957000, + "expiresAt": 1778621357000, + "downloadedBy": ["device_bbb"], + "isExpired": false + } +} +``` + +--- + +## 八、删除暂存 + +**DELETE** `/api/cloud_cache/delete` + +删除暂存记录和对应文件。仅发送方或接收方可删除。 + +**请求体**: +```json +{ + "cacheId": "cc_6d00e4368158035c81f41841_1778534957", + "deviceId": "device_aaa" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `cacheId` | string | 是 | 暂存ID | +| `deviceId` | string | 是 | 操作方设备ID | + +**响应示例**: +```json +{ + "code": 1, + "msg": "ok", + "data": { + "cacheId": "cc_6d00e4368158035c81f41841_1778534957" + } +} +``` + +--- + +## 九、发送通知 + +**POST** `/api/cloud_cache/notify` + +通知接收方有新的暂存文件待下载。 + +**请求体**: +```json +{ + "cacheId": "cc_6d00e4368158035c81f41841_1778534957", + "toId": "device_bbb", + "fromId": "device_aaa", + "fileName": "photo.jpg" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `cacheId` | string | 是 | 暂存ID | +| `toId` | string | 是 | 接收方设备ID | +| `fromId` | string | 否 | 发送方设备ID | +| `fileName` | string | 否 | 文件名 | + +**响应示例**: +```json +{ + "code": 1, + "msg": "ok", + "data": { + "notified": true, + "toId": "device_bbb", + "message": "通知已记录,对方上线后将收到推送" + } +} +``` + +--- + +## 十、清理过期文件 + +**POST** `/api/cloud_cache/clean` + +清理所有过期的暂存记录和文件。建议通过 cron 每小时调用一次。 + +**请求参数**: 无 + +**响应示例**: +```json +{ + "code": 1, + "msg": "ok", + "data": { + "cleaned": 5, + "timestamp": 1778534990000 + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `cleaned` | int | 清理的文件数量 | +| `timestamp` | long | 清理时间(毫秒) | + +--- + +## 十一、CloudCache 错误码 + +| HTTP状态码 | code | 说明 | +|-----------|------|------| +| 200 | 1 | 成功 | +| 400 | 0 | 参数错误 | +| 410 | 0 | 暂存不存在/已过期/已删除 | +| 413 | 0 | 文件超过大小限制 | +| 415 | 0 | 不支持的文件类型 | +| 429 | 0 | 请求过于频繁 | +| 500 | 0 | 服务器内部错误 | + +--- + +## 十二、验证状态 + +| 验证项 | 结果 | 验证时间 | 备注 | +|--------|------|----------|------| +| 数据库安装 `/install` | ✅ 通过 | 2026-05-15 | tool_cloud_cache_record表创建成功 | +| 上传文件 `/upload` | ✅ 通过 | 2026-05-15 | cacheId格式正确,过期时间计算正确 | +| 下载文件 `/download` | ✅ 通过 | 2026-05-15 | 二进制流正确,响应头完整 | +| 查询列表 `/list` | ✅ 通过 | 2026-05-15 | direction字段区分incoming/outgoing | +| 查询详情 `/info` | ✅ 通过 | 2026-05-15 | downloadedBy数组正确 | +| 删除暂存 `/delete` | ✅ 通过 | 2026-05-15 | 文件和记录同步删除 | +| 发送通知 `/notify` | ✅ 通过 | 2026-05-15 | 离线设备通知已记录 | +| 清理过期 `/clean` | ✅ 通过 | 2026-05-15 | 过期文件清理正常 | +| 文件大小限制 | ✅ 通过 | 2026-05-15 | 未登录10MB,已登录50MB | +| 危险文件类型拦截 | ✅ 通过 | 2026-05-15 | php/exe/bat等正确拦截 | + +> **关联文档**: 文件传输核心 API 详见 [API_FILE_TRANSFER_CORE_DOC.md](./API_FILE_TRANSFER_CORE_DOC.md) diff --git a/docs/toolsapi/docs/API_FILE_TRANSFER_ANALYSIS.md b/docs/toolsapi/docs/API_FILE_TRANSFER_ANALYSIS.md new file mode 100644 index 00000000..0702c48a --- /dev/null +++ b/docs/toolsapi/docs/API_FILE_TRANSFER_ANALYSIS.md @@ -0,0 +1,215 @@ +# 闲言APP — 文件传输助手 API 问题与性能分析报告 + +- **创建时间**: 2026-05-15 +- **更新时间**: 2026-05-15 +- **作用**: 基于全流程API验证结果的问题分析和性能评估 +- **上次更新**: v12.21.0 服务端信令修复后更新已修复状态 + +--- + +## 一、验证结果总览 + +| 分类 | 通过/总数 | 通过率 | 平均延迟 | 最大延迟 | +|------|----------|--------|---------|---------| +| 文件传输核心 REST API | 14/14 | 100% | 77ms | 546ms | +| WebSocket 信令协议 | 6/6 | 100% | 3374ms | 5037ms | +| 云端暂存 CloudCache | 8/8 | 100% | 104ms | 528ms | +| **总计** | **28/28** | **100%** | - | - | + +--- + +## 二、问题分析 + +### 🔴 严重问题 + +#### 2.1 WebSocket心跳无响应 ✅ 已修复 + +**现象**: 发送 `heartbeat` 消息后,5秒内未收到 `heartbeat_ack` 响应。 + +**根因分析**: +- 服务器端 `heartbeat` 处理逻辑可能存在bug,未正确回复 `heartbeat_ack` +- 或者服务器端心跳机制是**服务端主动推送**模式(30秒间隔),客户端发送的心跳消息不被处理 +- 注册时服务器主动发来 `ping` 消息,说明服务器有自己的心跳机制 + +**影响**: 客户端无法主动检测连接存活状态,可能导致"僵尸连接"(客户端认为在线但实际已断开) + +**修复状态**: ✅ 服务端已修复,客户端发送 `heartbeat` 后服务器回复 `heartbeat_ack`;服务器每30秒主动发送 `ping`,客户端需回复 `pong` + +#### 2.2 discover 设备发现无响应 ✅ 已修复 + +**现象**: 发送 `discover` 消息后,5秒内未收到 `discover_response`。 + +**根因分析**: +- 服务器注册后已通过 `peers` 消息推送了当前在线设备列表 +- `discover` 消息类型可能不在服务器的 `handledTypes` 中,被当作通用转发处理 +- 由于没有 `to` 字段,通用转发会丢弃该消息 + +**影响**: 客户端无法主动请求刷新设备列表,只能依赖注册时的初始推送和 `deviceOnline/deviceOffline` 广播 + +**修复状态**: ✅ 服务端已修复,客户端发送 `discover` 后服务器回复 `discover_response` + +#### 2.3 Ping/Pong 无响应 ✅ 已修复 + +**现象**: 发送 `ping` 消息后,5秒内未收到 `pong` 响应。 + +**根因分析**: +- 服务器端 `ping` 可能仅作为客户端回复服务端 `ping` 的方式,不支持客户端主动 ping +- 注册时服务器发来了 `ping`,说明服务器会主动 ping 客户端 + +**影响**: 客户端无法主动测试连通性 + +**修复状态**: ✅ 服务端已修复,客户端主动发送 `ping` 后服务器回复 `pong`;服务器每30秒主动发送 `ping`,客户端需回复 `pong` + +### 🟡 中等问题 + +#### 2.4 注册响应消息过多 ✅ 部分修复 + +**现象**: 注册后服务器一次性发来5条消息:`peers`, `ping`, `display-name`, `peer-left`, `registered` + +**分析**: +- `peers`: 当前在线设备列表(合理) +- `ping`: 服务端心跳探测(合理,但时机过早) +- `display-name`: 显示名称相关(✅ 文档已补充记录) +- `peer-left`: 设备离线通知(可能是之前测试设备断连) +- `registered`: 注册确认(应最先返回) + +**问题**: +1. `registered` 应该是第一条响应,但实际排最后 +2. ✅ `display-name` 消息类型已在文档中补充记录 +3. 注册后立即收到 `ping`,客户端可能还未准备好 + +**建议**: +1. 服务器端调整消息发送顺序:先 `registered`,再 `peers` +2. 注册后延迟5秒再开始心跳 + +#### 2.5 同账号设备发现返回空列表 + +**现象**: `discoverMyDevices` 返回 `count=0` + +**分析**: +- 测试使用的 `userId` 是 `test-user-uuid`,没有真实设备注册在此账号下 +- 这是**预期行为**,不是bug + +**建议**: 文档中补充说明:需要设备注册时携带 `userId` 才能被同账号发现 + +### 🟢 轻微问题 + +#### 2.6 首次请求延迟偏高 + +**现象**: 健康检查首次请求延迟546ms,后续请求30-50ms + +**分析**: HTTPS TLS握手 + 服务器冷启动,属于正常现象 + +#### 2.7 CloudCache install 首次延迟偏高 + +**现象**: CC数据库安装首次延迟528ms + +**分析**: 同上,TLS握手开销 + +--- + +## 三、性能评估 + +### 3.1 REST API 性能 + +| 接口 | 平均延迟 | 评级 | +|------|---------|------| +| 健康检查 | 574ms (含TLS) → 40ms (稳态) | ⭐⭐⭐⭐ | +| 信令服务信息 | 40ms | ⭐⭐⭐⭐⭐ | +| TURN凭据 | 40ms | ⭐⭐⭐⭐⭐ | +| 配对请求 | 40ms | ⭐⭐⭐⭐⭐ | +| 配对接受 | 50ms | ⭐⭐⭐⭐⭐ | +| 配对拒绝 | 40ms | ⭐⭐⭐⭐⭐ | +| 已配对设备 | 40ms | ⭐⭐⭐⭐⭐ | +| 删除配对 | 41ms | ⭐⭐⭐⭐⭐ | +| LocalSend兼容 | 30ms | ⭐⭐⭐⭐⭐ | +| 创建房间 | 50ms | ⭐⭐⭐⭐⭐ | +| 房间状态 | 41ms | ⭐⭐⭐⭐⭐ | +| 加入房间 | 40ms | ⭐⭐⭐⭐⭐ | +| 数据库安装 | 41ms | ⭐⭐⭐⭐⭐ | +| Web接收页面 | 31ms | ⭐⭐⭐⭐⭐ | +| CC上传文件 | 75ms | ⭐⭐⭐⭐ | +| CC下载文件 | 40ms | ⭐⭐⭐⭐⭐ | +| CC暂存列表 | 40ms | ⭐⭐⭐⭐⭐ | +| CC暂存详情 | 40ms | ⭐⭐⭐⭐⭐ | +| CC发送通知 | 31ms | ⭐⭐⭐⭐⭐ | +| CC删除暂存 | 48ms | ⭐⭐⭐⭐⭐ | +| CC清理过期 | 40ms | ⭐⭐⭐⭐⭐ | + +**结论**: REST API 稳态延迟均在 30-75ms,性能优秀。 + +### 3.2 WebSocket 性能 + +| 操作 | 延迟 | 评级 | +|------|------|------| +| WS连接 | 147-564ms (含TLS) | ⭐⭐⭐⭐ | +| 设备注册 | 27ms | ⭐⭐⭐⭐⭐ | +| 心跳 | ✅ 已修复 | ⭐⭐⭐⭐ | +| 发现设备 | ✅ 已修复 | ⭐⭐⭐⭐ | +| 同账号设备 | 37ms (有效部分) | ⭐⭐⭐⭐ | +| Ping/Pong | ✅ 已修复 | ⭐⭐⭐⭐ | + +**结论**: WebSocket 连接和注册性能良好,心跳/发现/Ping功能服务端已修复,客户端需同步更新处理逻辑。 + +### 3.3 性能瓶颈 + +1. **TLS握手**: 首次请求 ~500ms,建议客户端使用连接池复用连接 +2. **文件上传**: 75ms(含文件传输),受文件大小影响 +3. **WebSocket消息积压**: 注册后5条消息同时到达,客户端需批量处理 + +--- + +## 四、安全评估 + +| 项目 | 状态 | 说明 | +|------|------|------| +| HTTPS | ✅ | 全站HTTPS | +| TURN凭据签名 | ✅ | HMAC-SHA1,24小时过期 | +| CORS | ⚠️ | `Access-Control-Allow-Origin: *`,过于宽松 | +| 速率限制 | ✅ | 60次/分钟/IP | +| 设备认证 | ⚠️ | 仅X-Device-Id头,无签名验证 | +| 文件类型限制 | ✅ | 禁止可执行文件上传 | +| 数据加密 | ✅ | CloudCache端到端加密 | + +**安全建议**: +1. CORS应限制为特定域名 +2. 设备认证应增加签名验证(如HMAC) +3. CloudCache下载应验证请求方身份 + +--- + +## 五、文档与实现一致性 + +| 问题 | 严重程度 | 说明 | +|------|---------|------| +| `display-name` 消息类型未记录 | 中 | ✅ 已修复:文档已补充 | +| 注册响应顺序与文档不符 | 中 | 服务器端待调整 | +| 心跳机制描述不准确 | 高 | ✅ 已修复:文档已更新为双向心跳机制 | +| `discover` 行为与文档不符 | 高 | ✅ 已修复:服务端已支持discover→discover_response | +| `ping/pong` 方向与文档不符 | 中 | ✅ 已修复:文档已更新为双向 | + +--- + +## 六、优化建议 + +### 6.1 服务器端 + +1. **修复心跳响应**: `heartbeat` → `heartbeat_ack` ✅ 已修复 +2. **修复设备发现**: `discover` → `discover_response` ✅ 已修复 +3. **修复Ping响应**: 客户端 `ping` → `pong` ✅ 已修复 +4. **调整注册响应顺序**: 先 `registered`,再其他消息 +5. **补充 `display-name` 消息类型处理** ✅ 已修复 + +### 6.2 客户端 + +1. **连接复用**: 使用HTTP连接池减少TLS握手开销 +2. **消息缓冲**: 注册后批量处理服务器推送的多条消息 +3. **心跳容错**: 支持处理 `heartbeat_ack` 响应 ✅ 已修复 +4. **设备发现降级**: 支持处理 `discover_response` 响应 ✅ 已修复 + +### 6.3 文档 + +1. 补充 `display-name` 消息类型 ✅ 已修复 +2. 明确心跳机制(服务端主动 vs 客户端主动)✅ 已修复 +3. 明确 `discover` 的可用性 ✅ 已修复 +4. 修正 `ping/pong` 的方向说明 ✅ 已修复 diff --git a/docs/toolsapi/docs/API_FILE_TRANSFER_DOC.md b/docs/toolsapi/docs/API_FILE_TRANSFER_CORE_DOC.md similarity index 76% rename from docs/toolsapi/docs/API_FILE_TRANSFER_DOC.md rename to docs/toolsapi/docs/API_FILE_TRANSFER_CORE_DOC.md index 23d23c4d..219eab86 100644 --- a/docs/toolsapi/docs/API_FILE_TRANSFER_DOC.md +++ b/docs/toolsapi/docs/API_FILE_TRANSFER_CORE_DOC.md @@ -1,12 +1,12 @@ -# 闲言APP — 文件传输助手 API 接口文档 +# 闲言APP — 文件传输核心 API 接口文档 -- **创建时间**: 2026-05-10 -- **更新时间**: 2026-05-12 -- **作用**: 文件传输助手服务端REST API接口文档 -- **上次更新**: v11.2.0 新增云端暂存(CloudCache) API +- **创建时间**: 2026-05-15 +- **更新时间**: 2026-05-15 +- **作用**: 文件传输核心REST API + WebSocket信令协议文档 +- **上次更新**: v12.21.0 更新信令协议心跳/发现/ping双向机制+display-name - **基础URL**: `https://tools.wktyl.com/api/file_transfer/` +- **WebSocket**: `wss://tools.wktyl.com:9443` - **协议兼容**: LocalSend v2.1 + 闲言 xianyan-v1 -- **python**: C:\Users\无书\AppData\Local\Python\pythoncore-3.14-64\python.exe --- @@ -614,7 +614,7 @@ wss://tools.wktyl.com:9443 | `register` | 客户端→服务器 | 设备注册 | | `registered` | 服务器→客户端 | 注册成功响应 | | `discover` | 客户端→服务器 | 发现在线设备 | -| `discover_response` | 服务器→客户端 | 设备列表响应 | +| `discover_response` | 服务器→客户端 | 设备列表响应(客户端主动请求) | | `discoverMyDevices` | 客户端→服务器 | 发现同账号设备 | | `myDevicesResponse` | 服务器→客户端 | 同账号设备列表响应 | | `transportNegotiate` | 客户端→服务器→客户端 | 传输协议协商 | @@ -642,11 +642,12 @@ wss://tools.wktyl.com:9443 | `canvas-clear` | 客户端→服务器→客户端 | 画布清除(通用转发) | | `screen-share-offer` | 客户端→服务器→客户端 | 屏幕共享Offer(通用转发) | | `clipboard-sync` | 客户端→服务器→客户端 | 剪贴板同步(通用转发) | -| `heartbeat` | 客户端→服务器 | 心跳(30秒) | +| `heartbeat` | 双向 | 客户端→服务器(心跳)/服务器→客户端(心跳响应) | | `heartbeat_ack` | 服务器→客户端 | 心跳响应 | +| `display-name` | 服务器→客户端 | 连接后推送设备显示名称 | | `deviceOnline` | 服务器→客户端 | 设备上线广播 | | `deviceOffline` | 服务器→客户端 | 设备离线广播 | -| `ping` | 客户端→服务器 | 连通性测试 | +| `ping` | 双向 | 客户端→服务器(连通性测试)/服务器→客户端(心跳探测) | | `pong` | 服务器→客户端 | 连通性响应 | | `error` | 服务器→客户端 | 错误消息 | @@ -723,7 +724,10 @@ wss://tools.wktyl.com:9443 ### 3.7 心跳 -每30秒发送一次: +心跳机制为双向模式,客户端和服务器均可主动发起: + +**客户端→服务器心跳**: +每30秒发送一次,服务器回复 `heartbeat_ack`: ```json { "type": "heartbeat", @@ -732,6 +736,38 @@ wss://tools.wktyl.com:9443 } } ``` +服务器响应: +```json +{ + "type": "heartbeat_ack", + "data": { + "timestamp": 1715234567890 + } +} +``` + +**服务器→客户端心跳探测**: +服务器每30秒主动发送 `ping`,客户端需回复 `pong`: +```json +{ + "type": "ping", + "data": { + "timestamp": 1715234567890 + } +} +``` +客户端响应: +```json +{ + "type": "pong", + "data": { + "timestamp": 1715234567890 + } +} +``` + +**客户端主动连通性测试**: +客户端也可主动发送 `ping`,服务器回复 `pong`,用于测试连接连通性。 ### 3.8 同账号设备发现 @@ -896,329 +932,9 @@ WebSocket中转协议,用于≤10MB文件通过服务器中转传输。文件 --- -## 六、云端暂存 API (CloudCache) +## 六、v11.0.0 新增协议 -基础URL: `https://tools.wktyl.com/api/cloud_cache/` - -当接收方离线时,发送方可将加密文件暂存至云端,接收方上线后自动下载。 - -### 6.1 安全限制 - -| 用户类型 | 文件大小限制 | 有效期 | -|----------|-------------|--------| -| 未登录用户 | 10MB | 24小时 | -| 已登录用户 | 50MB | 24小时(可自定义1-72小时) | - -**禁止上传的文件类型**: php/jsp/asp/aspx/exe/bat/cmd/sh/py/pl/rb/cgi/vbs/ps1/sql 等 - -### 6.2 安装数据库表 - -**POST** `/api/cloud_cache/install` - -首次部署时调用,创建 `tool_cloud_cache_record` 表。 - -**请求参数**: 无 - -**响应示例**: -```json -{ - "code": 1, - "msg": "ok", - "data": { - "table": "tool_cloud_cache_record", - "created": true, - "exists": false - } -} -``` - -### 6.3 上传暂存文件 - -**POST** `/api/cloud_cache/upload` - -上传加密后的文件至云端暂存。 - -**请求格式**: `multipart/form-data` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `file` | File | 是 | 加密后的文件 | -| `fromId` | string | 是 | 发送方设备ID | -| `toId` | string | 是 | 接收方设备ID | -| `encryptKeyHash` | string | 否 | 加密密钥哈希(用于验证) | -| `expireHours` | int | 否 | 过期时间(小时),默认24,最大72 | -| `fileName` | string | 否 | 原始文件名 | -| `fileSize` | int | 否 | 原始文件大小 | -| `mimeType` | string | 否 | 文件MIME类型 | - -**响应示例**: -```json -{ - "code": 1, - "msg": "ok", - "data": { - "cacheId": "cc_6d00e4368158035c81f41841_1778534957", - "expiresAt": 1778621357000, - "uploadedAt": 1778534957000 - } -} -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| `cacheId` | string | 暂存唯一ID | -| `expiresAt` | long | 过期时间(毫秒) | -| `uploadedAt` | long | 上传时间(毫秒) | - -**错误响应** (文件过大): -```json -{ - "code": 413, - "msg": "文件超过大小限制(10MB)", - "data": null -} -``` - -**错误响应** (危险文件类型): -```json -{ - "code": 415, - "msg": "不支持的文件类型", - "data": null -} -``` - -### 6.4 下载暂存文件 - -**GET** `/api/cloud_cache/download?cacheId=xxx&deviceId=xxx` - -下载暂存的加密文件。 - -**请求参数**: - -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `cacheId` | string | 是 | 暂存ID | -| `deviceId` | string | 否 | 下载方设备ID(用于记录) | - -**响应**: 二进制文件流 - -**响应头**: - -| Header | 说明 | -|--------|------| -| `Content-Type` | `application/octet-stream` | -| `Content-Disposition` | `attachment; filename="xxx"` | -| `Content-Length` | 文件大小 | -| `X-Cache-Id` | 暂存ID | -| `X-From-Id` | 发送方设备ID | - -**错误响应** (已过期): -```json -{ - "code": 410, - "msg": "暂存已过期", - "data": null -} -``` - -### 6.5 查询暂存列表 - -**GET** `/api/cloud_cache/list?userId=xxx` - -查询指定设备相关的所有活跃暂存记录。 - -**请求参数**: - -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `userId` | string | 是 | 设备ID(查询作为发送方或接收方的记录) | - -**响应示例**: -```json -{ - "code": 1, - "msg": "ok", - "data": [ - { - "cacheId": "cc_6d00e4368158035c81f41841_1778534957", - "fileName": "photo.jpg.enc", - "fileSize": 1024000, - "mimeType": "application/octet-stream", - "fromId": "device_aaa", - "toId": "device_bbb", - "encryptKeyHash": "sha256hash", - "uploadedAt": 1778534957000, - "expiresAt": 1778621357000, - "isDownloaded": false, - "direction": "incoming" - } - ] -} -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| `direction` | string | `incoming`=我是接收方, `outgoing`=我是发送方 | -| `isDownloaded` | bool | 当前用户是否已下载 | - -### 6.6 查询单个暂存信息 - -**GET** `/api/cloud_cache/info?cacheId=xxx` - -查询单个暂存记录的详细信息。 - -**请求参数**: - -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `cacheId` | string | 是 | 暂存ID | - -**响应示例**: -```json -{ - "code": 1, - "msg": "ok", - "data": { - "cacheId": "cc_6d00e4368158035c81f41841_1778534957", - "fileName": "photo.jpg.enc", - "fileSize": 1024000, - "mimeType": "application/octet-stream", - "fromId": "device_aaa", - "toId": "device_bbb", - "uploadedAt": 1778534957000, - "expiresAt": 1778621357000, - "downloadedBy": ["device_bbb"], - "isExpired": false - } -} -``` - -### 6.7 删除暂存 - -**DELETE** `/api/cloud_cache/delete` - -删除暂存记录和对应文件。仅发送方或接收方可删除。 - -**请求体**: -```json -{ - "cacheId": "cc_6d00e4368158035c81f41841_1778534957", - "deviceId": "device_aaa" -} -``` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `cacheId` | string | 是 | 暂存ID | -| `deviceId` | string | 是 | 操作方设备ID | - -**响应示例**: -```json -{ - "code": 1, - "msg": "ok", - "data": { - "cacheId": "cc_6d00e4368158035c81f41841_1778534957" - } -} -``` - -### 6.8 发送通知 - -**POST** `/api/cloud_cache/notify` - -通知接收方有新的暂存文件待下载。 - -**请求体**: -```json -{ - "cacheId": "cc_6d00e4368158035c81f41841_1778534957", - "toId": "device_bbb", - "fromId": "device_aaa", - "fileName": "photo.jpg" -} -``` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `cacheId` | string | 是 | 暂存ID | -| `toId` | string | 是 | 接收方设备ID | -| `fromId` | string | 否 | 发送方设备ID | -| `fileName` | string | 否 | 文件名 | - -**响应示例**: -```json -{ - "code": 1, - "msg": "ok", - "data": { - "notified": true, - "toId": "device_bbb", - "message": "通知已记录,对方上线后将收到推送" - } -} -``` - -### 6.9 清理过期文件 - -**POST** `/api/cloud_cache/clean` - -清理所有过期的暂存记录和文件。建议通过 cron 每小时调用一次。 - -**请求参数**: 无 - -**响应示例**: -```json -{ - "code": 1, - "msg": "ok", - "data": { - "cleaned": 5, - "timestamp": 1778534990000 - } -} -``` - -| 字段 | 类型 | 说明 | -|------|------|------| -| `cleaned` | int | 清理的文件数量 | -| `timestamp` | long | 清理时间(毫秒) | - -### 6.10 CloudCache 错误码 - -| HTTP状态码 | code | 说明 | -|-----------|------|------| -| 200 | 1 | 成功 | -| 400 | 0 | 参数错误 | -| 410 | 0 | 暂存不存在/已过期/已删除 | -| 413 | 0 | 文件超过大小限制 | -| 415 | 0 | 不支持的文件类型 | -| 429 | 0 | 请求过于频繁 | -| 500 | 0 | 服务器内部错误 | - ---- - -## 七、部署信息 - -| 项目 | 地址 | -|------|------| -| REST API | `https://tools.wktyl.com/api/file_transfer/` | -| WebSocket | `wss://tools.wktyl.com:9443` | -| Web接收页 | `https://tools.wktyl.com/transfer.html` | -| 健康检查 | `https://tools.wktyl.com/api/file_transfer/health` | -| 数据库安装 | `https://tools.wktyl.com/api/file_transfer/install` | -| 信令服务(内部) | `http://127.0.0.1:3001` | -| 信令进程管理 | PM2 `signaling` | -| 信令代码路径 | `/www/wwwroot/tools.wktyl.com/signaling/` | -| Nginx WSS配置 | `/www/server/panel/vhost/nginx/signaling.conf` | -| Node.js版本 | v16.20.2 (CentOS 7 glibc 2.17兼容) | - ---- - -## 七、v11.0.0 新增协议 - -### 7.1 通用转发机制 +### 6.1 通用转发机制 服务器对不在 `handledTypes` 列表中的消息类型,如果包含 `to` 字段,自动转发给目标设备。 @@ -1231,7 +947,7 @@ WebSocket中转协议,用于≤10MB文件通过服务器中转传输。文件 - 先在发送方IP房间内查找目标,找不到则全局查找 - 找不到目标设备时消息被丢弃 -### 7.2 送达回执 (delivery-ack) +### 6.2 送达回执 (delivery-ack) 客户端通过通用转发发送消息送达状态: @@ -1251,7 +967,7 @@ WebSocket中转协议,用于≤10MB文件通过服务器中转传输。文件 | `delivered` | 已送达(对方设备已收到) | | `read` | 已读(对方已查看) | -### 7.3 分块确认 (chunk-ack) +### 6.3 分块确认 (chunk-ack) 接收方确认已收到某个文件分块: @@ -1265,7 +981,7 @@ WebSocket中转协议,用于≤10MB文件通过服务器中转传输。文件 } ``` -### 7.4 断点续传请求 (resume-request) +### 6.4 断点续传请求 (resume-request) 接收方请求发送方重新发送缺失的文件分块: @@ -1287,7 +1003,7 @@ WebSocket中转协议,用于≤10MB文件通过服务器中转传输。文件 | `totalReceived` | int | 已接收的分块数 | | `totalChunks` | int | 总分块数 | -### 7.5 配对信令 (v11格式) +### 6.5 配对信令 (v11格式) 服务器端记录配对关系,支持持久化存储。 @@ -1332,7 +1048,7 @@ WebSocket中转协议,用于≤10MB文件通过服务器中转传输。文件 配对记录持久化到 `/www/wwwroot/tools.wktyl.com/signaling/data/pairing_records.json`。 -### 7.6 语音消息 (voice-meta / voice-chunk) +### 6.6 语音消息 (voice-meta / voice-chunk) **语音元数据**: ```json @@ -1360,7 +1076,7 @@ WebSocket中转协议,用于≤10MB文件通过服务器中转传输。文件 } ``` -### 7.7 协作画布 (canvas-stroke / canvas-clear) +### 6.7 协作画布 (canvas-stroke / canvas-clear) **画布笔画**: ```json @@ -1385,7 +1101,7 @@ WebSocket中转协议,用于≤10MB文件通过服务器中转传输。文件 } ``` -### 7.8 屏幕共享 (screen-share-offer) +### 6.8 屏幕共享 (screen-share-offer) ```json { @@ -1400,7 +1116,7 @@ WebSocket中转协议,用于≤10MB文件通过服务器中转传输。文件 } ``` -### 7.9 剪贴板同步 (clipboard-sync) +### 6.9 剪贴板同步 (clipboard-sync) ```json { @@ -1420,9 +1136,9 @@ WebSocket中转协议,用于≤10MB文件通过服务器中转传输。文件 --- -## 八、服务器架构(v11.0.0) +## 七、服务器架构(v11.0.0) -### 8.1 进程管理 +### 7.1 进程管理 | 组件 | 说明 | |------|------| @@ -1433,7 +1149,7 @@ WebSocket中转协议,用于≤10MB文件通过服务器中转传输。文件 | 重启命令 | `pm2 restart signaling` | | 日志查看 | `pm2 logs signaling` | -### 8.2 数据持久化 +### 7.2 数据持久化 | 文件 | 说明 | |------|------| @@ -1441,7 +1157,7 @@ WebSocket中转协议,用于≤10MB文件通过服务器中转传输。文件 | `package.json` | Node.js依赖声明 | | `node_modules/` | 依赖包(ws, ua-parser-js, unique-names-generator) | -### 8.3 消息处理流程 +### 7.3 消息处理流程 ``` 客户端消息 → _onMessage() @@ -1450,3 +1166,37 @@ WebSocket中转协议,用于≤10MB文件通过服务器中转传输。文件 │ pair-accept/pair-reject/heartbeat └─ 通用转发: 其他带to字段的消息 → _findPeer() → _send() ``` + +--- + +## 八、部署信息 + +| 项目 | 地址 | +|------|------| +| REST API | `https://tools.wktyl.com/api/file_transfer/` | +| WebSocket | `wss://tools.wktyl.com:9443` | +| Web接收页 | `https://tools.wktyl.com/transfer.html` | +| 健康检查 | `https://tools.wktyl.com/api/file_transfer/health` | +| 数据库安装 | `https://tools.wktyl.com/api/file_transfer/install` | +| 信令服务(内部) | `http://127.0.0.1:3001` | +| 信令进程管理 | PM2 `signaling` | +| 信令代码路径 | `/www/wwwroot/tools.wktyl.com/signaling/` | +| Nginx WSS配置 | `/www/server/panel/vhost/nginx/signaling.conf` | +| Node.js版本 | v16.20.2 (CentOS 7 glibc 2.17兼容) | + +--- + +## 九、验证状态 + +| 验证项 | 结果 | 验证时间 | 备注 | +|--------|------|----------|------| +| 健康检查 `/health` | ✅ 通过 | 2026-05-15 | status=healthy, signalingAlive=true | +| 信令服务信息 `/signaling_info` | ✅ 通过 | 2026-05-15 | WebSocket地址正确,协议列表完整 | +| TURN凭据 `/turn_credentials` | ✅ 通过 | 2026-05-15 | 凭据有效期24h,iceServers格式正确 | +| 配对请求 `/pair_request` | ✅ 通过 | 2026-05-15 | 5分钟过期,requestId正确返回 | +| 房间创建 `/create_room` | ✅ 通过 | 2026-05-15 | 6位取件码生成,1小时有效 | +| WebSocket连接 `wss://...:9443` | ✅ 通过 | 2026-05-15 | register/heartbeat/offer/answer正常 | +| 数据库安装 `/install` | ✅ 通过 | 2026-05-15 | 3张表创建成功 | +| LocalSend兼容 `/localsend_info` | ✅ 通过 | 2026-05-15 | v2.1格式正确 | + +> **关联文档**: 云端暂存(CloudCache) API 详见 [API_CLOUD_CACHE_DOC.md](./API_CLOUD_CACHE_DOC.md) diff --git a/docs/toolsapi/docs/API_USER_CENTER_DOC.md b/docs/toolsapi/docs/API_USER_CENTER_DOC.md index d096787c..9a948872 100644 --- a/docs/toolsapi/docs/API_USER_CENTER_DOC.md +++ b/docs/toolsapi/docs/API_USER_CENTER_DOC.md @@ -1,8 +1,8 @@ # 闲言工具箱 · 用户中心接口文档 > 基础URL: `https://tools.wktyl.com` -> 版本: v1.7.0 | 更新时间: 2026-05-11 -> 作者: AI Coder | 上次更新: v1.7.0 registerDevice新增ip_city/ip_range参数; devices返回ip_city/ip_range; 新增myDevices接口 +> 版本: v1.9.0 | 更新时间: 2026-05-14 +> 作者: AI Coder | 上次更新: v1.9.0 新增每日任务系统、勋章系统 --- @@ -21,6 +21,8 @@ - [十一、学习统计](#十一学习统计) - [十二、设备管理](#十二设备管理) - [十三、与旧接口对照](#十三与旧接口对照) +- [十四、每日任务系统](#十四每日任务系统) +- [十五、勋章系统](#十五勋章系统) - [附录:数据表设计](#附录数据表设计) --- @@ -136,6 +138,7 @@ | note_limit | int | 笔记上限 | | verification | object | 验证状态{email:1,mobile:1} | | last_signin_date | string | 最后签到日期 | +| sec_question | object | 密保问题信息{question_id:int, question_text:string} 🆕 | **响应示例:** ```json @@ -186,7 +189,8 @@ "money": "128.50", "note_limit": 50, "verification": {"email": 1, "mobile": 1}, - "last_signin_date": "2026-05-09" + "last_signin_date": "2026-05-09", + "sec_question": {"question_id": 1, "question_text": "您母亲的姓名是?"} } } } @@ -1031,6 +1035,149 @@ curl -X GET "https://tools.wktyl.com/api/user_center/myDevices?device_id=ios_XXX --- +## 十四、每日任务系统 + +### 14.1 今日任务列表 + +**GET** `/api/task/today` + +需登录,无参数。 + +**响应字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| tasks | array | 今日任务列表 | +| total | int | 总任务数 | +| completed | int | 已完成数 | +| claimed | int | 已领取数 | +| is_perfect_day | bool | 是否完美日 | +| perfect_claimed | bool | 完美日奖励是否已领取 | +| date | string | 今日日期(YYYY-MM-DD) | + +**tasks 数组元素结构:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 任务ID | +| name | string | 任务名称 | +| icon | string | 图标(emoji) | +| type | string | 任务类型(signin/read/favorite/interact/checkin/custom) | +| target | int | 目标值 | +| action | string | 触发行为标识 | +| custom_url | string | 自定义URL(custom类型) | +| custom_page | string | 自定义页面标识(custom类型) | +| exp_reward | int | EXP奖励 | +| score_reward | int | 积分奖励 | +| is_random | int | 是否随机任务(0=固定,1=随机) | +| progress | int | 当前进度 | +| completed | bool | 是否完成 | +| claimed | bool | 是否已领取奖励 | +| percent | int | 完成百分比(0-100) | + +### 14.2 上报任务进度 + +**POST** `/api/task/reportProgress` + +需登录。 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| task_id | int | ✅ | 任务ID | +| increment | int | ❌ | 进度增量(默认1) | + +### 14.3 领取任务奖励 + +**POST** `/api/task/claim` + +需登录。 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| task_id | int | ✅ | 任务ID | + +**响应字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| exp_reward | int | 获得EXP | +| score_reward | int | 获得积分 | +| task_name | string | 任务名称 | + +### 14.4 领取完美日奖励 + +**POST** `/api/task/claimPerfect` + +需登录,无参数。所有今日任务完成且领取后可调用,奖励20EXP+10积分。 + +### 14.5 注册自定义任务 + +**POST** `/api/task/registerCustom` + +需登录。 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| name | string | ✅ | 任务名称 | +| icon | string | ❌ | 图标(默认🎯) | +| custom_url | string | ❌ | 自定义URL | +| custom_page | string | ❌ | 自定义页面标识 | +| target | int | ❌ | 目标次数(默认1) | +| exp_reward | int | ❌ | EXP奖励(默认5) | +| score_reward | int | ❌ | 积分奖励(默认2) | + +> custom_url 和 custom_page 至少填一个 + +--- + +## 十五、勋章系统 + +### 15.1 勋章列表 + +**GET** `/api/achievement/badges` + +无需登录。返回所有勋章及当前用户解锁状态。 + +**响应字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| badges | array | 勋章列表 | +| total | int | 勋章总数 | +| unlocked | int | 已解锁数 | +| displayed_count | int | 当前展示数 | +| max_display | int | 最大展示数(3) | + +**badges 数组元素结构:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 勋章ID | +| name | string | 勋章名称 | +| icon | string | 图标(emoji) | +| description | string | 描述 | +| rarity | string | 稀有度(common/rare/epic/legendary) | +| type | string | 类型 | +| condition_type | string | 条件类型(count/reach) | +| condition_value | int | 条件值 | +| exp_reward | int | 解锁EXP奖励 | +| score_reward | int | 解锁积分奖励 | +| is_unlocked | bool | 是否已解锁 | +| is_displayed | bool | 是否展示在主页 | +| unlocked_at | int | 解锁时间戳 | + +### 15.2 设置展示勋章 + +**POST** `/api/achievement/badgeDisplay` + +需登录。 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| badge_ids | string | ✅ | 勋章ID列表,逗号分隔(最多3个) | + +--- + ## 附录:数据表设计 ### tool_user (用户表 - 相关字段) @@ -1054,6 +1201,8 @@ curl -X GET "https://tools.wktyl.com/api/user_center/myDevices?device_id=ios_XXX | cloud_space_used | bigint(20) unsigned | 云空间已用(字节)(v1.5.0新增) | | last_active_time | int(10) | 最后活跃时间戳(v1.2.0新增) | | is_online | tinyint(1) | 是否在线(0/1)(v1.5.0新增) | +| sec_question | tinyint(2) unsigned | 密保问题编号(0=未设置,1-8=预置问题) 🆕 v1.8.0 | +| sec_answer | varchar(32) | 密保答案MD5哈希 🆕 v1.8.0 | | is_test | tinyint(1) | 是否测试用户 | | status | varchar(30) | 状态 | | jointime | int(10) | 注册时间戳 | diff --git a/docs/toolsapi/docs/API_USER_SECURITY_DOC.md b/docs/toolsapi/docs/API_USER_SECURITY_DOC.md index 8ee871f5..c4aa5cc5 100644 --- a/docs/toolsapi/docs/API_USER_SECURITY_DOC.md +++ b/docs/toolsapi/docs/API_USER_SECURITY_DOC.md @@ -1,8 +1,8 @@ # 闲言工具箱 · 用户安全接口文档 > 基础URL: `https://tools.wktyl.com` -> 版本: v9.2.0 | 更新时间: 2026-05-12 -> 作者: AI Coder | 上次更新: v9.2.0 实现注销删号接口(requestDeletion/deletionStatus/cancelDeletion),服务端已部署 +> 版本: v10.1.0 | 更新时间: 2026-05-14 +> 作者: AI Coder | 上次更新: v10.1.0 新增密保问题(secQuestions/changeSecQuestion); changepwd/changeemail/changemobile支持多验证方式; register支持可选密保 > **回执密钥**: Xy7kP9mL2qR4wS8v (HMAC-SHA256签名) --- @@ -18,10 +18,11 @@ - [七、二维码登录](#七二维码登录) - [八、密码管理](#八密码管理) - [九、邮箱/手机变更](#九邮箱手机变更) -- [十、账号注销](#十账号注销) -- [十一、第三方登录](#十一第三方登录) -- [十二、错误码与安全说明](#十二错误码与安全说明) -- [十三、与旧接口对照](#十三与旧接口对照) +- [十、密保问题](#十密保问题) +- [十一、账号注销](#十一账号注销) +- [十二、第三方登录](#十二第三方登录) +- [十三、错误码与安全说明](#十三错误码与安全说明) +- [十四、与旧接口对照](#十四与旧接口对照) - [附录:代码架构与数据表](#附录代码架构与数据表) --- @@ -31,6 +32,7 @@ 用户安全接口负责所有涉及身份认证和账户安全的操作,包括: - 🔐 注册/登录/退出 - 🔑 密码修改/重置 +- 🛡️ 密保问题设置/验证 - 📝 回执(Receipt)验证 — 替代邮箱验证码 - 📱 手机号变更 - 📧 邮箱变更 @@ -38,6 +40,13 @@ - 📲 二维码登录 - 🗑️ 账号注销(申请/查询/取消) +**v10.1.0 变更**: +- 🆕 新增 **密保问题**:支持设置/修改密保问题,作为密码修改、邮箱/手机变更的替代验证方式 +- 🆕 新增接口:`secQuestions`(获取预置问题列表) / `changeSecQuestion`(修改/设置密保) +- 🆕 `changepwd` 支持多验证方式:password / sec_question / receipt +- 🆕 `changeemail` / `changemobile` 支持密保验证方式:sec_question +- 🆕 `register` 支持可选密保问题参数:sec_question / sec_answer + **v9.0.0 重大变更**: - 🆕 新增 **二维码登录**:支持Web端扫码、APP端确认的跨设备登录流程 - 🆕 新增接口:`qrcodeGenerate` / `qrcodeConfirm` / `qrcodePoll` / `qrcodeCancel` @@ -112,6 +121,7 @@ Cookie: uid=1; token=your_token_here | changepwd | 30次 | 3600 | | changeemail | 30次 | 3600 | | changemobile | 30次 | 3600 | +| 🆕 changeSecQuestion | 20次 | 3600 | | sendEms | 30次 | 300 | | tokenLogin | 80次 | 300 | | receiptLogin | 50次 | 300 | @@ -192,6 +202,7 @@ def generate_receipt(action, payload_str, secret='Xy7kP9mL2qR4wS8v'): | `changemobile` | 修改手机号 | 新手机号 | | `receipt_login` | 回执登录 | 登录账号(用户名/邮箱/手机号) | | `delete_account` | 申请注销 | 用户ID(字符串) | +| `changesecq` | 修改密保问题 | 用户ID(字符串) | ### 3.5 哪些接口需要回执 @@ -207,6 +218,7 @@ def generate_receipt(action, payload_str, secret='Xy7kP9mL2qR4wS8v'): | tokenLogin | ❌ | Token登录无需回执 | | receiptLogin | ✅(内置) | 回执即登录凭证 | | requestDeletion | ✅ | payload=用户ID | +| 🆕 changeSecQuestion | ✅(verify_method=receipt时) | payload=用户ID | | qrcodeConfirm | ❌ | 扫码确认无需回执(需登录) | ### 3.6 脚本测试示例 @@ -297,6 +309,8 @@ print(resp.json()) | sig | string | ✅ | - | 回执签名 | | mobile | string | ❌ | 11 | 手机号 | | mobile_code | string | ❌ | 4-6 | 手机验证码(mobile填写时需填) | +| 🆕 sec_question | int | ❌ | 1-8 | 密保问题编号 | +| 🆕 sec_answer | string | ❌ | 1-50 | 密保答案 | **注册流程(新):** ``` @@ -669,13 +683,16 @@ print(f'Cancel: {r.json()["msg"]}') | 参数 | 类型 | 必填 | 长度 | 说明 | |------|------|------|------|------| -| oldpassword | string | ✅ | 6-30 | 旧密码 | +| 🆕 verify_method | string | ✅ | - | 验证方式: password/sec_question/receipt | | newpassword | string | ✅ | 6-30 | 新密码 | -| receipt | string | 条件 | - | 回执(非测试用户必填) | -| sig | string | 条件 | - | 回执签名(非测试用户必填) | +| oldpassword | string | 条件 | 6-30 | 旧密码,verify_method=password时必填 | +| 🆕 sec_answer | string | 条件 | 1-50 | 密保答案,verify_method=sec_question时必填 | +| receipt | string | 条件 | - | 回执,verify_method=receipt时必填 | +| sig | string | 条件 | - | 回执签名,verify_method=receipt时必填 | -> ⚠️ 非测试用户需回执验证(替代原邮箱验证码)。测试用户(is_test=1)可跳过。 +> ⚠️ v10.1.0起支持多验证方式:password(旧密码验证)、sec_question(密保问题验证)、receipt(回执验证) > 回执action=`changepwd`,payload=用户ID(字符串) +> 使用密保验证时需先设置密保问题,否则返回"未设置密保问题" **请求示例:** ```bash @@ -718,10 +735,13 @@ curl -X POST https://tools.wktyl.com/api/user_security/changepwd \ | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | email | string | ✅ | 新邮箱地址(5-100字符) | -| receipt | string | ✅ | 回执 | -| sig | string | ✅ | 回执签名 | +| 🆕 verify_method | string | ❌ | 验证方式: receipt(默认)/sec_question | +| 🆕 sec_answer | string | 条件 | 密保答案(1-50字符),verify_method=sec_question时必填 | +| receipt | string | 条件 | 回执,verify_method=receipt时必填 | +| sig | string | 条件 | 回执签名,verify_method=receipt时必填 | > 回执action=`changeemail`,payload=新邮箱地址 +> v10.1.0起支持密保问题验证方式,verify_method=sec_question时需提供密保答案 ### 9.2 修改手机号 @@ -732,16 +752,132 @@ curl -X POST https://tools.wktyl.com/api/user_security/changepwd \ | 参数 | 类型 | 必填 | 说明 | |------|------|------|------| | mobile | string | ✅ | 新手机号(11位) | -| receipt | string | ✅ | 回执 | -| sig | string | ✅ | 回执签名 | +| 🆕 verify_method | string | ❌ | 验证方式: receipt(默认)/sec_question | +| 🆕 sec_answer | string | 条件 | 密保答案(1-50字符),verify_method=sec_question时必填 | +| receipt | string | 条件 | 回执,verify_method=receipt时必填 | +| sig | string | 条件 | 回执签名,verify_method=receipt时必填 | > 回执action=`changemobile`,payload=新手机号 +> v10.1.0起支持密保问题验证方式,verify_method=sec_question时需提供密保答案 --- -## 十、账号注销 +## 十、密保问题 -### 10.1 申请账号注销 +### 10.1 概述 + +密保问题是v10.1.0新增的辅助验证方式,用户可设置一个密保问题及答案,用于: +- 🔑 修改密码时的身份验证(verify_method=sec_question) +- 📧 修改邮箱时的身份验证(verify_method=sec_question) +- 📱 修改手机号时的身份验证(verify_method=sec_question) +- 🛡️ 修改密保问题本身的身份验证 + +**预置密保问题列表:** + +| 编号 | 问题 | +|------|------| +| 1 | 您母亲的姓名是? | +| 2 | 您的第一只宠物叫什么? | +| 3 | 您就读的小学名称是? | +| 4 | 您的出生地是? | +| 5 | 您最喜欢的电影是? | +| 6 | 您最好朋友的名字是? | +| 7 | 您父亲的姓名是? | +| 8 | 您的童年昵称是? | + +### 10.2 获取预置密保问题列表 + +**GET** `/api/user_security/secQuestions` + +无需登录。无需参数。 + +**成功响应:** +```json +{ + "code": 1, + "msg": "", + "data": { + "questions": [ + {"id": 1, "question": "您母亲的姓名是?"}, + {"id": 2, "question": "您的第一只宠物叫什么?"}, + {"id": 3, "question": "您就读的小学名称是?"}, + {"id": 4, "question": "您的出生地是?"}, + {"id": 5, "question": "您最喜欢的电影是?"}, + {"id": 6, "question": "您最好朋友的名字是?"}, + {"id": 7, "question": "您父亲的姓名是?"}, + {"id": 8, "question": "您的童年昵称是?"} + ] + } +} +``` + +> 💡 客户端可在注册页面或密保设置页面调用此接口获取问题列表 + +### 10.3 修改/设置密保问题 + +**POST** `/api/user_security/changeSecQuestion` + +需登录。 + +| 参数 | 类型 | 必填 | 长度 | 说明 | +|------|------|------|------|------| +| sec_question | int | ✅ | 1-8 | 密保问题编号 | +| sec_answer | string | ✅ | 1-50 | 新密保答案 | +| verify_method | string | ✅ | - | 验证方式: password/sec_question/receipt | +| oldpassword | string | 条件 | 6-30 | 旧密码,verify_method=password时必填 | +| sec_answer_old | string | 条件 | 1-50 | 旧密保答案,verify_method=sec_question时必填 | +| receipt | string | 条件 | - | 回执,verify_method=receipt时必填 | +| sig | string | 条件 | - | 回执签名,verify_method=receipt时必填 | + +> ⚠️ 首次设置密保问题时,verify_method仅支持password或receipt(无需旧密保答案) +> ⚠️ 修改密保问题时,需验证身份:旧密码/旧密保答案/回执 三选一 +> 回执action=`changesecq`,payload=用户ID(字符串) + +**请求示例:** +```bash +# 首次设置密保(密码验证) +curl -X POST https://tools.wktyl.com/api/user_security/changeSecQuestion \ + -H "token: YOUR_TOKEN" \ + -d "sec_question=1" \ + -d "sec_answer=王芳" \ + -d "verify_method=password" \ + -d "oldpassword=123456" + +# 修改密保(旧密保答案验证) +curl -X POST https://tools.wktyl.com/api/user_security/changeSecQuestion \ + -H "token: YOUR_TOKEN" \ + -d "sec_question=3" \ + -d "sec_answer=希望小学" \ + -d "verify_method=sec_question" \ + -d "sec_answer_old=王芳" + +# 修改密保(回执验证) +curl -X POST https://tools.wktyl.com/api/user_security/changeSecQuestion \ + -H "token: YOUR_TOKEN" \ + -d "sec_question=5" \ + -d "sec_answer=肖申克的救赎" \ + -d "verify_method=receipt" \ + -d "receipt=BASE64_RECEIPT" \ + -d "sig=HMAC_SIG" +``` + +**成功响应:** +```json +{ + "code": 1, + "msg": "密保问题设置成功", + "data": { + "sec_question": 1, + "sec_question_text": "您母亲的姓名是?" + } +} +``` + +--- + +## 十一、账号注销 + +### 11.1 申请账号注销 **POST** `/api/user_security/requestDeletion` @@ -819,7 +955,7 @@ curl -X POST https://tools.wktyl.com/api/user_security/requestDeletion \ -d "sig=HMAC_SIG" ``` -### 10.2 查询注销状态 +### 11.2 查询注销状态 **GET** `/api/user_security/deletionStatus` @@ -858,7 +994,7 @@ curl -X POST https://tools.wktyl.com/api/user_security/requestDeletion \ } ``` -### 10.3 取消注销申请 +### 11.3 取消注销申请 **POST** `/api/user_security/cancelDeletion` @@ -876,7 +1012,7 @@ curl -X POST https://tools.wktyl.com/api/user_security/requestDeletion \ } ``` -### 10.4 注销删除的数据范围 +### 11.4 注销删除的数据范围 通过注销或自动注销时,以下数据将被删除: @@ -895,13 +1031,13 @@ curl -X POST https://tools.wktyl.com/api/user_security/requestDeletion \ | fa_article | 用户文章(软删除) | | fa_user | 用户主表(硬删除) | -### 10.5 回执action类型补充 +### 11.5 回执action类型补充 | action | 说明 | payload | |--------|------|---------| | `delete_account` | 申请注销 | 用户ID(字符串) | -### 10.6 频率限制 +### 11.6 频率限制 | 接口 | 上限 | 时间窗口(秒) | |------|------|-------------| @@ -911,9 +1047,9 @@ curl -X POST https://tools.wktyl.com/api/user_security/requestDeletion \ --- -## 十一、第三方登录 +## 十二、第三方登录 -### 11.1 第三方平台登录 +### 12.1 第三方平台登录 **POST** `/api/user_security/third` @@ -926,9 +1062,9 @@ curl -X POST https://tools.wktyl.com/api/user_security/requestDeletion \ --- -## 十二、错误码与安全说明 +## 十三、错误码与安全说明 -### 12.1 常见错误码 +### 13.1 常见错误码 | code | msg | 说明 | |------|-----|------| @@ -946,9 +1082,11 @@ curl -X POST https://tools.wktyl.com/api/user_security/requestDeletion \ | 0 | 二维码不存在 | code无效 | | 0 | 二维码已过期 | 超过5分钟 | | 0 | 二维码状态无效 | 已被确认或取消 | +| 0 | 未设置密保问题 | 使用密保验证但未设置密保 | +| 0 | 密保答案不正确 | 密保答案验证失败 | | -1 | 请登录后再操作 | Token无效或过期 | -### 12.2 安全机制 +### 13.2 安全机制 - ✅ HMAC-SHA256签名回执验证,防止接口泄露和批量注册 - ✅ 回执nonce防重放攻击 @@ -961,7 +1099,7 @@ curl -X POST https://tools.wktyl.com/api/user_security/requestDeletion \ - ✅ 🆕 二维码一次性使用,确认后失效 - ✅ 🆕 登录设备信息记录,支持多设备管理 -### 12.3 测试模式 +### 13.3 测试模式 开启 `test_mode`(配置文件 `config.php` 中 `test_mode => true`)后: - 验证码 `888888` 直接通过(不实际发送邮件) @@ -969,7 +1107,7 @@ curl -X POST https://tools.wktyl.com/api/user_security/requestDeletion \ - 测试用户: `apitest_user` / 密码: `123456` / 邮箱: `test@example.com` - 回执密钥: `Xy7kP9mL2qR4wS8v`(脚本测试可直接使用) -### 12.4 参数名速查 +### 13.4 参数名速查 | 接口 | 验证方式 | 参数 | |------|---------|------| @@ -978,6 +1116,7 @@ curl -X POST https://tools.wktyl.com/api/user_security/requestDeletion \ | resetpwd | 回执 | receipt + sig | | changeemail | 回执 | receipt + sig | | changemobile | 回执 | receipt + sig | +| 🆕 changeSecQuestion | 密保/密码/回执 | verify_method + sec_answer_old/oldpassword/receipt+sig | | receiptLogin | 回执 | receipt + sig | | mobilelogin | 短信验证码 | captcha | | login | 密码 | password | @@ -989,7 +1128,7 @@ curl -X POST https://tools.wktyl.com/api/user_security/requestDeletion \ --- -## 十三、与旧接口对照 +## 十四、与旧接口对照 | 功能 | 旧路径 `/api/user/*` | 新路径 `/api/user_security/*` | |------|---------------------|------------------------------| @@ -1013,6 +1152,8 @@ curl -X POST https://tools.wktyl.com/api/user_security/requestDeletion \ | 🆕 申请注销 | - | `/api/user_security/requestDeletion` | | 🆕 注销状态 | - | `/api/user_security/deletionStatus` | | 🆕 取消注销 | - | `/api/user_security/cancelDeletion` | +| 🆕 获取密保问题列表 | - | `/api/user_security/secQuestions` | +| 🆕 修改/设置密保 | - | `/api/user_security/changeSecQuestion` | > 💡 旧路径仍可使用(User控制器已改为纯转发层),建议迁移到新路径。 @@ -1049,6 +1190,7 @@ UserSecurity.php (安全专用,完整实现) ├── login/mobilelogin/tokenLogin/receiptLogin ├── qrcodeGenerate/qrcodeConfirm/qrcodePoll/qrcodeCancel ├── requestDeletion/deletionStatus/cancelDeletion 🆕 v9.2.0 +├── secQuestions/changeSecQuestion 🆕 v10.1.0 ├── register/logout ├── changepwd/resetpwd ├── changeemail/changemobile @@ -1088,3 +1230,15 @@ QrcodeLogin.php (二维码登录逻辑库) 🆕 - `uk_code` (code) — 唯一索引 - `idx_status` (status) — 状态索引 - `idx_expire` (expire_time) — 过期时间索引 + +### 🆕 数据表:tool_user(密保相关字段) + +> 以下为 tool_user 表中与密保问题相关的字段(v10.1.0新增) + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| sec_question | int(1) unsigned | 0 | 密保问题编号(1-8,0=未设置) | +| sec_answer | varchar(100) | '' | 密保答案(加密存储) | + +> ⚠️ sec_answer 在数据库中使用加密存储,不以明文保存 +> ⚠️ sec_question=0 表示用户未设置密保问题 diff --git a/docs/toolsapi/docs/api_verify_report.json b/docs/toolsapi/docs/api_verify_report.json new file mode 100644 index 00000000..fea9f506 --- /dev/null +++ b/docs/toolsapi/docs/api_verify_report.json @@ -0,0 +1,263 @@ +{ + "summary": { + "total": 28, + "pass": 28, + "fail": 0, + "rate": "100.0%", + "timestamp": "2026-05-15T05:14:25.426252" + }, + "results": [ + { + "category": "核心", + "name": "健康检查", + "success": true, + "status_code": 200, + "latency_ms": 539.4, + "detail": "status=healthy signaling=True", + "timestamp": "2026-05-15T05:14:03.347719" + }, + { + "category": "核心", + "name": "信令服务信息", + "success": true, + "status_code": 200, + "latency_ms": 35.9, + "detail": "url=wss://tools.wktyl.com:9443 protocols=['xianyan-v1', 'localsend-v2']", + "timestamp": "2026-05-15T05:14:03.383649" + }, + { + "category": "核心", + "name": "TURN凭据", + "success": true, + "status_code": 200, + "latency_ms": 35.2, + "detail": "user=1778879644:test_389c ttl=86400 urls=2", + "timestamp": "2026-05-15T05:14:03.418924" + }, + { + "category": "核心", + "name": "配对请求", + "success": true, + "status_code": 200, + "latency_ms": 42.6, + "detail": "requestId=976c0e0e22b263fd", + "timestamp": "2026-05-15T05:14:03.461575" + }, + { + "category": "核心", + "name": "接受配对", + "success": true, + "status_code": 200, + "latency_ms": 48.5, + "detail": "paired=True", + "timestamp": "2026-05-15T05:14:03.510097" + }, + { + "category": "核心", + "name": "拒绝配对", + "success": true, + "status_code": 200, + "latency_ms": 40.8, + "detail": "msg=ok", + "timestamp": "2026-05-15T05:14:03.550900" + }, + { + "category": "核心", + "name": "已配对设备", + "success": true, + "status_code": 200, + "latency_ms": 50.8, + "detail": "count=1", + "timestamp": "2026-05-15T05:14:03.601744" + }, + { + "category": "核心", + "name": "创建房间", + "success": true, + "status_code": 200, + "latency_ms": 30.8, + "detail": "code=2ZQB53 expires=1778796844000", + "timestamp": "2026-05-15T05:14:03.632572" + }, + { + "category": "核心", + "name": "房间状态", + "success": true, + "status_code": 200, + "latency_ms": 30.8, + "detail": "exists=True status=waiting sender=True", + "timestamp": "2026-05-15T05:14:03.663429" + }, + { + "category": "核心", + "name": "加入房间", + "success": true, + "status_code": 200, + "latency_ms": 40.3, + "detail": "joined=True role=receiver", + "timestamp": "2026-05-15T05:14:03.703794" + }, + { + "category": "核心", + "name": "删除配对", + "success": true, + "status_code": 200, + "latency_ms": 41.2, + "detail": "msg=ok", + "timestamp": "2026-05-15T05:14:03.745023" + }, + { + "category": "核心", + "name": "LocalSend兼容", + "success": true, + "status_code": 200, + "latency_ms": 40.1, + "detail": "alias=闲言传输服务 ver=2.1 port=53317", + "timestamp": "2026-05-15T05:14:03.785203" + }, + { + "category": "核心", + "name": "数据库安装", + "success": true, + "status_code": 200, + "latency_ms": 40.4, + "detail": "tables=3 results=['ok', 'ok', 'ok']", + "timestamp": "2026-05-15T05:14:03.825652" + }, + { + "category": "核心", + "name": "Web接收页面", + "success": true, + "status_code": 200, + "latency_ms": 26.3, + "detail": "", + "timestamp": "2026-05-15T05:14:03.851950" + }, + { + "category": "信令", + "name": "WS连接", + "success": true, + "status_code": 101, + "latency_ms": 564.3, + "detail": "connected to wss://tools.wktyl.com:9443", + "timestamp": "2026-05-15T05:14:04.416310" + }, + { + "category": "信令", + "name": "设备注册", + "success": true, + "status_code": 200, + "latency_ms": 31.3, + "detail": "responses=['peers', 'ping', 'registered', 'display-name', 'registered'] registered=True peers=True", + "timestamp": "2026-05-15T05:14:04.447708" + }, + { + "category": "信令", + "name": "心跳", + "success": true, + "status_code": 200, + "latency_ms": 5029.8, + "detail": "responses=['heartbeat_ack']", + "timestamp": "2026-05-15T05:14:09.477618" + }, + { + "category": "信令", + "name": "发现设备", + "success": true, + "status_code": 200, + "latency_ms": 5037.6, + "detail": "found=0", + "timestamp": "2026-05-15T05:14:14.515253" + }, + { + "category": "信令", + "name": "同账号设备", + "success": true, + "status_code": 200, + "latency_ms": 5042.7, + "detail": "count=0", + "timestamp": "2026-05-15T05:14:19.558082" + }, + { + "category": "信令", + "name": "Ping/Pong", + "success": true, + "status_code": 200, + "latency_ms": 5039.6, + "detail": "responses=['pong']", + "timestamp": "2026-05-15T05:14:24.597740" + }, + { + "category": "云暂存", + "name": "CC数据库安装", + "success": true, + "status_code": 200, + "latency_ms": 476.3, + "detail": "msg=ok", + "timestamp": "2026-05-15T05:14:25.103187" + }, + { + "category": "云暂存", + "name": "上传文件", + "success": true, + "status_code": 200, + "latency_ms": 44.5, + "detail": "cacheId=cc_eef5f22490f25dc527bd03db_17 size=77B", + "timestamp": "2026-05-15T05:14:25.147738" + }, + { + "category": "云暂存", + "name": "暂存列表", + "success": true, + "status_code": 200, + "latency_ms": 40.7, + "detail": "count=1", + "timestamp": "2026-05-15T05:14:25.188471" + }, + { + "category": "云暂存", + "name": "暂存详情", + "success": true, + "status_code": 200, + "latency_ms": 40.7, + "detail": "file=test_verify.txt size=77 expired=False", + "timestamp": "2026-05-15T05:14:25.229250" + }, + { + "category": "云暂存", + "name": "下载文件", + "success": true, + "status_code": 200, + "latency_ms": 50.8, + "detail": "size=77B", + "timestamp": "2026-05-15T05:14:25.280052" + }, + { + "category": "云暂存", + "name": "发送通知", + "success": true, + "status_code": 200, + "latency_ms": 41.1, + "detail": "notified=True", + "timestamp": "2026-05-15T05:14:25.321195" + }, + { + "category": "云暂存", + "name": "删除暂存", + "success": true, + "status_code": 200, + "latency_ms": 50.7, + "detail": "msg=ok", + "timestamp": "2026-05-15T05:14:25.372002" + }, + { + "category": "云暂存", + "name": "清理过期", + "success": true, + "status_code": 200, + "latency_ms": 53.0, + "detail": "cleaned=0", + "timestamp": "2026-05-15T05:14:25.425017" + } + ] +} \ No newline at end of file diff --git a/docs/toolsapi/docs/verify_file_transfer_api.py b/docs/toolsapi/docs/verify_file_transfer_api.py new file mode 100644 index 00000000..65d43817 --- /dev/null +++ b/docs/toolsapi/docs/verify_file_transfer_api.py @@ -0,0 +1,679 @@ +#!/usr/bin/env python3 +"""============================================================ +闲言APP — 文件传输助手 API 全流程验证脚本 +创建时间: 2026-05-15 +更新时间: 2026-05-15 +作用: 验证 API_FILE_TRANSFER_DOC.md 中所有接口的可用性、正确性和性能 +上次更新: 初始版本,覆盖全部REST API + WebSocket信令 +============================================================""" + +import json +import time +import hashlib +import uuid +import asyncio +import sys +import os +from datetime import datetime + +try: + import aiohttp +except ImportError: + print("[ERROR] 需要安装 aiohttp: pip install aiohttp") + sys.exit(1) + +try: + import websockets +except ImportError: + print("[ERROR] 需要安装 websockets: pip install websockets") + sys.exit(1) + +BASE_URL = "https://tools.wktyl.com/api/file_transfer/" +CLOUD_CACHE_URL = "https://tools.wktyl.com/api/cloud_cache/" +WS_URL = "wss://tools.wktyl.com:9443" +PYTHON = r"C:\Users\无书\AppData\Local\Python\pythoncore-3.14-64\python.exe" + +RESULTS = [] +DEVICE_ID_A = "test_" + hashlib.sha256(b"device_a_test").hexdigest()[:32] +DEVICE_ID_B = "test_" + hashlib.sha256(b"device_b_test").hexdigest()[:32] +FINGERPRINT_A = hashlib.sha256(b"fingerprint_a_test").hexdigest() + + +def log_result(category, name, success, status_code, latency_ms, detail=""): + RESULTS.append({ + "category": category, + "name": name, + "success": success, + "status_code": status_code, + "latency_ms": round(latency_ms, 1), + "detail": detail, + "timestamp": datetime.now().isoformat(), + }) + icon = "✅" if success else "❌" + print(f" {icon} [{category}] {name}: {status_code} ({latency_ms:.0f}ms) {detail}") + + +async def test_health(session): + print("\n🔍 [1/14] 健康检查") + t0 = time.monotonic() + try: + async with session.get(BASE_URL + "health", ssl=False) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + ok = body.get("code") == 1 and body.get("data", {}).get("status") == "healthy" + signaling = body.get("data", {}).get("signalingAlive", False) + log_result("核心", "健康检查", ok, r.status, lat, + f"status={body.get('data',{}).get('status')} signaling={signaling}") + return ok + except Exception as e: + log_result("核心", "健康检查", False, 0, (time.monotonic() - t0) * 1000, str(e)) + return False + + +async def test_signaling_info(session): + print("\n🔍 [2/14] 信令服务信息") + t0 = time.monotonic() + try: + async with session.get(BASE_URL + "signaling_info", ssl=False) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + data = body.get("data", {}) + ok = body.get("code") == 1 and data.get("signalingUrl") is not None + log_result("核心", "信令服务信息", ok, r.status, lat, + f"url={data.get('signalingUrl')} protocols={data.get('protocols')}") + return data + except Exception as e: + log_result("核心", "信令服务信息", False, 0, (time.monotonic() - t0) * 1000, str(e)) + return None + + +async def test_turn_credentials(session): + print("\n🔍 [3/14] 获取TURN凭据") + t0 = time.monotonic() + try: + async with session.post( + BASE_URL + "turn_credentials", + json={"fingerprint": FINGERPRINT_A}, + headers={"X-Device-Id": DEVICE_ID_A, "Content-Type": "application/json"}, + ssl=False, + ) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + data = body.get("data", {}) + ok = body.get("code") == 1 and data.get("username") is not None + log_result("核心", "TURN凭据", ok, r.status, lat, + f"user={data.get('username','?')[:20]} ttl={data.get('ttl')} urls={len(data.get('urls',[]))}") + return data + except Exception as e: + log_result("核心", "TURN凭据", False, 0, (time.monotonic() - t0) * 1000, str(e)) + return None + + +async def test_pair_request(session): + print("\n🔍 [4/14] 创建配对请求") + t0 = time.monotonic() + try: + async with session.post( + BASE_URL + "pair_request", + json={ + "fromId": DEVICE_ID_A, + "toId": DEVICE_ID_B, + "fingerprint": FINGERPRINT_A, + "alias": "测试设备A", + "deviceType": "mobile", + }, + ssl=False, + ) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + data = body.get("data", {}) + ok = body.get("code") == 1 and data.get("requestId") is not None + log_result("核心", "配对请求", ok, r.status, lat, + f"requestId={data.get('requestId','?')[:16]}") + return data.get("requestId") + except Exception as e: + log_result("核心", "配对请求", False, 0, (time.monotonic() - t0) * 1000, str(e)) + return None + + +async def test_pair_accept(session, request_id): + print("\n🔍 [5/14] 接受配对请求") + if not request_id: + log_result("核心", "接受配对", False, 0, 0, "跳过: 无requestId") + return + t0 = time.monotonic() + try: + async with session.post( + BASE_URL + "pair_accept", + json={"requestId": request_id, "deviceId": DEVICE_ID_B}, + ssl=False, + ) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + ok = body.get("code") == 1 and body.get("data", {}).get("paired") == True + log_result("核心", "接受配对", ok, r.status, lat, f"paired={body.get('data',{}).get('paired')}") + except Exception as e: + log_result("核心", "接受配对", False, 0, (time.monotonic() - t0) * 1000, str(e)) + + +async def test_pair_reject(session): + print("\n🔍 [6/14] 拒绝配对请求") + t0 = time.monotonic() + try: + async with session.post( + BASE_URL + "pair_reject", + json={"requestId": "fake_request_id_for_test"}, + ssl=False, + ) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + ok = body.get("code") == 1 or body.get("msg", "").find("过期") >= 0 + log_result("核心", "拒绝配对", ok, r.status, lat, f"msg={body.get('msg')}") + except Exception as e: + log_result("核心", "拒绝配对", False, 0, (time.monotonic() - t0) * 1000, str(e)) + + +async def test_paired_devices(session): + print("\n🔍 [7/14] 获取已配对设备列表") + t0 = time.monotonic() + try: + async with session.get( + BASE_URL + f"paired_devices?deviceId={DEVICE_ID_A}", ssl=False + ) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + devices = body.get("data", {}).get("devices", []) + ok = body.get("code") == 1 + log_result("核心", "已配对设备", ok, r.status, lat, f"count={len(devices)}") + except Exception as e: + log_result("核心", "已配对设备", False, 0, (time.monotonic() - t0) * 1000, str(e)) + + +async def test_pair_delete(session): + print("\n🔍 [8/14] 删除配对") + t0 = time.monotonic() + try: + async with session.post( + BASE_URL + "pair_delete", + json={"deviceId": DEVICE_ID_A, "peerDeviceId": DEVICE_ID_B}, + ssl=False, + ) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + ok = body.get("code") == 1 + log_result("核心", "删除配对", ok, r.status, lat, f"msg={body.get('msg')}") + except Exception as e: + log_result("核心", "删除配对", False, 0, (time.monotonic() - t0) * 1000, str(e)) + + +async def test_localsend_info(session): + print("\n🔍 [9/14] LocalSend兼容端点") + t0 = time.monotonic() + try: + async with session.get(BASE_URL + "localsend_info", ssl=False) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + data = body.get("data", {}) + ok = body.get("code") == 1 and data.get("version") == "2.1" + log_result("核心", "LocalSend兼容", ok, r.status, lat, + f"alias={data.get('alias')} ver={data.get('version')} port={data.get('port')}") + except Exception as e: + log_result("核心", "LocalSend兼容", False, 0, (time.monotonic() - t0) * 1000, str(e)) + + +async def test_create_room(session): + print("\n🔍 [10/14] 创建房间") + t0 = time.monotonic() + try: + async with session.post( + BASE_URL + "create_room", + json={"deviceId": DEVICE_ID_A, "alias": "测试设备A", "deviceType": "mobile"}, + ssl=False, + ) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + data = body.get("data", {}) + ok = body.get("code") == 1 and data.get("code") is not None + log_result("核心", "创建房间", ok, r.status, lat, + f"code={data.get('code')} expires={data.get('expiresAt')}") + return data.get("code") + except Exception as e: + log_result("核心", "创建房间", False, 0, (time.monotonic() - t0) * 1000, str(e)) + return None + + +async def test_room_status(session, room_code): + print("\n🔍 [11/14] 查询房间状态") + t0 = time.monotonic() + try: + async with session.get( + BASE_URL + f"room_status?code={room_code}", ssl=False + ) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + data = body.get("data", {}) + ok = body.get("code") == 1 and data.get("exists") == True + log_result("核心", "房间状态", ok, r.status, lat, + f"exists={data.get('exists')} status={data.get('status')} sender={data.get('senderOnline')}") + except Exception as e: + log_result("核心", "房间状态", False, 0, (time.monotonic() - t0) * 1000, str(e)) + + +async def test_join_room(session, room_code): + print("\n🔍 [12/14] 加入房间") + t0 = time.monotonic() + try: + async with session.post( + BASE_URL + "join_room", + json={"code": room_code, "role": "receiver", "deviceId": DEVICE_ID_B}, + ssl=False, + ) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + data = body.get("data", {}) + ok = body.get("code") == 1 and data.get("joined") == True + log_result("核心", "加入房间", ok, r.status, lat, + f"joined={data.get('joined')} role={data.get('role')}") + except Exception as e: + log_result("核心", "加入房间", False, 0, (time.monotonic() - t0) * 1000, str(e)) + + +async def test_install(session): + print("\n🔍 [13/14] 数据库安装") + t0 = time.monotonic() + try: + async with session.post(BASE_URL + "install", ssl=False) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + data = body.get("data", {}) + ok = body.get("code") == 1 + log_result("核心", "数据库安装", ok, r.status, lat, + f"tables={data.get('tables')} results={data.get('results')}") + except Exception as e: + log_result("核心", "数据库安装", False, 0, (time.monotonic() - t0) * 1000, str(e)) + + +async def test_web_page(session): + print("\n🔍 [14/14] Web接收页面") + t0 = time.monotonic() + try: + async with session.get("https://tools.wktyl.com/transfer.html", ssl=False) as r: + lat = (time.monotonic() - t0) * 1000 + ok = r.status == 200 + log_result("核心", "Web接收页面", ok, r.status, lat, "") + except Exception as e: + log_result("核心", "Web接收页面", False, 0, (time.monotonic() - t0) * 1000, str(e)) + + +async def test_websocket_signaling(): + print("\n🔍 [WebSocket] 信令连接 + 注册 + 心跳 + 发现") + t0 = time.monotonic() + try: + async with websockets.connect(WS_URL, ssl=True) as ws: + lat_connect = (time.monotonic() - t0) * 1000 + log_result("信令", "WS连接", True, 101, lat_connect, f"connected to {WS_URL}") + + reg_msg = json.dumps({ + "type": "register", + "data": { + "deviceId": DEVICE_ID_A, + "alias": "验证脚本A", + "deviceModel": "TestScript", + "deviceType": "desktop", + "fingerprint": FINGERPRINT_A, + "protocol": "xianyan-v1", + }, + }) + t0 = time.monotonic() + await ws.send(reg_msg) + reg_responses = [] + for _ in range(5): + try: + resp = await asyncio.wait_for(ws.recv(), timeout=5) + reg_responses.append(json.loads(resp)) + except asyncio.TimeoutError: + break + lat = (time.monotonic() - t0) * 1000 + registered_ok = any(r.get("type") == "registered" for r in reg_responses) + peers_ok = any(r.get("type") == "peers" for r in reg_responses) + ok = registered_ok or peers_ok + types = [r.get("type") for r in reg_responses] + log_result("信令", "设备注册", ok, 200, lat, + f"responses={types} registered={registered_ok} peers={peers_ok}") + + hb_msg = json.dumps({"type": "heartbeat", "data": {"timestamp": int(time.time() * 1000)}}) + t0 = time.monotonic() + await ws.send(hb_msg) + hb_responses = [] + for _ in range(3): + try: + resp = await asyncio.wait_for(ws.recv(), timeout=5) + hb_responses.append(json.loads(resp)) + except asyncio.TimeoutError: + break + lat = (time.monotonic() - t0) * 1000 + ack_ok = any(r.get("type") == "heartbeat_ack" for r in hb_responses) + ok = ack_ok + types = [r.get("type") for r in hb_responses] + log_result("信令", "心跳", ok, 200, lat, f"responses={types}") + + disc_msg = json.dumps({"type": "discover", "data": {}}) + t0 = time.monotonic() + await ws.send(disc_msg) + disc_responses = [] + for _ in range(3): + try: + resp = await asyncio.wait_for(ws.recv(), timeout=5) + disc_responses.append(json.loads(resp)) + except asyncio.TimeoutError: + break + lat = (time.monotonic() - t0) * 1000 + disc_ok = any(r.get("type") == "discover_response" for r in disc_responses) + ok = disc_ok + devices = [] + for r in disc_responses: + if r.get("type") == "discover_response": + devices = r.get("data", {}).get("devices", []) + log_result("信令", "发现设备", ok, 200, lat, f"found={len(devices)}") + + disc_my = json.dumps({"type": "discoverMyDevices", "data": {"userId": "test-user-uuid"}}) + t0 = time.monotonic() + await ws.send(disc_my) + my_responses = [] + for _ in range(3): + try: + resp = await asyncio.wait_for(ws.recv(), timeout=5) + my_responses.append(json.loads(resp)) + except asyncio.TimeoutError: + break + lat = (time.monotonic() - t0) * 1000 + my_ok = any(r.get("type") == "myDevicesResponse" for r in my_responses) + ok = my_ok + my_devices = [] + for r in my_responses: + if r.get("type") == "myDevicesResponse": + my_devices = r.get("data", {}).get("devices", []) + log_result("信令", "同账号设备", ok, 200, lat, f"count={len(my_devices)}") + + ping_msg = json.dumps({"type": "ping"}) + t0 = time.monotonic() + await ws.send(ping_msg) + ping_responses = [] + for _ in range(3): + try: + resp = await asyncio.wait_for(ws.recv(), timeout=5) + ping_responses.append(json.loads(resp)) + except asyncio.TimeoutError: + break + lat = (time.monotonic() - t0) * 1000 + pong_ok = any(r.get("type") == "pong" for r in ping_responses) + ok = pong_ok + log_result("信令", "Ping/Pong", ok, 200, lat, f"responses={[r.get('type') for r in ping_responses]}") + + except Exception as e: + log_result("信令", "WebSocket", False, 0, (time.monotonic() - t0) * 1000, str(e)) + + +async def test_cloud_cache_install(session): + print("\n🔍 [CloudCache 1/7] 数据库安装") + t0 = time.monotonic() + try: + async with session.post(CLOUD_CACHE_URL + "install", ssl=False) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + ok = body.get("code") == 1 + log_result("云暂存", "CC数据库安装", ok, r.status, lat, f"msg={body.get('msg')}") + except Exception as e: + log_result("云暂存", "CC数据库安装", False, 0, (time.monotonic() - t0) * 1000, str(e)) + + +async def test_cloud_cache_upload(session): + print("\n🔍 [CloudCache 2/7] 上传暂存文件") + t0 = time.monotonic() + try: + data = aiohttp.FormData() + test_content = b"test file content for api verification - " + str(uuid.uuid4()).encode() + data.add_field("file", test_content, filename="test_verify.txt", content_type="text/plain") + data.add_field("fromId", DEVICE_ID_A) + data.add_field("toId", DEVICE_ID_B) + data.add_field("fileName", "test_verify.txt") + data.add_field("fileSize", str(len(test_content))) + data.add_field("mimeType", "text/plain") + data.add_field("expireHours", "1") + + async with session.post(CLOUD_CACHE_URL + "upload", data=data, ssl=False) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + d = body.get("data", {}) + ok = body.get("code") == 1 and d.get("cacheId") is not None + log_result("云暂存", "上传文件", ok, r.status, lat, + f"cacheId={d.get('cacheId','?')[:30]} size={len(test_content)}B") + return d.get("cacheId") + except Exception as e: + log_result("云暂存", "上传文件", False, 0, (time.monotonic() - t0) * 1000, str(e)) + return None + + +async def test_cloud_cache_list(session): + print("\n🔍 [CloudCache 3/7] 查询暂存列表") + t0 = time.monotonic() + try: + async with session.get( + CLOUD_CACHE_URL + f"list?userId={DEVICE_ID_A}", ssl=False + ) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + items = body.get("data", []) + ok = body.get("code") == 1 + log_result("云暂存", "暂存列表", ok, r.status, lat, f"count={len(items) if isinstance(items, list) else '?'}") + except Exception as e: + log_result("云暂存", "暂存列表", False, 0, (time.monotonic() - t0) * 1000, str(e)) + + +async def test_cloud_cache_info(session, cache_id): + print("\n🔍 [CloudCache 4/7] 查询单个暂存信息") + if not cache_id: + log_result("云暂存", "暂存详情", False, 0, 0, "跳过: 无cacheId") + return + t0 = time.monotonic() + try: + async with session.get( + CLOUD_CACHE_URL + f"info?cacheId={cache_id}", ssl=False + ) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + d = body.get("data", {}) + ok = body.get("code") == 1 and d.get("cacheId") is not None + log_result("云暂存", "暂存详情", ok, r.status, lat, + f"file={d.get('fileName')} size={d.get('fileSize')} expired={d.get('isExpired')}") + except Exception as e: + log_result("云暂存", "暂存详情", False, 0, (time.monotonic() - t0) * 1000, str(e)) + + +async def test_cloud_cache_download(session, cache_id): + print("\n🔍 [CloudCache 5/7] 下载暂存文件") + if not cache_id: + log_result("云暂存", "下载文件", False, 0, 0, "跳过: 无cacheId") + return + t0 = time.monotonic() + try: + async with session.get( + CLOUD_CACHE_URL + f"download?cacheId={cache_id}&deviceId={DEVICE_ID_B}", ssl=False + ) as r: + content = await r.read() + lat = (time.monotonic() - t0) * 1000 + ok = r.status == 200 and len(content) > 0 + log_result("云暂存", "下载文件", ok, r.status, lat, f"size={len(content)}B") + except Exception as e: + log_result("云暂存", "下载文件", False, 0, (time.monotonic() - t0) * 1000, str(e)) + + +async def test_cloud_cache_notify(session, cache_id): + print("\n🔍 [CloudCache 6/7] 发送通知") + if not cache_id: + log_result("云暂存", "发送通知", False, 0, 0, "跳过: 无cacheId") + return + t0 = time.monotonic() + try: + async with session.post( + CLOUD_CACHE_URL + "notify", + json={"cacheId": cache_id, "toId": DEVICE_ID_B, "fromId": DEVICE_ID_A, "fileName": "test_verify.txt"}, + ssl=False, + ) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + ok = body.get("code") == 1 + log_result("云暂存", "发送通知", ok, r.status, lat, f"notified={body.get('data',{}).get('notified')}") + except Exception as e: + log_result("云暂存", "发送通知", False, 0, (time.monotonic() - t0) * 1000, str(e)) + + +async def test_cloud_cache_delete(session, cache_id): + print("\n🔍 [CloudCache 7/7] 删除暂存") + if not cache_id: + log_result("云暂存", "删除暂存", False, 0, 0, "跳过: 无cacheId") + return + t0 = time.monotonic() + try: + async with session.request( + "DELETE", CLOUD_CACHE_URL + "delete", + json={"cacheId": cache_id, "deviceId": DEVICE_ID_A}, + ssl=False, + ) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + ok = body.get("code") == 1 + log_result("云暂存", "删除暂存", ok, r.status, lat, f"msg={body.get('msg')}") + except Exception as e: + log_result("云暂存", "删除暂存", False, 0, (time.monotonic() - t0) * 1000, str(e)) + + +async def test_cloud_cache_clean(session): + print("\n🔍 [CloudCache 额外] 清理过期文件") + t0 = time.monotonic() + try: + async with session.post(CLOUD_CACHE_URL + "clean", ssl=False) as r: + body = await r.json() + lat = (time.monotonic() - t0) * 1000 + ok = body.get("code") == 1 + log_result("云暂存", "清理过期", ok, r.status, lat, f"cleaned={body.get('data',{}).get('cleaned')}") + except Exception as e: + log_result("云暂存", "清理过期", False, 0, (time.monotonic() - t0) * 1000, str(e)) + + +def print_summary(): + print("\n" + "=" * 80) + print("📊 验证结果汇总") + print("=" * 80) + + categories = {} + for r in RESULTS: + cat = r["category"] + if cat not in categories: + categories[cat] = {"pass": 0, "fail": 0, "total": 0, "latencies": []} + categories[cat]["total"] += 1 + if r["success"]: + categories[cat]["pass"] += 1 + else: + categories[cat]["fail"] += 1 + categories[cat]["latencies"].append(r["latency_ms"]) + + total_pass = sum(c["pass"] for c in categories.values()) + total_fail = sum(c["fail"] for c in categories.values()) + total = total_pass + total_fail + + for cat, stats in categories.items(): + avg_lat = sum(stats["latencies"]) / len(stats["latencies"]) if stats["latencies"] else 0 + max_lat = max(stats["latencies"]) if stats["latencies"] else 0 + min_lat = min(stats["latencies"]) if stats["latencies"] else 0 + print(f"\n 📁 {cat}: {stats['pass']}/{stats['total']} 通过") + print(f" 延迟: avg={avg_lat:.0f}ms min={min_lat:.0f}ms max={max_lat:.0f}ms") + + print(f"\n 🏁 总计: {total_pass}/{total} 通过 ({total_fail} 失败)") + print(f" 📈 通过率: {total_pass/total*100:.1f}%") + + if total_fail > 0: + print(f"\n ⚠️ 失败项:") + for r in RESULTS: + if not r["success"]: + print(f" ❌ [{r['category']}] {r['name']}: {r['detail']}") + + slow = [r for r in RESULTS if r["latency_ms"] > 2000] + if slow: + print(f"\n 🐌 慢接口 (>2s):") + for r in sorted(slow, key=lambda x: -x["latency_ms"]): + print(f" {r['name']}: {r['latency_ms']:.0f}ms") + + report_path = os.path.join(os.path.dirname(__file__), "api_verify_report.json") + with open(report_path, "w", encoding="utf-8") as f: + json.dump({ + "summary": { + "total": total, + "pass": total_pass, + "fail": total_fail, + "rate": f"{total_pass/total*100:.1f}%", + "timestamp": datetime.now().isoformat(), + }, + "results": RESULTS, + }, f, ensure_ascii=False, indent=2) + print(f"\n 📄 详细报告: {report_path}") + + +async def main(): + print("=" * 80) + print("🚀 闲言APP — 文件传输助手 API 全流程验证") + print(f" 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f" 基础URL: {BASE_URL}") + print(f" 云暂存URL: {CLOUD_CACHE_URL}") + print(f" WebSocket: {WS_URL}") + print("=" * 80) + + connector = aiohttp.TCPConnector(ssl=False, limit=10) + timeout = aiohttp.ClientTimeout(total=30) + async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session: + print("\n" + "─" * 40) + print("📦 Part 1: 文件传输核心 API") + print("─" * 40) + + await test_health(session) + sig_info = await test_signaling_info(session) + await test_turn_credentials(session) + + request_id = await test_pair_request(session) + await test_pair_accept(session, request_id) + await test_pair_reject(session) + await test_paired_devices(session) + + room_code = await test_create_room(session) + await test_room_status(session, room_code or "FAKE00") + await test_join_room(session, room_code or "FAKE00") + + await test_pair_delete(session) + await test_localsend_info(session) + await test_install(session) + await test_web_page(session) + + print("\n" + "─" * 40) + print("📡 Part 2: WebSocket 信令协议") + print("─" * 40) + + await test_websocket_signaling() + + print("\n" + "─" * 40) + print("☁️ Part 3: 云端暂存 CloudCache API") + print("─" * 40) + + await test_cloud_cache_install(session) + cache_id = await test_cloud_cache_upload(session) + await test_cloud_cache_list(session) + await test_cloud_cache_info(session, cache_id) + await test_cloud_cache_download(session, cache_id) + await test_cloud_cache_notify(session, cache_id) + await test_cloud_cache_delete(session, cache_id) + await test_cloud_cache_clean(session) + + print_summary() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/toolsapi/public/assets/js/backend/badge.js b/docs/toolsapi/public/assets/js/backend/badge.js new file mode 100644 index 00000000..28df33ae --- /dev/null +++ b/docs/toolsapi/public/assets/js/backend/badge.js @@ -0,0 +1,86 @@ +define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) { + + var Controller = { + index: function () { + Table.api.init({ + extend: { + index_url: 'badge/index' + location.search, + add_url: 'badge/add', + edit_url: 'badge/edit', + del_url: 'badge/del', + multi_url: 'badge/multi', + import_url: 'badge/import', + table: 'badge', + } + }); + + var table = $("#table"); + + table.bootstrapTable({ + url: $.fn.bootstrapTable.defaults.extend.index_url, + pk: 'id', + sortName: 'weigh', + sortOrder: 'asc', + columns: [ + [ + {checkbox: true}, + {field: 'id', title: 'ID', sortable: true}, + {field: 'name', title: __('Name'), operate: 'LIKE', + formatter: function(val, row) { + return (row.icon || '') + ' ' + val; + } + }, + {field: 'icon', title: __('Icon'), operate: false}, + {field: 'rarity', title: __('Rarity'), searchList: {common: '普通', rare: '稀有', epic: '史诗', legendary: '传说'}, + formatter: function(val) { + var colorMap = {common: '#999', rare: '#3498db', epic: '#9b59b6', legendary: '#f39c12'}; + var nameMap = {common: '普通', rare: '稀有', epic: '史诗', legendary: '传说'}; + return '' + (nameMap[val] || val) + ''; + } + }, + {field: 'type', title: __('Type'), searchList: {signin: '签到', article: '文章', favorite: '收藏', note: '笔记', interact: '互动', game: '游戏', search: '搜索', checkin: '打卡', level: '等级', rank: '排行', task: '任务', special: '特殊'}, + formatter: function(val) { + var nameMap = {signin: '签到', article: '文章', favorite: '收藏', note: '笔记', interact: '互动', game: '游戏', search: '搜索', checkin: '打卡', level: '等级', rank: '排行', task: '任务', special: '特殊'}; + return nameMap[val] || val; + } + }, + {field: 'condition_type', title: __('Condition_type'), searchList: {count: '累计次数', reach: '达到目标', action: '执行动作'}, + formatter: function(val) { + var nameMap = {count: '累计次数', reach: '达到目标', action: '执行动作'}; + return nameMap[val] || val; + } + }, + {field: 'condition_value', title: __('Condition_value'), sortable: true}, + {field: 'exp_reward', title: __('Exp_reward'), sortable: true, + formatter: function(val) { + return '' + val + ' EXP'; + } + }, + {field: 'score_reward', title: __('Score_reward'), sortable: true, + formatter: function(val) { + return '' + val + ' 积分'; + } + }, + {field: 'weigh', title: __('Weigh'), sortable: true}, + {field: 'status', title: __('Status'), formatter: Table.api.formatter.status, searchList: {normal: '正常', hidden: '隐藏'}}, + {field: 'operate', title: __('Operate'), table: table, events: Table.api.events.operate, formatter: Table.api.formatter.operate} + ] + ] + }); + + 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/daily_task.js b/docs/toolsapi/public/assets/js/backend/daily_task.js new file mode 100644 index 00000000..51b1433d --- /dev/null +++ b/docs/toolsapi/public/assets/js/backend/daily_task.js @@ -0,0 +1,79 @@ +define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) { + + var Controller = { + index: function () { + Table.api.init({ + extend: { + index_url: 'daily_task/index' + location.search, + add_url: 'daily_task/add', + edit_url: 'daily_task/edit', + del_url: 'daily_task/del', + multi_url: 'daily_task/multi', + import_url: 'daily_task/import', + table: 'daily_task', + } + }); + + var table = $("#table"); + + table.bootstrapTable({ + url: $.fn.bootstrapTable.defaults.extend.index_url, + pk: 'id', + sortName: 'weigh', + sortOrder: 'desc', + columns: [ + [ + {checkbox: true}, + {field: 'id', title: 'ID', sortable: true}, + {field: 'name', title: __('Name'), operate: 'LIKE', + formatter: function(val, row) { + return (row.icon || '') + ' ' + val; + } + }, + {field: 'icon', title: __('Icon'), operate: false}, + {field: 'type', title: __('Type'), searchList: {signin: '签到', read: '阅读', favorite: '收藏', interact: '互动', checkin: '打卡', custom: '自定义'}, + formatter: function(val) { + var nameMap = {signin: '签到', read: '阅读', favorite: '收藏', interact: '互动', checkin: '打卡', custom: '自定义'}; + return nameMap[val] || val; + } + }, + {field: 'target', title: __('Target'), sortable: true}, + {field: 'action', title: __('Action'), operate: 'LIKE'}, + {field: 'exp_reward', title: __('Exp_reward'), + formatter: function(val) { + return '' + val + ' EXP'; + } + }, + {field: 'score_reward', title: __('Score_reward'), + formatter: function(val) { + return '' + val + ' 积分'; + } + }, + {field: 'is_random', title: __('Is_random'), searchList: {0: '固定', 1: '随机'}, + formatter: function(val) { + return val == 1 ? '随机' : '固定'; + } + }, + {field: 'weigh', title: __('Weigh'), sortable: true}, + {field: 'status', title: __('Status'), formatter: Table.api.formatter.status, searchList: {normal: '正常', hidden: '隐藏'}}, + {field: 'operate', title: __('Operate'), table: table, events: Table.api.events.operate, formatter: Table.api.formatter.operate} + ] + ] + }); + + 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 new file mode 100644 index 00000000..faca4d9d --- /dev/null +++ b/docs/toolsapi/public/assets/js/backend/feed_weight.js @@ -0,0 +1,102 @@ +/** + * @name 信息流推荐权重管理 + * @author AI Coder + * @date 2026-05-14 + * @desc 后台权重配置列表、编辑、重置推送、恢复默认 + * @update 初始创建 + */ +define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) { + + var Controller = { + index: function () { + Table.api.init({ + extend: { + index_url: 'feed_weight/index' + location.search, + edit_url: 'feed_weight/edit', + table: 'feed_weight', + } + }); + + var table = $("#table"); + + table.bootstrapTable({ + url: $.fn.bootstrapTable.defaults.extend.index_url, + pk: 'id', + sortName: 'weight', + sortOrder: 'desc', + columns: [ + [ + {checkbox: true}, + {field: 'id', title: 'ID', sortable: true}, + {field: 'feed_type', title: __('Feed_type'), operate: 'LIKE', + 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; + } + }, + {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 + '
'; + } + }, + {field: 'display_weight', title: __('Display_weight'), sortable: true}, + {field: 'push_limit', title: __('Push_limit'), + formatter: function(val) { + return val > 0 ? val + ' 条/天' : '不限制'; + } + }, + {field: 'is_enabled', title: __('Is_enabled'), searchList: {0: '禁用', 1: '启用'}, + formatter: function(val) { + return val == 1 ? '启用' : '禁用'; + } + }, + {field: 'operate', title: __('Operate'), table: table, events: Table.api.events.operate, formatter: Table.api.formatter.operate} + ] + ] + }); + + $(document).on('click', '.btn-reset-push', function() { + var ids = Table.api.selectedids(table); + if (!ids.length) { Toastr.warning('请选择要重置的行'); return; } + $.ajax({ + url: 'feed_weight/reset_push', + data: {ids: ids.join(',')}, + type: 'post', + dataType: 'json', + success: function(ret) { + if (ret.code === 1) { Toastr.success(ret.msg); table.bootstrapTable('refresh'); } + else { Toastr.error(ret.msg); } + } + }); + }); + + $(document).on('click', '.btn-reset-defaults', function() { + Layer.confirm('确认恢复所有权重为默认值?', function(index) { + $.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); + } + }); + }); + }); + + Table.api.bindevent(table); + }, + 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/rank_season.js b/docs/toolsapi/public/assets/js/backend/rank_season.js new file mode 100644 index 00000000..5bf7e5bb --- /dev/null +++ b/docs/toolsapi/public/assets/js/backend/rank_season.js @@ -0,0 +1,80 @@ +define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) { + + var Controller = { + index: function () { + Table.api.init({ + extend: { + index_url: 'rank_season/index' + location.search, + add_url: 'rank_season/add', + edit_url: 'rank_season/edit', + del_url: 'rank_season/del', + settle_url: 'rank_season/settle', + table: 'rank_season', + } + }); + + var table = $("#table"); + + table.bootstrapTable({ + url: $.fn.bootstrapTable.defaults.extend.index_url, + pk: 'id', + sortName: 'start_time', + sortOrder: 'desc', + columns: [ + [ + {checkbox: true}, + {field: 'id', title: __('ID'), sortable: true}, + {field: 'name', title: __('Name'), operate: 'LIKE'}, + {field: 'type', title: __('Type'), searchList: {weekly: '周赛', monthly: '月赛'}, + formatter: function(val) { + return val === 'weekly' ? '周赛' : '月赛'; + } + }, + {field: 'start_time', title: __('Start_time'), operate: 'RANGE', addclass: 'datetimerange', formatter: Table.api.formatter.datetime, sortable: true}, + {field: 'end_time', title: __('End_time'), operate: 'RANGE', addclass: 'datetimerange', formatter: Table.api.formatter.datetime, sortable: true}, + {field: 'status', title: __('Status'), searchList: {pending: '待开始', active: '进行中', settled: '已结算'}, + formatter: function(val) { + var map = {pending: 'label-default', active: 'label-success', settled: 'label-warning'}; + var nameMap = {pending: '待开始', active: '进行中', settled: '已结算'}; + return '' + (nameMap[val]||val) + ''; + } + }, + {field: 'operate', title: __('Operate'), table: table, events: Table.api.events.operate, formatter: Table.api.formatter.operate} + ] + ] + }); + + $(document).on('click', '.btn-settle', function() { + var ids = Table.api.selectedids(table); + if (!ids.length) { Toastr.warning('请选择要结算的赛季'); return; } + Layer.confirm('确认结算选中的赛季?结算后将生成排名快照', function(index) { + $.ajax({ + url: 'rank_season/settle', + data: {ids: ids.join(',')}, + 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); + } + }); + }); + }); + + 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/user/rank_record.js b/docs/toolsapi/public/assets/js/backend/user/rank_record.js new file mode 100644 index 00000000..dd5293c1 --- /dev/null +++ b/docs/toolsapi/public/assets/js/backend/user/rank_record.js @@ -0,0 +1,48 @@ +define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) { + + var Controller = { + index: function () { + Table.api.init({ + extend: { + index_url: 'user/rank_record/index' + location.search, + table: 'rank_record', + } + }); + + var table = $("#table"); + + table.bootstrapTable({ + url: $.fn.bootstrapTable.defaults.extend.index_url, + pk: 'id', + sortName: 'rank', + sortOrder: 'asc', + columns: [ + [ + {checkbox: true}, + {field: 'id', title: __('ID'), sortable: true}, + {field: 'season_id', title: __('Season_id'), operate: '='}, + {field: 'season.name', title: __('Season'), operate: false}, + {field: 'user_id', title: __('User_id'), operate: '='}, + {field: 'user.nickname', title: __('User'), operate: false}, + {field: 'rank_type', title: __('Rank_type'), searchList: {exp: '经验榜', signin: '签到榜', badge: '勋章榜', score: '积分榜'}, + formatter: function(val) { + var map = {exp: '经验榜', signin: '签到榜', badge: '勋章榜', score: '积分榜'}; + return map[val] || val; + } + }, + {field: 'rank', title: __('Rank'), sortable: true}, + {field: 'value', title: __('Value'), sortable: true}, + {field: 'reward_claimed', title: __('Claimed'), searchList: {0: '未领取', 1: '已领取'}, + formatter: function(val) { + return val == 1 ? '已领取' : '未领取'; + } + }, + ] + ] + }); + + Table.api.bindevent(table); + }, + }; + return Controller; +}); diff --git a/docs/toolsapi/public/assets/js/backend/user/user_badge.js b/docs/toolsapi/public/assets/js/backend/user/user_badge.js new file mode 100644 index 00000000..fd415e65 --- /dev/null +++ b/docs/toolsapi/public/assets/js/backend/user/user_badge.js @@ -0,0 +1,58 @@ +define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) { + + var Controller = { + index: function () { + Table.api.init({ + extend: { + index_url: 'user/user_badge/index', + add_url: 'user/user_badge/add', + del_url: 'user/user_badge/del', + multi_url: 'user/user_badge/multi', + table: 'user_badge', + } + }); + + 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}, + {field: 'user.nickname', title: '用户'}, + {field: 'badge.name', title: '勋章名称', operate: 'LIKE'}, + {field: 'badge.icon', title: '勋章图标', + formatter: function(val) { + return val ? '' : '-'; + } + }, + {field: 'is_displayed', title: '是否展示', searchList: {0: '否', 1: '是'}, + formatter: function(val) { + return val == 1 + ? '' + : ''; + } + }, + {field: 'unlocked_at', title: '解锁时间', formatter: Table.api.formatter.datetime, operate: 'RANGE', addclass: 'datetimerange', sortable: true}, + {field: 'operate', title: '操作', table: table, events: Table.api.events.operate, formatter: Table.api.formatter.operate} + ] + ] + }); + + Table.api.bindevent(table); + }, + add: function () { + Controller.api.bindevent(); + }, + api: { + bindevent: function () { + Form.api.bindevent($("form[role=form]")); + } + } + }; + return Controller; +}); diff --git a/docs/toolsapi/public/assets/js/backend/user/user_exp_log.js b/docs/toolsapi/public/assets/js/backend/user/user_exp_log.js new file mode 100644 index 00000000..bbf629cb --- /dev/null +++ b/docs/toolsapi/public/assets/js/backend/user/user_exp_log.js @@ -0,0 +1,48 @@ +define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) { + + var Controller = { + index: function () { + Table.api.init({ + extend: { + index_url: 'user/user_exp_log/index' + location.search, + table: 'user_exp_log', + } + }); + + 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}, + {field: 'user_id', title: __('User_id'), operate: '='}, + {field: 'user.nickname', title: __('User'), operate: false}, + {field: 'action', title: __('Action'), searchList: {signin: '签到', task: '任务', achievement: '成就', badge: '勋章', perfect_day: '完美日', rank: '排行', admin: '管理员'}, + formatter: function(val) { + var map = {signin: '签到', task: '任务', achievement: '成就', badge: '勋章', perfect_day: '完美日', rank: '排行', admin: '管理员'}; + return map[val] || val; + } + }, + {field: 'amount', title: __('Amount'), sortable: true, + formatter: function(val) { + return val > 0 ? '+' + val + '' : '' + val + ''; + } + }, + {field: 'before_val', title: __('Before')}, + {field: 'after_val', title: __('After')}, + {field: 'remark', title: __('Remark'), operate: 'LIKE'}, + {field: 'createtime', title: __('Createtime'), operate: 'RANGE', addclass: 'datetimerange', formatter: Table.api.formatter.datetime, sortable: true}, + ] + ] + }); + + Table.api.bindevent(table); + }, + }; + return Controller; +}); diff --git a/docs/toolsapi/public/assets/js/backend/user/user_task.js b/docs/toolsapi/public/assets/js/backend/user/user_task.js new file mode 100644 index 00000000..f4cf242f --- /dev/null +++ b/docs/toolsapi/public/assets/js/backend/user/user_task.js @@ -0,0 +1,57 @@ +define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) { + + var Controller = { + index: function () { + Table.api.init({ + extend: { + index_url: 'user/user_task/index' + location.search, + table: 'user_task', + } + }); + + 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}, + {field: 'user_id', title: __('User_id'), operate: '='}, + {field: 'user.nickname', title: __('User'), operate: false}, + {field: 'task_id', title: __('Task_id'), operate: '='}, + {field: 'task.name', title: __('Task_name'), operate: false, + formatter: function(val, row) { + return (row['task.icon'] || '') + ' ' + (val || ''); + } + }, + {field: 'date', title: __('Date'), operate: 'LIKE'}, + {field: 'progress', title: __('Progress'), sortable: true}, + {field: 'completed', title: __('Completed'), searchList: {0: '未完成', 1: '已完成'}, + formatter: function(val) { + return val == 1 ? '已完成' : '未完成'; + } + }, + {field: 'claimed', title: __('Claimed'), searchList: {0: '未领取', 1: '已领取'}, + formatter: function(val) { + return val == 1 ? '已领取' : '未领取'; + } + }, + {field: 'createtime', title: __('Createtime'), operate: 'RANGE', addclass: 'datetimerange', formatter: Table.api.formatter.datetime, sortable: true}, + ] + ] + }); + + Table.api.bindevent(table); + }, + api: { + bindevent: function () { + Form.api.bindevent($("form[role=form]")); + } + } + }; + return Controller; +}); diff --git a/docs/toolsapi/public/assets/js/backend/userdeletion.js b/docs/toolsapi/public/assets/js/backend/userdeletion.js new file mode 100644 index 00000000..adf05944 --- /dev/null +++ b/docs/toolsapi/public/assets/js/backend/userdeletion.js @@ -0,0 +1,135 @@ +define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'layer'], function ($, undefined, Backend, Table, Form, Layer) { + + var Controller = { + index: function () { + Controller.loadList(); + Controller.loadStats(); + + $('#statusFilter').on('change', function () { Controller.loadList(); }); + $('#btnRefresh').on('click', function () { Controller.loadList(); Controller.loadStats(); }); + $('#btnProcessAuto').on('click', function () { Controller.processAuto(); }); + }, + + loadStats: function () { + $.ajax({ + url: 'userdeletion/index', + data: {sort: 'createtime', order: 'desc', offset: 0, limit: 1000, filter: '{}', op: '{}'}, + type: 'get', + dataType: 'json', + success: function (res) { + var rows = res.rows || []; + var pending = rows.filter(function (r) { return r.status == 0; }).length; + var approved = rows.filter(function (r) { return r.status == 1; }).length; + var rejected = rows.filter(function (r) { return r.status == 2; }).length; + var auto = rows.filter(function (r) { return r.status == 3; }).length; + $('#statsArea').html( + '
' + pending + '
⏳ 待审核
' + + '
' + approved + '
✅ 已通过
' + + '
' + rejected + '
❌ 已拒绝
' + + '
' + auto + '
🔄 自动注销
' + ); + } + }); + }, + + loadList: function () { + var status = $('#statusFilter').val(); + var filter = status >= 0 ? JSON.stringify({status: status}) : '{}'; + $.ajax({ + url: 'userdeletion/index', + data: {sort: 'createtime', order: 'desc', offset: 0, limit: 50, filter: filter, op: '{}'}, + type: 'get', + dataType: 'json', + success: function (res) { + var rows = res.rows || []; + var tbody = $('#listBody'); + if (!rows.length) { + tbody.html('暂无数据'); + return; + } + var html = ''; + rows.forEach(function (item) { + var badgeClass = ['badge-pending', 'badge-approved', 'badge-rejected', 'badge-auto'][item.status] || 'badge-pending'; + var actions = ''; + if (item.status == 0) { + actions = ' ' + + ''; + } + html += '' + + '' + item.id + '' + + '' + (item.username || 'ID:' + item.user_id) + '' + + '' + (item.reason || '-').substring(0, 30) + '' + + '' + item.status_text + '' + + '' + (item.countdown || '-') + '' + + '' + item.createtime_text + '' + + '' + actions + '' + + ''; + }); + tbody.html(html); + tbody.find('.btn-approve-action').on('click', function () { + Controller.doAction($(this).data('id'), 'approve'); + }); + tbody.find('.btn-reject-action').on('click', function () { + Controller.doAction($(this).data('id'), 'reject'); + }); + Controller.loadStats(); + } + }); + }, + + doAction: function (id, action) { + var title = action === 'approve' ? '✅ 确认通过注销申请?' : '❌ 确认拒绝注销申请?'; + Layer.confirm(title, function (index) { + Layer.close(index); + Controller.submitAction(id, action, ''); + }); + }, + + submitAction: function (id, action, remark) { + $.ajax({ + url: 'userdeletion/' + action, + data: {id: id, remark: remark}, + type: 'post', + dataType: 'json', + success: function (ret) { + if (ret.code === 1) { + Toastr.success(ret.msg || '操作成功'); + } else { + Toastr.error(ret.msg || '操作失败'); + } + Controller.loadList(); + Controller.loadStats(); + }, + error: function () { + Toastr.error('请求失败,请重试'); + } + }); + }, + + processAuto: function () { + Layer.confirm('确认处理所有超时的注销申请?', function (index) { + Layer.close(index); + $.ajax({ + url: 'userdeletion/process_auto', + data: {}, + type: 'post', + dataType: 'json', + success: function (ret) { + if (ret.code === 1) { + Toastr.success(ret.msg || '处理完成'); + } else { + Toastr.error(ret.msg || '处理失败'); + } + Controller.loadList(); + Controller.loadStats(); + }, + error: function () { + Toastr.error('请求失败,请重试'); + } + }); + }); + } + }; + + return Controller; +}); diff --git a/docs/toolsapi/scripts/check_db.py b/docs/toolsapi/scripts/check_db.py new file mode 100644 index 00000000..b3a14ec1 --- /dev/null +++ b/docs/toolsapi/scripts/check_db.py @@ -0,0 +1,25 @@ +"""Check database tables on server""" +import paramiko + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('123.207.67.197', username='root', password='520Kiss123') + +def run_sql(sql): + cmd = f"mysql -u tools -p'tools' --default-character-set=utf8mb4 tools -e \"{sql}\"" + stdin, stdout, stderr = ssh.exec_command(cmd) + out = stdout.read().decode().strip() + err = stderr.read().decode().strip() + if out: print(out) + if err: print(f'ERR: {err[:300]}') + +print('=== SHOW TABLES LIKE "fa_%" ===') +run_sql("SHOW TABLES LIKE 'fa_%'") + +print('\n=== DESCRIBE tool_feed_weight_config ===') +run_sql("DESCRIBE tool_feed_weight_config") + +print('\n=== SHOW TABLES LIKE "tool_%" ===') +run_sql("SHOW TABLES LIKE 'tool_%'") + +ssh.close() diff --git a/docs/toolsapi/scripts/debug_cloud_cache2.py b/docs/toolsapi/scripts/debug_cloud_cache2.py deleted file mode 100644 index 5714b9a8..00000000 --- a/docs/toolsapi/scripts/debug_cloud_cache2.py +++ /dev/null @@ -1,39 +0,0 @@ -import paramiko - -ssh = paramiko.SSHClient() -ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) -ssh.connect('123.207.67.197', 22, 'root', '520Kiss123') - -print('=== Check if table exists ===') -stdin, stdout, stderr = ssh.exec_command( - 'cd /www/wwwroot/tools.wktyl.com && php -r "' - "require 'vendor/autoload.php';" - "\\$config = include 'application/database.php';" - "\\$pdo = new PDO('mysql:host='.\\$config['hostname'].';dbname='.\\$config['database'], \\$config['username'], \\$config['password']);" - "\\$stmt = \\$pdo->query('SHOW TABLES LIKE \\\"tool_cloud_cache_record\\\"');" - "\\$result = \\$stmt->fetchAll();" - "echo count(\\$result) > 0 ? 'TABLE EXISTS' : 'TABLE NOT FOUND';" - '"' -) -print(stdout.read().decode()) -print(stderr.read().decode()) - -print('=== Try creating table directly ===') -stdin, stdout, stderr = ssh.exec_command( - 'cd /www/wwwroot/tools.wktyl.com && php -r "' - "require 'vendor/autoload.php';" - "\\$config = include 'application/database.php';" - "\\$pdo = new PDO('mysql:host='.\\$config['hostname'].';dbname='.\\$config['database'], \\$config['username'], \\$config['password']);" - "\\$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);" - "try {" - " \\$pdo->exec('CREATE TABLE IF NOT EXISTS tool_cloud_cache_record (id int(11) unsigned NOT NULL AUTO_INCREMENT, cache_id varchar(64) NOT NULL, PRIMARY KEY (id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');" - " echo 'TABLE CREATED OK';" - "} catch (PDOException \\$e) {" - " echo 'ERROR: ' . \\$e->getMessage();" - "}" - '"' -) -print(stdout.read().decode()) -print(stderr.read().decode()) - -ssh.close() diff --git a/docs/toolsapi/scripts/debug_cloud_cache3.py b/docs/toolsapi/scripts/debug_cloud_cache3.py deleted file mode 100644 index 7bb95494..00000000 --- a/docs/toolsapi/scripts/debug_cloud_cache3.py +++ /dev/null @@ -1,25 +0,0 @@ -import paramiko - -ssh = paramiko.SSHClient() -ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) -ssh.connect('123.207.67.197', 22, 'root', '520Kiss123') - -print('=== Read .env file ===') -stdin, stdout, stderr = ssh.exec_command('cat /www/wwwroot/tools.wktyl.com/.env 2>/dev/null | grep -i database || echo "no .env found"') -print(stdout.read().decode()) - -print('=== Try install via API with more detail ===') -stdin, stdout, stderr = ssh.exec_command('curl -s -X POST "https://tools.wktyl.com/api/cloud_cache/install" 2>&1') -print(stdout.read().decode()) - -print('=== Check PHP error in runtime ===') -stdin, stdout, stderr = ssh.exec_command('find /www/wwwroot/tools.wktyl.com/runtime -name "*.log" -newer /www/wwwroot/tools.wktyl.com/application/api/controller/CloudCache.php -exec tail -5 {} \\; 2>/dev/null || echo "no recent logs"') -print(stdout.read().decode()) - -print('=== Try direct SQL via ThinkPHP CLI ===') -stdin, stdout, stderr = ssh.exec_command( - 'cd /www/wwwroot/tools.wktyl.com && php think version 2>&1; echo "---"; php think 2>&1 | head -5' -) -print(stdout.read().decode()) - -ssh.close() diff --git a/docs/toolsapi/scripts/deploy_fortune.py b/docs/toolsapi/scripts/deploy_fortune.py deleted file mode 100644 index f088db66..00000000 --- a/docs/toolsapi/scripts/deploy_fortune.py +++ /dev/null @@ -1,138 +0,0 @@ -import paramiko -import os -import sys -import time - -SSH_HOST = '123.207.67.197' -SSH_PORT = 22 -SSH_USER = 'root' -SSH_PASS = '520Kiss123' -REMOTE_BASE = '/www/wwwroot/tools.wktyl.com' - -LOCAL_BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -LOCAL_CONTROLLER = os.path.join(LOCAL_BASE, 'application', 'api', 'controller', 'Fortune.php') -LOCAL_ROUTE = os.path.join(LOCAL_BASE, 'application', 'route.php') - -REMOTE_CONTROLLER = f'{REMOTE_BASE}/application/api/controller/Fortune.php' -REMOTE_ROUTE = f'{REMOTE_BASE}/application/route.php' - -def ssh_connect(): - ssh = paramiko.SSHClient() - ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - ssh.connect(SSH_HOST, SSH_PORT, SSH_USER, SSH_PASS) - return ssh - -def upload_file(sftp, local_path, remote_path): - if not os.path.exists(local_path): - print(f'[ERROR] Local file not found: {local_path}') - return False - remote_dir = os.path.dirname(remote_path) - try: - sftp.stat(remote_dir) - except FileNotFoundError: - print(f'[INFO] Creating remote directory: {remote_dir}') - parts = remote_dir.split('/') - current = '' - for part in parts: - if not part: - continue - current += '/' + part - try: - sftp.stat(current) - except FileNotFoundError: - sftp.mkdir(current) - print(f'[UPLOAD] {os.path.basename(local_path)} -> {remote_path}') - sftp.put(local_path, remote_path) - print(f'[OK] Uploaded {os.path.basename(local_path)}') - return True - -def run_cmd(ssh, cmd, label=''): - if label: - print(f'\n=== {label} ===') - stdin, stdout, stderr = ssh.exec_command(cmd) - out = stdout.read().decode('utf-8', errors='replace').strip() - err = stderr.read().decode('utf-8', errors='replace').strip() - if out: - print(out) - if err: - print(f'[STDERR] {err}') - return out, err - -def test_api(ssh, endpoint, label=''): - url = f'https://tools.wktyl.com/api/fortune/{endpoint}' - if label: - print(f'\n--- Test: {label} ---') - cmd = f'curl -s -k -m 10 "{url}"' - out, err = run_cmd(ssh, cmd, label) - return out - -def main(): - print('=' * 60) - print(' 每日运势 Fortune API - 部署脚本') - print(f' 时间: {time.strftime("%Y-%m-%d %H:%M:%S")}') - print('=' * 60) - - ssh = ssh_connect() - sftp = ssh.open_sftp() - - try: - print('\n[STEP 1] 上传 Fortune.php 控制器') - upload_file(sftp, LOCAL_CONTROLLER, REMOTE_CONTROLLER) - - print('\n[STEP 2] 上传 route.php (含运势路由)') - upload_file(sftp, LOCAL_ROUTE, REMOTE_ROUTE) - - print('\n[STEP 3] 验证文件已上传') - run_cmd(ssh, f'ls -la {REMOTE_CONTROLLER}', '验证 Fortune.php') - run_cmd(ssh, f'head -5 {REMOTE_CONTROLLER}', 'Fortune.php 前5行') - - print('\n[STEP 4] 执行数据库安装 (install)') - test_api(ssh, 'install', '数据库安装') - - print('\n[STEP 5] 测试运势生成 (daily)') - test_api(ssh, 'daily?uid=test_user_001', '今日运势') - - print('\n[STEP 6] 测试换签 (daily?regen=1)') - test_api(ssh, 'daily?uid=test_user_001®en=1', '换一签') - - print('\n[STEP 7] 测试历史运势 (history)') - test_api(ssh, 'history?uid=test_user_001', '历史运势') - - print('\n[STEP 8] 测试用户配置 (config)') - test_api(ssh, 'config?uid=test_user_001', '用户配置') - - print('\n[STEP 9] 测试卡片风格 (themes)') - test_api(ssh, 'themes', '卡片风格') - - print('\n[STEP 10] 测试60秒新闻 (sixtySeconds)') - test_api(ssh, 'sixtySeconds', '60秒新闻') - - print('\n[STEP 11] 测试黄历 (huangli)') - test_api(ssh, 'huangli', '黄历') - - print('\n[STEP 12] 测试星座运势 (horoscope)') - test_api(ssh, 'horoscope?sign=白羊', '星座运势') - - print('\n[STEP 13] 测试指定日期 (date)') - test_api(ssh, 'date?uid=test_user_001&date=2026-05-12', '指定日期运势') - - print('\n[STEP 14] 测试图片生成 (image)') - test_api(ssh, 'image?uid=test_user_001', '运势图片') - - print('\n[STEP 15] 全量第三方API测试 (test)') - test_api(ssh, 'test', '第三方API测试') - - print('\n' + '=' * 60) - print(' 部署完成!') - print('=' * 60) - - except Exception as e: - print(f'\n[FATAL ERROR] {e}') - import traceback - traceback.print_exc() - finally: - sftp.close() - ssh.close() - -if __name__ == '__main__': - main() diff --git a/docs/toolsapi/scripts/fix_v12_sql.py b/docs/toolsapi/scripts/fix_v12_sql.py new file mode 100644 index 00000000..a7d8b1e6 --- /dev/null +++ b/docs/toolsapi/scripts/fix_v12_sql.py @@ -0,0 +1,24 @@ +"""Fix remaining SQL - feed_weight data + admin menus""" +import paramiko + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect('123.207.67.197', username='root', password='520Kiss123') + +def run_sql(sql, name=''): + cmd = f"mysql -u tools -p'tools' --default-character-set=utf8mb4 tools -e \"{sql}\"" + stdin, stdout, stderr = ssh.exec_command(cmd) + err = stderr.read().decode().strip() + exit_code = stdout.channel.recv_exit_status() + status = 'OK' if exit_code == 0 else 'FAIL' + print(f'[{status}] {name}' + (f' - {err[:200]}' if err else '')) + return exit_code == 0 + +run_sql("INSERT IGNORE INTO tool_feed_weight_config (feed_type,feed_name,feed_icon,weight,display_weight,push_limit,is_enabled,update_time) VALUES ('poetry','古诗词','📜',60,48,0,1,UNIX_TIMESTAMP()),('wisdom','名言金句','💡',55,44,0,1,UNIX_TIMESTAMP()),('story','故事','📚',50,40,0,1,UNIX_TIMESTAMP()),('hitokoto','一言','💬',70,56,0,1,UNIX_TIMESTAMP()),('riddle','谜语','🧩',40,32,0,1,UNIX_TIMESTAMP()),('efs','歇后语','🎭',35,28,0,1,UNIX_TIMESTAMP()),('brainteaser','脑筋急转弯','🧠',40,32,0,1,UNIX_TIMESTAMP()),('saying','俗语','🗣️',30,24,0,1,UNIX_TIMESTAMP()),('lyric','歌词','🎵',55,44,0,1,UNIX_TIMESTAMP()),('why','十万个为什么','❓',35,28,0,1,UNIX_TIMESTAMP()),('composition','作文','📝',30,24,0,1,UNIX_TIMESTAMP()),('couplet','对联','🧧',25,20,0,1,UNIX_TIMESTAMP()),('cs','常识','📖',30,24,0,1,UNIX_TIMESTAMP()),('drug','中药','🌿',15,12,0,1,UNIX_TIMESTAMP()),('herbal','草药','🌱',15,12,0,1,UNIX_TIMESTAMP()),('food','美食','🍜',20,16,0,1,UNIX_TIMESTAMP()),('wine','美酒','🍷',10,8,0,1,UNIX_TIMESTAMP()),('article','文章','📰',60,48,0,1,UNIX_TIMESTAMP())", 'feed_weight data') + +run_sql("DESCRIBE tool_auth_rule", 'check auth_rule schema') + +run_sql("INSERT IGNORE INTO tool_auth_rule (id,type,pid,name,title,icon,ismenu,weigh,status) VALUES (200,'file',0,'feed_weight','推荐权重','fa fa-balance-scale',1,0,'normal'),(201,'file',200,'feed_weight/index','查看','',0,0,'normal'),(202,'file',200,'feed_weight/edit','编辑','',0,0,'normal'),(203,'file',200,'feed_weight/reset_push','重置推送','',0,0,'normal'),(204,'file',200,'feed_weight/reset_defaults','恢复默认','',0,0,'normal'),(210,'file',0,'daily_task','每日任务','fa fa-tasks',1,0,'normal'),(211,'file',210,'daily_task/index','查看','',0,0,'normal'),(212,'file',210,'daily_task/add','添加','',0,0,'normal'),(213,'file',210,'daily_task/edit','编辑','',0,0,'normal'),(214,'file',210,'daily_task/del','删除','',0,0,'normal'),(220,'file',0,'badge','勋章管理','fa fa-shield',1,0,'normal'),(221,'file',220,'badge/index','查看','',0,0,'normal'),(222,'file',220,'badge/add','添加','',0,0,'normal'),(223,'file',220,'badge/edit','编辑','',0,0,'normal'),(224,'file',220,'badge/del','删除','',0,0,'normal'),(230,'file',0,'user/user_badge','用户勋章','fa fa-id-badge',1,0,'normal'),(231,'file',230,'user/user_badge/index','查看','',0,0,'normal'),(240,'file',0,'user/user_task','用户任务','fa fa-list-check',1,0,'normal'),(241,'file',240,'user/user_task/index','查看','',0,0,'normal')", 'admin menus') + +ssh.close() +print('\nAll done!') diff --git a/docs/toolsapi/scripts/quick_test.py b/docs/toolsapi/scripts/quick_test.py deleted file mode 100644 index 4d6ceb75..00000000 --- a/docs/toolsapi/scripts/quick_test.py +++ /dev/null @@ -1,18 +0,0 @@ -import paramiko -ssh = paramiko.SSHClient() -ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) -ssh.connect('123.207.67.197', 22, 'root', '520Kiss123') - -tests = [ - ('news60s', 'curl -s -k "https://tools.wktyl.com/api/fortune/news60s"'), - ('sixtySeconds', 'curl -s -k "https://tools.wktyl.com/api/fortune/sixtySeconds"'), - ('daily', 'curl -s -k "https://tools.wktyl.com/api/fortune/daily?uid=test"'), -] - -for name, cmd in tests: - print(f'\n=== Test {name} ===') - stdin, stdout, stderr = ssh.exec_command(cmd) - out = stdout.read().decode('utf-8', errors='replace').strip() - print(out[:300]) - -ssh.close() diff --git a/docs/toolsapi/scripts/test_daily_task.py b/docs/toolsapi/scripts/test_daily_task.py new file mode 100644 index 00000000..cac7d21b --- /dev/null +++ b/docs/toolsapi/scripts/test_daily_task.py @@ -0,0 +1,199 @@ +import requests +import json +import base64 +import hashlib +import hmac +import time +import os +import sys + +BASE = 'https://tools.wktyl.com' +SECRET = 'Xy7kP9mL2qR4wS8v' + +TEST_USERNAME = f'task_test_{int(time.time())}' +TEST_PASSWORD = '123456' +TEST_EMAIL = f'task_test_{int(time.time())}@test.com' + +PASSED = 0 +FAILED = 0 +token = None +user_id = None + +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 test(name, response, expected_code=1, check_func=None): + global PASSED, FAILED + try: + data = response.json() + except Exception as e: + print(f' ❌ {name}: Response parse error: {e}') + FAILED += 1 + return None + if data.get('code') == expected_code: + if check_func and not check_func(data): + print(f' ❌ {name}: Check failed. Response: {json.dumps(data, ensure_ascii=False)[:200]}') + FAILED += 1 + return data + print(f' ✅ {name}') + PASSED += 1 + else: + print(f' ❌ {name}: Expected code={expected_code}, got code={data.get("code")}, msg={data.get("msg")}') + FAILED += 1 + return data + +print('=' * 60) +print('每日任务系统全流程测试') +print(f'测试账号: {TEST_USERNAME}') +print(f'测试时间: {time.strftime("%Y-%m-%d %H:%M:%S")}') +print('=' * 60) + +# ========== 1. 注册 ========== +print('\n--- 1. 注册测试用户 ---') +receipt_data = make_receipt('register', TEST_EMAIL) +r = requests.post(f'{BASE}/api/user_security/register', data={ + 'username': TEST_USERNAME, + 'password': TEST_PASSWORD, + 'email': TEST_EMAIL, + 'receipt': receipt_data['receipt'], + 'sig': receipt_data['sig'], +}) +data = test('register', r) +if data and data.get('code') == 1: + token = data['data'].get('token', '') + user_id = data['data'].get('userinfo', {}).get('id', '') + print(f' 用户ID: {user_id}') + +if not token: + print('❌ 注册失败,终止测试') + sys.exit(1) + +headers = {'token': token} + +# ========== 2. 登录获取token ========== +print('\n--- 2. 登录获取token ---') +receipt_data = make_receipt('login', TEST_USERNAME) +r = requests.post(f'{BASE}/api/user_security/login', data={ + 'account': TEST_USERNAME, + 'password': TEST_PASSWORD, + 'receipt': receipt_data['receipt'], + 'sig': receipt_data['sig'], +}) +data = test('login', r) +if data and data.get('code') == 1: + token = data['data'].get('token', '') + user_id = data['data'].get('userinfo', {}).get('id', '') + headers = {'token': token} + print(f' Token: {token[:20]}...') + +# ========== 3. 获取今日任务列表 ========== +print('\n--- 3. 获取今日任务列表 ---') +r = requests.get(f'{BASE}/api/task/today', headers=headers) +data = test('today tasks exist', r, check_func=lambda d: len(d.get('data', {}).get('tasks', [])) > 0) +if data and data.get('code') == 1: + tasks = data['data']['tasks'] + print(f' 今日任务数: {len(tasks)}') + for t in tasks: + print(f' {t.get("icon", "📋")} {t.get("name")} | id={t.get("id")} progress={t.get("progress", 0)}/{t.get("target", 1)} claimed={t.get("claimed", False)}') + +# ========== 4. 上报签到任务进度 ========== +print('\n--- 4. 上报签到任务进度 ---') +signin_task = None +if data and data.get('code') == 1: + for t in tasks: + if '签到' in t.get('name', '') or t.get('id') == 1: + signin_task = t + break + +task_id_to_report = signin_task.get('id', 1) if signin_task else 1 +task_name = signin_task.get('name', '签到') if signin_task else '签到' +r = requests.post(f'{BASE}/api/task/reportProgress', headers=headers, data={ + 'task_id': task_id_to_report, + 'increment': 1, +}) +data = test(f'reportProgress (task={task_name}, id={task_id_to_report})', r) +if data and data.get('code') == 1: + progress = data.get('data', {}) + print(f' 上报结果: progress={progress.get("progress")}, target={progress.get("target")}, completed={progress.get("completed")}') + +# ========== 5. 再次获取任务列表 - 验证进度更新 ========== +print('\n--- 5. 验证进度已更新 ---') +r = requests.get(f'{BASE}/api/task/today', headers=headers) +data = test('progress updated', r, check_func=lambda d: any( + t.get('id') == task_id_to_report and t.get('progress', 0) > 0 + for t in d.get('data', {}).get('tasks', []) +)) +if data and data.get('code') == 1: + for t in data['data']['tasks']: + if t.get('id') == task_id_to_report: + print(f' {t.get("icon", "📋")} {t.get("name")} | progress={t.get("progress")}/{t.get("target")} completed={t.get("completed")} claimed={t.get("claimed", False)}') + +# ========== 6. 领取已完成任务奖励 ========== +print('\n--- 6. 领取任务奖励 ---') +r = requests.post(f'{BASE}/api/task/claim', headers=headers, data={ + 'task_id': task_id_to_report, +}) +data = test(f'claim reward (task_id={task_id_to_report})', r) +if data and data.get('code') == 1: + reward = data.get('data', {}) + print(f' 奖励: exp={reward.get("exp_reward")}, coin={reward.get("coin_reward")}') + +# ========== 7. 再次获取任务列表 - 验证已领取 ========== +print('\n--- 7. 验证奖励已领取 ---') +r = requests.get(f'{BASE}/api/task/today', headers=headers) +data = test('claimed status', r, check_func=lambda d: any( + t.get('id') == task_id_to_report and t.get('claimed') is True + for t in d.get('data', {}).get('tasks', []) +)) +if data and data.get('code') == 1: + for t in data['data']['tasks']: + if t.get('id') == task_id_to_report: + print(f' {t.get("icon", "📋")} {t.get("name")} | claimed={t.get("claimed")}') + +# ========== 8. 注册自定义任务 ========== +print('\n--- 8. 注册自定义任务 ---') +r = requests.post(f'{BASE}/api/task/registerCustom', headers=headers, data={ + 'name': '测试自定义', + 'icon': '🎯', + 'custom_page': 'test_page', +}) +data = test('registerCustom', r) +if data and data.get('code') == 1: + custom_task = data.get('data', {}) + print(f' 自定义任务: id={custom_task.get("id")}, name={custom_task.get("name")}') + +# ========== 9. 尝试领取完美奖励(应失败) ========== +print('\n--- 9. 尝试领取完美奖励(应失败) ---') +r = requests.post(f'{BASE}/api/task/claimPerfect', headers=headers) +data = test('claimPerfect (should fail)', r, expected_code=0) +if data and data.get('code') != 1: + print(f' 预期失败: {data.get("msg")}') + +# ========== 10. 申请账号注销 ========== +print('\n--- 10. 申请账号注销 ---') +receipt_data = make_receipt('delete_account', str(user_id)) +r = requests.post(f'{BASE}/api/user_security/requestDeletion', headers=headers, data={ + 'reason': 'test complete', + 'receipt': receipt_data['receipt'], + 'sig': receipt_data['sig'], +}) +data = test('requestDeletion', r) +if data and data.get('code') == 1: + print(f' 注销申请ID: {data["data"].get("id")}') + +# ========== 结果汇总 ========== +print('\n' + '=' * 60) +print(f'测试完成!通过: {PASSED}, 失败: {FAILED}, 总计: {PASSED + FAILED}') +print(f'测试账号 {TEST_USERNAME} 已提交注销申请') +print('=' * 60) + +if FAILED > 0: + sys.exit(1) diff --git a/docs/toolsapi/scripts/test_rank_system.py b/docs/toolsapi/scripts/test_rank_system.py new file mode 100644 index 00000000..305737f0 --- /dev/null +++ b/docs/toolsapi/scripts/test_rank_system.py @@ -0,0 +1,162 @@ +import requests +import json +import base64 +import hashlib +import hmac +import time +import os +import sys + +BASE = 'https://tools.wktyl.com' +SECRET = 'Xy7kP9mL2qR4wS8v' + +TEST_USERNAME = f'rank_test_{int(time.time())}' +TEST_PASSWORD = '123456' +TEST_EMAIL = f'rank_test_{int(time.time())}@test.com' + +PASSED = 0 +FAILED = 0 +token = None +user_id = None + +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 test(name, response, expected_code=1, check_func=None): + global PASSED, FAILED + try: + data = response.json() + except Exception as e: + print(f' ❌ {name}: Response parse error: {e}') + FAILED += 1 + return None + if data.get('code') == expected_code: + if check_func and not check_func(data): + print(f' ❌ {name}: Check failed. Response: {json.dumps(data, ensure_ascii=False)[:200]}') + FAILED += 1 + return data + print(f' ✅ {name}') + PASSED += 1 + else: + print(f' ❌ {name}: Expected code={expected_code}, got code={data.get("code")}, msg={data.get("msg")}') + FAILED += 1 + return data + +print('=' * 60) +print('赛季排行榜系统全流程测试') +print(f'测试账号: {TEST_USERNAME}') +print(f'测试时间: {time.strftime("%Y-%m-%d %H:%M:%S")}') +print('=' * 60) + +# ========== 1. 注册 ========== +print('\n--- 1. 注册测试用户 ---') +receipt_data = make_receipt('register', TEST_EMAIL) +r = requests.post(f'{BASE}/api/user_security/register', data={ + 'username': TEST_USERNAME, + 'password': TEST_PASSWORD, + 'email': TEST_EMAIL, + 'receipt': receipt_data['receipt'], + 'sig': receipt_data['sig'], +}) +data = test('register', r) +if data and data.get('code') == 1: + token = data['data'].get('token', '') + user_id = data['data'].get('userinfo', {}).get('id', '') + print(f' 用户ID: {user_id}') + +if not token: + print('❌ 注册失败,终止测试') + sys.exit(1) + +headers = {'token': token} + +# ========== 2. 登录获取token ========== +print('\n--- 2. 登录获取token ---') +receipt_data = make_receipt('login', TEST_USERNAME) +r = requests.post(f'{BASE}/api/user_security/login', data={ + 'account': TEST_USERNAME, + 'password': TEST_PASSWORD, + 'receipt': receipt_data['receipt'], + 'sig': receipt_data['sig'], +}) +data = test('login', r) +if data and data.get('code') == 1: + token = data['data'].get('token', '') + user_id = data['data'].get('userinfo', {}).get('id', '') + headers = {'token': token} + print(f' Token: {token[:20]}...') + +# ========== 3. 获取赛季列表 ========== +print('\n--- 3. 获取赛季列表 ---') +r = requests.get(f'{BASE}/api/rank/seasons', headers=headers) +data = test('seasons list', r, check_func=lambda d: 'data' in d) +if data and data.get('code') == 1: + seasons = data['data'].get('seasons', data['data']) if isinstance(data['data'], dict) else data['data'] + if isinstance(seasons, list): + print(f' 赛季数量: {len(seasons)}') + for s in seasons[:5]: + print(f' 🏆 {s.get("name", "?")} | type={s.get("type", "?")} status={s.get("status", "?")}') + else: + print(f' 赛季数据: {json.dumps(data["data"], ensure_ascii=False)[:200]}') + +# ========== 4. 获取排行榜(exp类型) ========== +print('\n--- 4. 获取排行榜(exp类型) ---') +r = requests.get(f'{BASE}/api/rank/leaderboard', headers=headers, params={'type': 'exp'}) +data = test('leaderboard (exp)', r, check_func=lambda d: 'data' in d) +if data and data.get('code') == 1: + lb = data['data'].get('list', data['data'].get('leaderboard', data['data'])) + if isinstance(lb, list): + print(f' 排行榜人数: {len(lb)}') + for item in lb[:3]: + print(f' 🥇 rank={item.get("rank", "?")} user={item.get("username", item.get("nickname", "?"))} value={item.get("value", "?")}') + else: + print(f' 排行榜数据: {json.dumps(data["data"], ensure_ascii=False)[:200]}') + +# ========== 5. 获取我的排名 ========== +print('\n--- 5. 获取我的排名 ---') +r = requests.get(f'{BASE}/api/rank/myRank', headers=headers, params={'type': 'exp'}) +data = test('myRank (exp)', r, check_func=lambda d: 'data' in d) +if data and data.get('code') == 1: + my_rank = data['data'] + if isinstance(my_rank, dict): + print(f' 我的排名: rank={my_rank.get("rank", "?")} value={my_rank.get("value", "?")}') + else: + print(f' 排名数据: {json.dumps(data["data"], ensure_ascii=False)[:200]}') + +# ========== 6. 领取赛季奖励(应失败 - 无效赛季) ========== +print('\n--- 6. 领取赛季奖励(应失败 - 无效赛季ID=999) ---') +r = requests.post(f'{BASE}/api/rank/claimReward', headers=headers, data={ + 'season_id': 999, +}) +data = test('claimReward (invalid season, should fail)', r, expected_code=0) +if data and data.get('code') != 1: + print(f' 预期失败: {data.get("msg")}') + +# ========== 7. 申请账号注销 ========== +print('\n--- 7. 申请账号注销 ---') +receipt_data = make_receipt('delete_account', str(user_id)) +r = requests.post(f'{BASE}/api/user_security/requestDeletion', headers=headers, data={ + 'reason': 'test complete', + 'receipt': receipt_data['receipt'], + 'sig': receipt_data['sig'], +}) +data = test('requestDeletion', r) +if data and data.get('code') == 1: + print(f' 注销申请ID: {data["data"].get("id")}') + +# ========== 结果汇总 ========== +print('\n' + '=' * 60) +print(f'测试完成!通过: {PASSED}, 失败: {FAILED}, 总计: {PASSED + FAILED}') +print(f'测试账号 {TEST_USERNAME} 已提交注销申请') +print('=' * 60) + +if FAILED > 0: + sys.exit(1) diff --git a/docs/toolsapi/scripts/test_web_transfer.py b/docs/toolsapi/scripts/test_web_transfer.py deleted file mode 100644 index c449d00c..00000000 --- a/docs/toolsapi/scripts/test_web_transfer.py +++ /dev/null @@ -1,219 +0,0 @@ -# -*- coding: utf-8 -*- -""" -闲言传输 — Web Transfer API 测试脚本 -创建时间: 2026-05-14 -更新时间: 2026-05-14 -名称: test_web_transfer.py -作用: 测试LocalSend HTTP服务器的Web传输API端点 -上次更新: 初始创建,测试GET /api/info、POST /api/send-text、WebSocket连接 -""" - -import sys -import json -import time - -try: - import requests -except ImportError: - print("❌ 缺少 requests 库,请运行: pip install requests") - sys.exit(1) - -try: - import websocket -except ImportError: - try: - import websocket as _ws - websocket = _ws - except ImportError: - websocket = None - -BASE_URL = "http://localhost:53317" -WS_URL = "ws://localhost:53317/ws" -TIMEOUT = 5 - - -def test_api_info(): - print("\n" + "=" * 60) - print("测试 1: GET /api/info — 获取设备信息") - print("=" * 60) - try: - resp = requests.get(f"{BASE_URL}/api/info", timeout=TIMEOUT) - if resp.status_code == 200: - data = resp.json() - alias = data.get("alias", "") - device_type = data.get("deviceType", "") - fingerprint = data.get("fingerprint", "") - port = data.get("port", "") - version = data.get("version", "") - print(f" ✅ PASS — HTTP {resp.status_code}") - print(f" 设备别名: {alias}") - print(f" 设备类型: {device_type}") - print(f" 指纹: {fingerprint[:16]}..." if len(fingerprint) > 16 else f" 指纹: {fingerprint}") - print(f" 端口: {port}") - print(f" 版本: {version}") - return True - else: - print(f" ❌ FAIL — HTTP {resp.status_code}") - print(f" 响应: {resp.text[:200]}") - return False - except requests.exceptions.ConnectionError: - print(" ⚠️ SKIP — 无法连接到服务器 (服务器可能未运行)") - return None - except requests.exceptions.Timeout: - print(" ⚠️ SKIP — 请求超时") - return None - except Exception as e: - print(f" ❌ FAIL — 异常: {e}") - return False - - -def test_send_text(): - print("\n" + "=" * 60) - print("测试 2: POST /api/send-text — 发送文本消息") - print("=" * 60) - try: - payload = {"text": "Hello from test_web_transfer.py 🧪"} - resp = requests.post( - f"{BASE_URL}/api/send-text", - json=payload, - timeout=TIMEOUT - ) - if resp.status_code == 200: - data = resp.json() - print(f" ✅ PASS — HTTP {resp.status_code}") - print(f" 响应: {json.dumps(data, ensure_ascii=False)[:200]}") - return True - else: - print(f" ❌ FAIL — HTTP {resp.status_code}") - print(f" 响应: {resp.text[:200]}") - return False - except requests.exceptions.ConnectionError: - print(" ⚠️ SKIP — 无法连接到服务器 (服务器可能未运行)") - return None - except requests.exceptions.Timeout: - print(" ⚠️ SKIP — 请求超时") - return None - except Exception as e: - print(f" ❌ FAIL — 异常: {e}") - return False - - -def test_websocket(): - print("\n" + "=" * 60) - print("测试 3: WebSocket /ws — WS连接测试") - print("=" * 60) - if websocket is None: - print(" ⚠️ SKIP — 缺少 websocket-client 库") - print(" 请运行: pip install websocket-client") - return None - - try: - ws = websocket.create_connection(WS_URL, timeout=TIMEOUT) - print(f" ✅ PASS — WebSocket 连接成功") - print(f" 状态: {ws.status}") - print(f" 头部: {dict(list(ws.headers.items())[:3])}") - - ping_msg = json.dumps({"type": "ping", "timestamp": int(time.time() * 1000)}) - ws.send(ping_msg) - print(f" 发送: {ping_msg[:80]}") - - try: - result = ws.recv() - print(f" 接收: {result[:200]}") - except Exception: - print(f" (未收到即时响应,连接正常)") - - ws.close() - return True - except ConnectionRefusedError: - print(" ⚠️ SKIP — 无法连接到WebSocket服务器 (服务器可能未运行)") - return None - except Exception as e: - error_msg = str(e) - if "Connection refused" in error_msg or "ECONNREFUSED" in error_msg: - print(" ⚠️ SKIP — 无法连接到WebSocket服务器 (服务器可能未运行)") - return None - print(f" ❌ FAIL — 异常: {e}") - return False - - -def test_localsend_info(): - print("\n" + "=" * 60) - print("测试 4: GET /api/localsend/v2/info — LocalSend协议信息") - print("=" * 60) - try: - resp = requests.get(f"{BASE_URL}/api/localsend/v2/info", timeout=TIMEOUT) - if resp.status_code == 200: - data = resp.json() - alias = data.get("alias", "") - device_type = data.get("deviceType", "") - fingerprint = data.get("fingerprint", "") - print(f" ✅ PASS — HTTP {resp.status_code}") - print(f" 设备别名: {alias}") - print(f" 设备类型: {device_type}") - print(f" 指纹: {fingerprint[:16]}..." if len(fingerprint) > 16 else f" 指纹: {fingerprint}") - return True - else: - print(f" ❌ FAIL — HTTP {resp.status_code}") - return False - except requests.exceptions.ConnectionError: - print(" ⚠️ SKIP — 无法连接到服务器 (服务器可能未运行)") - return None - except requests.exceptions.Timeout: - print(" ⚠️ SKIP — 请求超时") - return None - except Exception as e: - print(f" ❌ FAIL — 异常: {e}") - return False - - -def main(): - print("╔══════════════════════════════════════════════════════════╗") - print("║ 闲言传输 — Web Transfer API 测试 ║") - print("╚══════════════════════════════════════════════════════════╝") - print(f"目标服务器: {BASE_URL}") - print(f"WebSocket: {WS_URL}") - print(f"时间: {time.strftime('%Y-%m-%d %H:%M:%S')}") - - results = [] - - results.append(("GET /api/info", test_api_info())) - results.append(("POST /api/send-text", test_send_text())) - results.append(("WebSocket /ws", test_websocket())) - results.append(("GET /api/localsend/v2/info", test_localsend_info())) - - print("\n" + "=" * 60) - print("测试结果汇总") - print("=" * 60) - - passed = 0 - failed = 0 - skipped = 0 - - for name, result in results: - if result is True: - status = "✅ PASS" - passed += 1 - elif result is False: - status = "❌ FAIL" - failed += 1 - else: - status = "⚠️ SKIP" - skipped += 1 - print(f" {status} {name}") - - print(f"\n通过: {passed} 失败: {failed} 跳过: {skipped} 总计: {len(results)}") - - if skipped == len(results): - print("\n⚠️ 所有测试均跳过 — 请确保LocalSend服务器正在运行 (端口 53317)") - print(" 在闲言APP中开启文件传输功能后,HTTP服务器会自动启动") - elif failed > 0: - print(f"\n❌ {failed} 个测试失败,请检查服务器日志") - else: - print("\n🎉 所有测试通过!") - - return 0 if failed == 0 else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/docs/toolsapi/scripts/upload_cloud_cache.py b/docs/toolsapi/scripts/upload_cloud_cache.py deleted file mode 100644 index 20910820..00000000 --- a/docs/toolsapi/scripts/upload_cloud_cache.py +++ /dev/null @@ -1,61 +0,0 @@ -import paramiko -import os -import time - -HOST = '123.207.67.197' -PORT = 22 -USER = 'root' -PASS = '520Kiss123' - -LOCAL_CONTROLLER = r'e:\project\flutter\f\xianyan\docs\toolsapi\application\api\controller\CloudCache.php' -REMOTE_CONTROLLER = '/www/wwwroot/tools.wktyl.com/application/api/controller/CloudCache.php' - -ssh = paramiko.SSHClient() -ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) -ssh.connect(HOST, PORT, USER, PASS) - -sftp = ssh.open_sftp() - -backup_file = REMOTE_CONTROLLER + '.bak.' + time.strftime('%Y%m%d_%H%M%S') -print(f'Backing up {REMOTE_CONTROLLER} -> {backup_file}') -try: - sftp.rename(REMOTE_CONTROLLER, backup_file) - print('Backup created') -except: - print('No existing file to backup (or rename failed)') - -print(f'Uploading {LOCAL_CONTROLLER} -> {REMOTE_CONTROLLER}') -sftp.put(LOCAL_CONTROLLER, REMOTE_CONTROLLER) -print('Upload complete') - -sftp.chmod(REMOTE_CONTROLLER, 0o644) -print('Permissions set to 644') - -sftp.close() - -print('\nVerifying file on server...') -stdin, stdout, stderr = ssh.exec_command(f'head -5 {REMOTE_CONTROLLER}') -print(stdout.read().decode()) - -print('\nCreating cloud_cache storage directory...') -stdin, stdout, stderr = ssh.exec_command('mkdir -p /www/wwwroot/tools.wktyl.com/runtime/cloud_cache && chmod 755 /www/wwwroot/tools.wktyl.com/runtime/cloud_cache') -print(stdout.read().decode()) -print(stderr.read().decode()) - -print('\nInstalling cloud_cache_record table...') -install_url = 'https://tools.wktyl.com/api/cloud_cache/install' -stdin, stdout, stderr = ssh.exec_command(f'curl -s -X POST "{install_url}"') -result = stdout.read().decode() -print(f'Install result: {result}') - -print('\nSetting up cron job for auto cleanup (every hour)...') -cron_cmd = '0 * * * * curl -s -X POST https://tools.wktyl.com/api/cloud_cache/clean > /dev/null 2>&1' -stdin, stdout, stderr = ssh.exec_command(f'(crontab -l 2>/dev/null; echo "{cron_cmd}") | sort -u | crontab -') -print('Cron job configured') - -stdin, stdout, stderr = ssh.exec_command('crontab -l | grep cloud_cache') -cron_result = stdout.read().decode() -print(f'Cron verification: {cron_result}') - -ssh.close() -print('\nUpload and setup complete!') diff --git a/docs/toolsapi/scripts/upload_indexjs.py b/docs/toolsapi/scripts/upload_indexjs.py deleted file mode 100644 index e3945b55..00000000 --- a/docs/toolsapi/scripts/upload_indexjs.py +++ /dev/null @@ -1,51 +0,0 @@ -import paramiko -import os - -HOST = '123.207.67.197' -PORT = 22 -USER = 'root' -PASS = '520Kiss123' -LOCAL_FILE = r'e:\project\flutter\f\xianyan\server\index.js' -REMOTE_FILE = '/www/wwwroot/tools.wktyl.com/signaling/index.js' - -ssh = paramiko.SSHClient() -ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) -ssh.connect(HOST, PORT, USER, PASS) - -sftp = ssh.open_sftp() - -backup_file = REMOTE_FILE + '.bak.' + os.popen('echo %date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%').read().strip().replace(' ', '0') -print(f'Backing up {REMOTE_FILE} -> {backup_file}') -try: - sftp.rename(REMOTE_FILE, backup_file) - print('Backup created') -except: - print('No existing file to backup (or rename failed)') - -print(f'Uploading {LOCAL_FILE} -> {REMOTE_FILE}') -sftp.put(LOCAL_FILE, REMOTE_FILE) -print('Upload complete') - -sftp.chmod(REMOTE_FILE, 0o644) -print('Permissions set to 644') - -sftp.close() - -print('\nRestarting PM2 signaling service...') -stdin, stdout, stderr = ssh.exec_command('cd /www/wwwroot/tools.wktyl.com/signaling && pm2 restart signaling') -print(stdout.read().decode()) -print(stderr.read().decode()) - -import time -time.sleep(2) - -print('\nChecking PM2 status...') -stdin, stdout, stderr = ssh.exec_command('pm2 list') -print(stdout.read().decode()) - -print('\nChecking recent logs...') -stdin, stdout, stderr = ssh.exec_command('pm2 logs signaling --lines 10 --nostream') -print(stdout.read().decode()) - -ssh.close() -print('\nDone!') diff --git a/docs/toolsapi/scripts/upload_sec_question.py b/docs/toolsapi/scripts/upload_sec_question.py new file mode 100644 index 00000000..c887ecd5 --- /dev/null +++ b/docs/toolsapi/scripts/upload_sec_question.py @@ -0,0 +1,83 @@ +import paramiko +import os +import sys + +HOST = '123.207.67.197' +PORT = 22 +USER = 'root' +PASS = '520Kiss123' + +BASE_DIR = r'e:\project\flutter\f\xianyan\docs\toolsapi' +REMOTE_BASE = '/www/wwwroot/tools.wktyl.com/' + +UPLOAD_FILES = [ + { + 'local': os.path.join(BASE_DIR, 'application', 'api', 'controller', 'UserSecurity.php'), + 'remote': REMOTE_BASE + 'application/api/controller/UserSecurity.php', + }, + { + 'local': os.path.join(BASE_DIR, 'application', 'api', 'controller', 'UserCenter.php'), + 'remote': REMOTE_BASE + 'application/api/controller/UserCenter.php', + }, +] + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect(HOST, PORT, USER, PASS) +sftp = ssh.open_sftp() + +for item in UPLOAD_FILES: + local = item['local'] + remote = item['remote'] + if not os.path.exists(local): + print(f'[SKIP] Local file not found: {local}') + continue + + backup_file = remote + '.bak.' + os.popen('echo %date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%').read().strip().replace(' ', '0') + print(f'Backing up {remote} -> {backup_file}') + try: + sftp.rename(remote, backup_file) + print(' Backup created') + except: + print(' No existing file to backup (or rename failed)') + + print(f'Uploading {local} -> {remote}') + sftp.put(local, remote) + print(' Upload complete') + sftp.chmod(remote, 0o644) + print(' Permissions set to 644') + +print('\nRunning database migration...') +sql_file = os.path.join(BASE_DIR, 'application', 'admin', 'command', 'Install', 'migrate_v10_1.sql') +if os.path.exists(sql_file): + with open(sql_file, 'r', encoding='utf-8') as f: + sql_content = f.read() + sql_lines = [line.strip() for line in sql_content.split('\n') if line.strip() and not line.strip().startswith('--')] + for sql in sql_lines: + if sql: + stdin, stdout, stderr = ssh.exec_command( + f"cd {REMOTE_BASE} && php think sql \"{sql.replace('\"', '\\\"')}\" 2>/dev/null || " + f"mysql -u tools -ptools tools -e \"{sql.replace('\"', '\\\"')}\" 2>/dev/null || " + f"echo 'SQL_MANUAL: {sql[:80]}...'" + ) + output = stdout.read().decode() + err = stderr.read().decode() + if output: + print(f' SQL: {output.strip()}') + if err and 'Warning' not in err: + print(f' SQL Err: {err.strip()}') + print('Migration commands sent (check manually if needed)') + +print('\nClearing ThinkPHP cache...') +stdin, stdout, stderr = ssh.exec_command(f'rm -rf {REMOTE_BASE}runtime/cache/* {REMOTE_BASE}runtime/temp/*') +print(stdout.read().decode()) + +sftp.close() + +print('\nDone! Files uploaded and migration executed.') +print('Please verify the migration manually if needed:') +print(f' SQL: ALTER TABLE tool_user ADD COLUMN sec_question TINYINT(2) UNSIGNED NOT NULL DEFAULT 0;') +print(f' SQL: ALTER TABLE tool_user ADD COLUMN sec_answer VARCHAR(32) NOT NULL DEFAULT \'\';') +print(f' SQL: ALTER TABLE tool_user ADD INDEX idx_sec_question (sec_question);') + +ssh.close() diff --git a/docs/toolsapi/scripts/upload_signaling.py b/docs/toolsapi/scripts/upload_signaling.py new file mode 100644 index 00000000..bf5edc46 --- /dev/null +++ b/docs/toolsapi/scripts/upload_signaling.py @@ -0,0 +1,55 @@ +"""Upload signaling server index.js and restart PM2""" +import paramiko +import os +import time + +HOST = '123.207.67.197' +USER = 'root' +PASS = '520Kiss123' +REMOTE_BASE = '/www/wwwroot/tools.wktyl.com/signaling' +LOCAL_FILE = r'e:\project\flutter\f\xianyan\server\index.js' + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect(HOST, username=USER, password=PASS) + +sftp = ssh.open_sftp() + +remote_file = REMOTE_BASE + '/index.js' +print(f'Uploading index.js to {remote_file}...') + +backup_path = REMOTE_BASE + '/index.js.bak' +try: + sftp.stat(remote_file) + sftp.rename(remote_file, backup_path) + print(f'Backup created: {backup_path}') +except FileNotFoundError: + pass + +sftp.put(LOCAL_FILE, remote_file) +print(f'[OK] index.js uploaded') +sftp.close() + +print('\nRestarting PM2 signaling process...') +stdin, stdout, stderr = ssh.exec_command('cd /www/wwwroot/tools.wktyl.com/signaling && pm2 restart signaling') +out = stdout.read().decode().strip() +err = stderr.read().decode().strip() +if out: + print(out) +if err: + print(f'STDERR: {err[:300]}') + +time.sleep(2) + +print('\nChecking PM2 status...') +stdin, stdout, stderr = ssh.exec_command('pm2 list') +out = stdout.read().decode().strip() +print(out[-500:] if len(out) > 500 else out) + +print('\nChecking signaling logs (last 10 lines)...') +stdin, stdout, stderr = ssh.exec_command('pm2 logs signaling --lines 10 --nostream') +out = stdout.read().decode().strip() +print(out[-500:] if len(out) > 500 else out) + +ssh.close() +print('\nDeployment complete!') diff --git a/docs/toolsapi/scripts/upload_v12_daily_task.py b/docs/toolsapi/scripts/upload_v12_daily_task.py new file mode 100644 index 00000000..4757fa71 --- /dev/null +++ b/docs/toolsapi/scripts/upload_v12_daily_task.py @@ -0,0 +1,289 @@ +""" +@File : upload_v12_daily_task.py +@Created : 2026-05-14 +@Updated : 2026-05-14 +@Name : Phase3 Daily Task Upload Script +@Desc : Upload Phase 3 daily task files and bug fixes to server, execute migration SQL +@Last : Initial creation +""" + +import os +import sys +import paramiko +from stat import S_ISDIR + +HOST = "123.207.67.197" +USERNAME = "root" +PASSWORD = "520Kiss123" +REMOTE_BASE = "/www/wwwroot/tools.wktyl.com" +LOCAL_BASE = r"e:\project\flutter\f\xianyan\docs\toolsapi" + +PHASE3_FILES = [ + (r"application\admin\command\Install\migrate_v12.sql", + "application/admin/command/Install/migrate_v12.sql"), + (r"application\api\controller\Task.php", + "application/api/controller/Task.php"), + (r"application\admin\controller\DailyTask.php", + "application/admin/controller/DailyTask.php"), + (r"application\admin\model\DailyTask.php", + "application/admin/model/DailyTask.php"), + (r"application\admin\validate\DailyTask.php", + "application/admin/validate/DailyTask.php"), + (r"application\admin\view\daily_task\index.html", + "application/admin/view/daily_task/index.html"), + (r"application\admin\view\daily_task\add.html", + "application/admin/view/daily_task/add.html"), + (r"application\admin\view\daily_task\edit.html", + "application/admin/view/daily_task/edit.html"), + (r"public\assets\js\backend\daily_task.js", + "public/assets/js/backend/daily_task.js"), + (r"application\admin\lang\zh-cn\daily_task.php", + "application/admin/lang/zh-cn/daily_task.php"), + (r"application\admin\controller\user\UserTask.php", + "application/admin/controller/user/UserTask.php"), + (r"application\admin\model\UserTask.php", + "application/admin/model/UserTask.php"), + (r"application\admin\view\user\user_task\index.html", + "application/admin/view/user/user_task/index.html"), + (r"public\assets\js\backend\user\user_task.js", + "public/assets/js/backend/user/user_task.js"), + (r"application\admin\lang\zh-cn\user\user_task.php", + "application/admin/lang/zh-cn/user/user_task.php"), +] + +BUGFIX_FILES = [ + (r"application\admin\controller\FeedWeight.php", + "application/admin/controller/FeedWeight.php"), + (r"application\admin\model\FeedWeightConfig.php", + "application/admin/model/FeedWeightConfig.php"), + (r"application\admin\view\feed_weight\index.html", + "application/admin/view/feed_weight/index.html"), + (r"application\admin\view\feed_weight\edit.html", + "application/admin/view/feed_weight/edit.html"), + (r"public\assets\js\backend\feed_weight.js", + "public/assets/js/backend/feed_weight.js"), + (r"application\admin\lang\zh-cn\feed_weight.php", + "application/admin/lang/zh-cn/feed_weight.php"), + (r"application\admin\view\userdeletion\index.html", + "application/admin/view/userdeletion/index.html"), + (r"application\admin\controller\user\User.php", + "application/admin/controller/user/User.php"), + (r"application\admin\controller\Userdeletion.php", + "application/admin/controller/Userdeletion.php"), +] + +MIGRATE_SQL_CMD = ( + "mysql -u root -p'520Kiss123' --default-character-set=utf8mb4 " + "tool_db < /www/wwwroot/tools.wktyl.com/application/admin/command/Install/migrate_v12.sql" +) + +CREATE_FEED_WEIGHT_TABLE_SQL = ( + 'mysql -u root -p\'520Kiss123\' --default-character-set=utf8mb4 tool_db -e ' + '"CREATE TABLE IF NOT EXISTS tool_feed_weight_config (' + 'id int(10) unsigned NOT NULL AUTO_INCREMENT,' + 'feed_type varchar(30) NOT NULL DEFAULT \'\' COMMENT \'内容类型\',' + 'weight int(10) NOT NULL DEFAULT 50 COMMENT \'推荐权重0-100\',' + 'display_weight int(10) NOT NULL DEFAULT 40 COMMENT \'展示权重\',' + 'push_limit int(10) NOT NULL DEFAULT 0 COMMENT \'每日推送上限0=不限\',' + 'push_count int(10) NOT NULL DEFAULT 0 COMMENT \'今日已推送数\',' + 'push_date varchar(10) NOT NULL DEFAULT \'\' COMMENT \'推送日期\',' + 'is_enabled tinyint(1) NOT NULL DEFAULT 1 COMMENT \'是否启用\',' + 'createtime int(10) DEFAULT NULL,' + 'updatetime int(10) DEFAULT NULL,' + 'update_time int(10) DEFAULT NULL,' + 'PRIMARY KEY (id),' + 'UNIQUE KEY uk_feed_type (feed_type)' + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT=\'推荐权重配置\';"' +) + +INSERT_DEFAULT_DATA_SQL = ( + 'mysql -u root -p\'520Kiss123\' --default-character-set=utf8mb4 tool_db -e ' + '"INSERT IGNORE INTO tool_feed_weight_config ' + '(feed_type, weight, display_weight, push_limit, is_enabled, createtime, updatetime) VALUES ' + '(\'poetry\',60,48,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()),' + '(\'wisdom\',55,44,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()),' + '(\'story\',50,40,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()),' + '(\'hitokoto\',70,56,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()),' + '(\'riddle\',40,32,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()),' + '(\'efs\',35,28,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()),' + '(\'brainteaser\',40,32,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()),' + '(\'saying\',30,24,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()),' + '(\'lyric\',55,44,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()),' + '(\'why\',35,28,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()),' + '(\'composition\',30,24,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()),' + '(\'couplet\',25,20,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()),' + '(\'cs\',30,24,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()),' + '(\'drug\',15,12,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()),' + '(\'herbal\',15,12,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()),' + '(\'food\',20,16,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()),' + '(\'wine\',10,8,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP()),' + '(\'article\',60,48,0,1,UNIX_TIMESTAMP(),UNIX_TIMESTAMP());"' +) + +INSERT_MENU_SQL = ( + 'mysql -u root -p\'520Kiss123\' --default-character-set=utf8mb4 tool_db -e "' + 'INSERT IGNORE INTO fa_auth_rule ' + '(id, type, pid, name, title, icon, condition, remark, ismenu, createtime, updatetime, weigh, status) VALUES ' + "(200, 'file', 0, 'feed_weight', '推荐权重', 'fa fa-balance-scale', '', '', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(201, 'file', 200, 'feed_weight/index', '查看', '', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(202, 'file', 200, 'feed_weight/edit', '编辑', '', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(203, 'file', 200, 'feed_weight/reset_push', '重置推送', '', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(204, 'file', 200, 'feed_weight/reset_defaults', '恢复默认', '', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(210, 'file', 0, 'daily_task', '每日任务', 'fa fa-tasks', '', '', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(211, 'file', 210, 'daily_task/index', '查看', '', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(212, 'file', 210, 'daily_task/add', '添加', '', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(213, 'file', 210, 'daily_task/edit', '编辑', '', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(214, 'file', 210, 'daily_task/del', '删除', '', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(220, 'file', 0, 'badge', '勋章管理', 'fa fa-shield', '', '', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(221, 'file', 220, 'badge/index', '查看', '', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(222, 'file', 220, 'badge/add', '添加', '', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(223, 'file', 220, 'badge/edit', '编辑', '', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(224, 'file', 220, 'badge/del', '删除', '', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(230, 'file', 0, 'user/user_badge', '用户勋章', 'fa fa-id-badge', '', '', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(231, 'file', 230, 'user/user_badge/index', '查看', '', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(240, 'file', 0, 'user/user_task', '用户任务', 'fa fa-list-check', '', '', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal')," + "(241, 'file', 240, 'user/user_task/index', '查看', '', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal');" + '"' +) + + +def create_ssh_client(): + """Create and return SSH client connection""" + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + print(f"[SSH] Connecting to {HOST}...") + client.connect(HOST, username=USERNAME, password=PASSWORD, timeout=30) + print(f"[SSH] Connected successfully.") + return client + + +def ensure_remote_dir(sftp, remote_path): + """Ensure remote directory exists, create if not""" + dirs_to_create = [] + path = remote_path + while path != "/" and path != "": + try: + sftp.stat(path) + break + except FileNotFoundError: + dirs_to_create.append(path) + path = os.path.dirname(path).replace("\\", "/") + + for d in reversed(dirs_to_create): + print(f" [DIR] Creating remote directory: {d}") + sftp.mkdir(d) + + +def upload_file(sftp, local_path, remote_path): + """Upload a single file, creating remote directories as needed""" + remote_dir = os.path.dirname(remote_path).replace("\\", "/") + ensure_remote_dir(sftp, remote_dir) + + if not os.path.isfile(local_path): + print(f" [SKIP] Local file not found: {local_path}") + return False + + sftp.put(local_path, remote_path) + print(f" [OK] {os.path.basename(local_path)} -> {remote_path}") + return True + + +def run_ssh_command(ssh, cmd, label=""): + """Execute SSH command and print output""" + if label: + print(f"\n[SQL] {label}") + print(f" [CMD] {cmd[:120]}...") + stdin, stdout, stderr = ssh.exec_command(cmd) + out = stdout.read().decode("utf-8", errors="replace").strip() + err = stderr.read().decode("utf-8", errors="replace").strip() + exit_code = stdout.channel.recv_exit_status() + if out: + print(f" [OUT] {out[:500]}") + if err: + print(f" [ERR] {err[:500]}") + if exit_code != 0: + print(f" [FAIL] Exit code: {exit_code}") + else: + print(f" [OK] Exit code: 0") + return exit_code + + +def main(): + all_files = [] + print("=" * 60) + print(" Phase 3 Daily Task & Bug Fix Upload Script v12") + print("=" * 60) + + print("\n--- Phase 3: Daily Task Files ---") + for local_rel, remote_rel in PHASE3_FILES: + local = os.path.join(LOCAL_BASE, local_rel) + remote = REMOTE_BASE + "/" + remote_rel + all_files.append(("Phase3", local, remote)) + + print("\n--- Bug Fix Files ---") + for local_rel, remote_rel in BUGFIX_FILES: + local = os.path.join(LOCAL_BASE, local_rel) + remote = REMOTE_BASE + "/" + remote_rel + all_files.append(("BugFix", local, remote)) + + missing = [(t, l, r) for t, l, r in all_files if not os.path.isfile(l)] + if missing: + print("\n[WARN] Missing local files:") + for tag, local, remote in missing: + print(f" [{tag}] {local}") + answer = input("\nContinue anyway? (y/N): ").strip().lower() + if answer != "y": + print("Aborted.") + sys.exit(1) + + ssh = create_ssh_client() + sftp = ssh.open_sftp() + + success = 0 + failed = 0 + skipped = 0 + + print(f"\n{'=' * 60}") + print(f" Uploading {len(all_files)} files...") + print(f"{'=' * 60}") + + for tag, local, remote in all_files: + print(f"\n[{tag}] {os.path.basename(local)}") + try: + result = upload_file(sftp, local, remote) + if result: + success += 1 + else: + skipped += 1 + except Exception as e: + print(f" [FAIL] {e}") + failed += 1 + + sftp.close() + print(f"\n{'=' * 60}") + print(f" Upload Summary: {success} ok, {skipped} skipped, {failed} failed") + print(f"{'=' * 60}") + + if failed > 0: + print("[WARN] Some uploads failed, but continuing with SQL execution...") + + print(f"\n{'=' * 60}") + print(" Executing SQL Commands...") + print(f"{'=' * 60}") + + run_ssh_command(ssh, MIGRATE_SQL_CMD, "Execute migrate_v12.sql") + + run_ssh_command(ssh, CREATE_FEED_WEIGHT_TABLE_SQL, "Create tool_feed_weight_config table if not exists") + + run_ssh_command(ssh, INSERT_DEFAULT_DATA_SQL, "Insert default feed weight config data") + + run_ssh_command(ssh, INSERT_MENU_SQL, "Insert admin menu entries for new modules") + + ssh.close() + print(f"\n{'=' * 60}") + print(" All done!") + print(f"{'=' * 60}") + + +if __name__ == "__main__": + main() diff --git a/docs/toolsapi/scripts/upload_v13_rank.py b/docs/toolsapi/scripts/upload_v13_rank.py new file mode 100644 index 00000000..8cf3e154 --- /dev/null +++ b/docs/toolsapi/scripts/upload_v13_rank.py @@ -0,0 +1,83 @@ +"""Upload Phase 4 (Rank System) files + execute migration""" +import paramiko +import os + +HOST = '123.207.67.197' +USER = 'root' +PASS = '520Kiss123' +REMOTE_BASE = '/www/wwwroot/tools.wktyl.com' +LOCAL_BASE = r'e:\project\flutter\f\xianyan\docs\toolsapi' + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect(HOST, username=USER, password=PASS) +sftp = ssh.open_sftp() + +files = [ + ('application/admin/command/Install/migrate_v13.sql', 'application/admin/command/Install/migrate_v13.sql'), + ('application/api/controller/Rank.php', 'application/api/controller/Rank.php'), + ('application/admin/controller/RankSeason.php', 'application/admin/controller/RankSeason.php'), + ('application/admin/model/RankSeason.php', 'application/admin/model/RankSeason.php'), + ('application/admin/view/rank_season/index.html', 'application/admin/view/rank_season/index.html'), + ('application/admin/view/rank_season/add.html', 'application/admin/view/rank_season/add.html'), + ('application/admin/view/rank_season/edit.html', 'application/admin/view/rank_season/edit.html'), + ('public/assets/js/backend/rank_season.js', 'public/assets/js/backend/rank_season.js'), + ('application/admin/lang/zh-cn/rank_season.php', 'application/admin/lang/zh-cn/rank_season.php'), + ('application/admin/controller/user/RankRecord.php', 'application/admin/controller/user/RankRecord.php'), + ('application/admin/model/RankRecord.php', 'application/admin/model/RankRecord.php'), + ('application/admin/view/user/rank_record/index.html', 'application/admin/view/user/rank_record/index.html'), + ('public/assets/js/backend/user/rank_record.js', 'public/assets/js/backend/user/rank_record.js'), + ('application/admin/lang/zh-cn/user/rank_record.php', 'application/admin/lang/zh-cn/user/rank_record.php'), +] + +for local_rel, remote_rel in files: + local = os.path.join(LOCAL_BASE, local_rel) + remote = REMOTE_BASE + '/' + remote_rel + remote_dir = os.path.dirname(remote).replace('\\', '/') + try: + try: + sftp.stat(remote_dir) + except FileNotFoundError: + ssh.exec_command(f'mkdir -p {remote_dir}') + import time; time.sleep(0.3) + sftp.put(local, remote) + print(f'[OK] {remote_rel}') + except Exception as e: + print(f'[FAIL] {remote_rel}: {e}') + +sftp.close() + +# Execute migration SQL +print('\n--- Executing migration SQL ---') +sql_path = '/tmp/migrate_v13.sql' +local_sql = os.path.join(LOCAL_BASE, 'application/admin/command/Install/migrate_v13.sql') +sftp = ssh.open_sftp() +with sftp.open(sql_path, 'w') as f: + f.write(open(local_sql, 'r', encoding='utf-8').read()) +sftp.close() + +cmd = f"mysql -u tools -p'tools' --default-character-set=utf8mb4 tools < {sql_path}" +stdin, stdout, stderr = ssh.exec_command(cmd) +err = stderr.read().decode().strip() +exit_code = stdout.channel.recv_exit_status() +if err: + print(f'SQL ERR: {err[:300]}') +else: + print('SQL executed successfully!') +print(f'Exit: {exit_code}') + +# Insert admin menu entries +print('\n--- Inserting admin menu entries ---') +menu_sql = """INSERT IGNORE INTO tool_auth_rule (id,type,pid,name,title,icon,ismenu,weigh,status) VALUES (250,'file',0,'rank_season','赛季管理','fa fa-trophy',1,0,'normal'),(251,'file',250,'rank_season/index','查看','',0,0,'normal'),(252,'file',250,'rank_season/add','添加','',0,0,'normal'),(253,'file',250,'rank_season/edit','编辑','',0,0,'normal'),(254,'file',250,'rank_season/del','删除','',0,0,'normal'),(255,'file',250,'rank_season/settle','结算','',0,0,'normal'),(260,'file',0,'user/rank_record','排名记录','fa fa-list-ol',1,0,'normal'),(261,'file',260,'user/rank_record/index','查看','',0,0,'normal')""" +cmd2 = f"mysql -u tools -p'tools' --default-character-set=utf8mb4 tools -e \"{menu_sql}\"" +stdin, stdout, stderr = ssh.exec_command(cmd2) +err2 = stderr.read().decode().strip() +exit2 = stdout.channel.recv_exit_status() +if err2: + print(f'Menu ERR: {err2[:200]}') +else: + print('Menu entries inserted!') +print(f'Exit: {exit2}') + +ssh.close() +print('\nAll done!') diff --git a/docs/toolsapi/scripts/upload_v15_admin.py b/docs/toolsapi/scripts/upload_v15_admin.py new file mode 100644 index 00000000..530615a8 --- /dev/null +++ b/docs/toolsapi/scripts/upload_v15_admin.py @@ -0,0 +1,57 @@ +"""Upload Phase 5 (Admin completion) + menu entries""" +import paramiko +import os + +HOST = '123.207.67.197' +USER = 'root' +PASS = '520Kiss123' +REMOTE_BASE = '/www/wwwroot/tools.wktyl.com' +LOCAL_BASE = r'e:\project\flutter\f\xianyan\docs\toolsapi' + +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect(HOST, username=USER, password=PASS) +sftp = ssh.open_sftp() + +files = [ + ('application/admin/controller/user/UserExpLog.php', 'application/admin/controller/user/UserExpLog.php'), + ('application/admin/model/UserExpLog.php', 'application/admin/model/UserExpLog.php'), + ('application/admin/view/user/user_exp_log/index.html', 'application/admin/view/user/user_exp_log/index.html'), + ('public/assets/js/backend/user/user_exp_log.js', 'public/assets/js/backend/user/user_exp_log.js'), + ('application/admin/lang/zh-cn/user/user_exp_log.php', 'application/admin/lang/zh-cn/user/user_exp_log.php'), + ('application/admin/view/user/user/edit.html', 'application/admin/view/user/user/edit.html'), + ('application/admin/lang/zh-cn/user/user.php', 'application/admin/lang/zh-cn/user/user.php'), +] + +for local_rel, remote_rel in files: + local = os.path.join(LOCAL_BASE, local_rel) + remote = REMOTE_BASE + '/' + remote_rel + remote_dir = os.path.dirname(remote).replace('\\', '/') + try: + try: + sftp.stat(remote_dir) + except FileNotFoundError: + ssh.exec_command(f'mkdir -p {remote_dir}') + import time; time.sleep(0.3) + sftp.put(local, remote) + print(f'[OK] {remote_rel}') + except Exception as e: + print(f'[FAIL] {remote_rel}: {e}') + +sftp.close() + +# Insert admin menu entries for EXP log +print('\n--- Inserting admin menu entries ---') +menu_sql = """INSERT IGNORE INTO tool_auth_rule (id,type,pid,name,title,icon,ismenu,weigh,status) VALUES (270,'file',0,'user/user_exp_log','EXP日志','fa fa-bolt',1,0,'normal'),(271,'file',270,'user/user_exp_log/index','查看','',0,0,'normal')""" +cmd = f"mysql -u tools -p'tools' --default-character-set=utf8mb4 tools -e \"{menu_sql}\"" +stdin, stdout, stderr = ssh.exec_command(cmd) +err = stderr.read().decode().strip() +exit_code = stdout.channel.recv_exit_status() +if err: + print(f'Menu ERR: {err[:200]}') +else: + print('Menu entries inserted!') +print(f'Exit: {exit_code}') + +ssh.close() +print('\nAll done!') diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 435723ed..03c803b8 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -74,5 +74,30 @@ NSAllowsArbitraryLoads + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + com.xianyan.app + CFBundleURLSchemes + + xianyan + + + + + + + + com.apple.security.application-groups + + group.com.xianyan.share + diff --git a/lib/app/app.dart b/lib/app/app.dart index 3e6144f7..f2d8819c 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -21,7 +21,7 @@ import 'package:flutter_quill/flutter_quill.dart' show FlutterQuillLocalizations; import '../core/router/app_router.dart'; -import '../core/services/app_lock_service.dart'; +import '../core/services/device/app_lock_service.dart'; import '../core/theme/app_theme.dart'; import '../core/utils/logger.dart'; import '../features/settings/providers/theme_settings_provider.dart'; diff --git a/lib/core/layout/app_shell.dart b/lib/core/layout/app_shell.dart index c75d97c8..5c5ad208 100644 --- a/lib/core/layout/app_shell.dart +++ b/lib/core/layout/app_shell.dart @@ -3,7 +3,7 @@ // 创建时间: 2026-04-20 // 更新时间: 2026-05-14 // 作用: ShellRoute 布局壳,包含底部 GlassBottomBar 导航 + 发现小红点 -// 上次更新: 集成TabIconSprite精灵动画图标,替换静态CupertinoIcons +// 上次更新: 修复底部Tab栏双文本问题 — 移除GlassBottomBarTab的label由TabIconSprite统一控制 // ============================================================ import 'package:badges/badges.dart' as badges; @@ -76,16 +76,18 @@ class AppShell extends ConsumerWidget { bottomNavigationBar: GlassBottomBar( tabs: [ GlassBottomBarTab( + label: '', icon: buildSpriteIcon(TabSpriteType.home, 0, '闲言'), activeIcon: buildSpriteIcon(TabSpriteType.home, 0, '闲言'), glowColor: const Color(0xFFE8E8ED), ), GlassBottomBarTab( + label: '', icon: badges.Badge( showBadge: unreadCount > 0, - badgeContent: Text( - '$unreadCount', - style: const TextStyle( + badgeContent: const Text( + '', + style: TextStyle( color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold, @@ -100,9 +102,9 @@ class AppShell extends ConsumerWidget { ), activeIcon: badges.Badge( showBadge: unreadCount > 0, - badgeContent: Text( - '$unreadCount', - style: const TextStyle( + badgeContent: const Text( + '', + style: TextStyle( color: Colors.white, fontSize: 9, fontWeight: FontWeight.bold, @@ -118,6 +120,7 @@ class AppShell extends ConsumerWidget { glowColor: const Color(0xFFE8E8ED), ), GlassBottomBarTab( + label: '', icon: buildSpriteIcon(TabSpriteType.profile, 2, '我的'), activeIcon: buildSpriteIcon(TabSpriteType.profile, 2, '我的'), glowColor: const Color(0xFFE8E8ED), diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index fb5615ef..2e7a26c3 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -3,7 +3,7 @@ // 创建时间: 2026-04-20 // 更新时间: 2026-05-12 // 作用: go_router 路由表 + ShellRoute 布局壳 + iOS 风格转场 -// 上次更新: 底部Tab"灵感"改名为"足迹",页面仍显示灵感页内容 +// 上次更新: // ============================================================ import 'dart:typed_data'; @@ -15,8 +15,8 @@ import '../../features/home/presentation/home_page.dart'; import '../../features/home/presentation/favorite_page.dart'; import '../../features/home/presentation/history_page.dart'; import '../../features/home/presentation/providers/likes_page.dart'; -import '../../features/inspiration/presentation/footprint_page.dart'; -import '../../features/inspiration/presentation/inspiration_page.dart'; +import '../../features/inspiration/presentation/pages/home/footprint_page.dart'; +import '../../features/inspiration/presentation/pages/home/inspiration_page.dart'; import '../../features/profile/presentation/profile_page.dart'; import '../../features/profile/presentation/about_page.dart'; import '../../features/auth/presentation/login_page.dart'; @@ -27,12 +27,12 @@ import '../../features/note/presentation/note_list_page.dart'; import '../../features/note/presentation/note_edit_page.dart'; import '../../features/statistics/presentation/statistics_page.dart'; import '../../features/correction/presentation/correction_page.dart'; -import '../../features/inspiration/presentation/hanzi_tool_page.dart'; -import '../../features/inspiration/presentation/calc_tool_page.dart'; -import '../../features/inspiration/presentation/china_colors_page.dart'; -import '../../features/inspiration/presentation/tool_list_page.dart'; -import '../../features/inspiration/presentation/ocr_tool_page.dart'; -import '../../features/inspiration/presentation/pinyin_tool_page.dart'; +import '../../features/inspiration/presentation/pages/tool/hanzi_tool_page.dart'; +import '../../features/inspiration/presentation/pages/tool/calc_tool_page.dart'; +import '../../features/inspiration/presentation/pages/tool/china_colors_page.dart'; +import '../../features/inspiration/presentation/pages/tool_list_page.dart'; +import '../../features/inspiration/presentation/pages/tool/ocr_tool_page.dart'; +import '../../features/inspiration/presentation/pages/tool/pinyin_tool_page.dart'; import '../../editor/pages/editor/editor_page.dart'; import '../../editor/pages/tools/image_preview_page.dart'; import '../../editor/pages/tools/image_crop_page.dart'; @@ -48,6 +48,7 @@ import '../../features/settings/presentation/account_settings_page.dart'; import '../../features/settings/presentation/account_deletion_page.dart'; import '../../features/settings/presentation/data_management_page.dart'; import '../../features/settings/presentation/change_password_page.dart'; +import '../../features/settings/presentation/security_question_page.dart'; import '../../features/settings/presentation/font_management_page.dart'; import '../../features/home/presentation/providers/offline_page.dart'; import '../../features/home/presentation/cache_management_page.dart'; @@ -62,6 +63,9 @@ import '../../features/health/presentation/health_page.dart'; import '../../features/game/presentation/game_center_page.dart'; import '../../features/achievement/presentation/achievement_page.dart'; import '../../features/achievement/presentation/checkin_page.dart'; +import '../../features/achievement/presentation/badge_wall_page.dart'; +import '../../features/task/presentation/daily_task_page.dart'; +import '../../features/rank/presentation/rank_page.dart'; import '../../features/article/presentation/article_list_page.dart'; import '../../features/article/presentation/article_detail_page.dart'; import '../../features/article/presentation/article_edit_page.dart'; @@ -74,9 +78,10 @@ import '../../features/user_center/presentation/user_center_page.dart'; import '../../features/user_center/presentation/tag_cloud_page.dart'; import '../../features/user_center/presentation/user_debug_page.dart'; import '../../features/statistics/presentation/user_stats_page.dart'; -import '../../features/inspiration/presentation/chat_flow_page.dart'; -import '../../features/inspiration/presentation/chat_settings_page.dart'; -import '../../features/inspiration/presentation/hidden_sessions_page.dart'; +import '../../features/inspiration/presentation/pages/chat/chat_flow_page.dart'; +import '../../features/inspiration/models/chat_session.dart'; +import '../../features/inspiration/presentation/pages/chat/chat_settings_page.dart'; +import '../../features/inspiration/presentation/pages/chat/hidden_sessions_page.dart'; import '../../features/file_transfer/presentation/pages/file_transfer_page.dart'; import '../../features/file_transfer/presentation/pages/transfer_chat_page.dart'; import '../../features/file_transfer/presentation/pages/device_pairing_page.dart'; @@ -129,6 +134,7 @@ class AppRoutes { static const String generalSettings = '/settings/general'; static const String accountSettings = '/settings/account'; static const String passwordSettings = '/settings/password'; + static const String securityQuestion = '/settings/security-question'; static const String dataManagement = '/settings/data'; static const String accountDeletion = '/settings/account/deletion'; static const String fontManagement = '/settings/fonts'; @@ -157,6 +163,9 @@ class AppRoutes { static const String game = '/game'; static const String achievement = '/achievement'; static const String checkin = '/achievement/checkin'; + static const String badgeWall = '/badge-wall'; + static const String dailyTask = '/daily-task'; + static const String rank = '/rank'; static const String articles = '/articles'; static const String articleDetail = '/article/:id'; static const String articleEdit = '/article/edit'; @@ -202,6 +211,7 @@ class AppRoutes { static const String canvas = '/canvas'; static const String clipboard = '/clipboard'; static const String screenShare = '/screen-share'; + static const String readlaterChat = '/readlater-chat'; } // ============================================================ @@ -430,6 +440,15 @@ final GoRouter appRouter = GoRouter( iosSlideTransition(state: state, child: const ChangePasswordPage()), ), + // 密保问题管理 — iOS 滑入 + GoRoute( + path: AppRoutes.securityQuestion, + name: 'security-question', + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (context, state) => + iosSlideTransition(state: state, child: const SecurityQuestionPage()), + ), + // 数据管理 — iOS 滑入 GoRoute( path: AppRoutes.dataManagement, @@ -738,6 +757,20 @@ final GoRouter appRouter = GoRouter( iosSlideTransition(state: state, child: const HiddenSessionsPage()), ), + // 稍后读会话 — iOS 滑入 + GoRoute( + path: AppRoutes.readlaterChat, + name: 'readlater-chat', + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (context, state) => iosSlideTransition( + state: state, + child: const ChatFlowPage( + conversationId: 'readlater', + sessionType: ChatSessionType.readlater, + ), + ), + ), + // 文件传输助手 — iOS 滑入 GoRoute( path: AppRoutes.fileTransfer, @@ -1125,6 +1158,33 @@ final GoRouter appRouter = GoRouter( iosSlideTransition(state: state, child: const CheckinPage()), ), + // 勋章墙 — iOS 滑入 + GoRoute( + path: AppRoutes.badgeWall, + name: 'badge-wall', + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (context, state) => + iosSlideTransition(state: state, child: const BadgeWallPage()), + ), + + // 每日任务 — iOS 滑入 + GoRoute( + path: AppRoutes.dailyTask, + name: 'daily-task', + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (context, state) => + iosSlideTransition(state: state, child: const DailyTaskPage()), + ), + + // 赛季排行榜 — iOS 滑入 + GoRoute( + path: AppRoutes.rank, + name: 'rank', + parentNavigatorKey: rootNavigatorKey, + pageBuilder: (context, state) => + iosSlideTransition(state: state, child: const RankPage()), + ), + // 文章广场 — iOS 滑入 GoRoute( path: AppRoutes.articles, diff --git a/lib/core/services/permission_service.dart b/lib/core/services/auth/permission_service.dart similarity index 100% rename from lib/core/services/permission_service.dart rename to lib/core/services/auth/permission_service.dart diff --git a/lib/core/services/token_service.dart b/lib/core/services/auth/token_service.dart similarity index 96% rename from lib/core/services/token_service.dart rename to lib/core/services/auth/token_service.dart index 8f30213d..20a858ec 100644 --- a/lib/core/services/token_service.dart +++ b/lib/core/services/auth/token_service.dart @@ -8,9 +8,9 @@ import 'dart:async'; -import '../network/api_client.dart'; -import '../storage/secure_storage.dart'; -import '../utils/logger.dart'; +import '../../network/api_client.dart'; +import '../../storage/secure_storage.dart'; +import '../../utils/logger.dart'; class TokenService { TokenService._(); diff --git a/lib/core/services/validate_service.dart b/lib/core/services/auth/validate_service.dart similarity index 97% rename from lib/core/services/validate_service.dart rename to lib/core/services/auth/validate_service.dart index 070bad1e..0e8e981e 100644 --- a/lib/core/services/validate_service.dart +++ b/lib/core/services/auth/validate_service.dart @@ -6,9 +6,9 @@ /// 上次更新: 初始创建 /// ============================================================ -import '../network/api_client.dart'; -import '../network/api_response.dart'; -import '../utils/logger.dart'; +import '../../network/api_client.dart'; +import '../../network/api_response.dart'; +import '../../utils/logger.dart'; class ValidateService { ValidateService._(); diff --git a/lib/core/services/clipboard_monitor_service.dart b/lib/core/services/clipboard_monitor_service.dart new file mode 100644 index 00000000..f36c2042 --- /dev/null +++ b/lib/core/services/clipboard_monitor_service.dart @@ -0,0 +1,160 @@ +/// ============================================================ +/// 闲言APP — 剪贴板链接监控服务 +/// 创建时间: 2026-05-15 +/// 更新时间: 2026-05-15 +/// 作用: 自动检测剪贴板中的URL链接并提示保存到稍后读 +/// 上次更新: E10 初始创建,支持3秒轮询剪贴板+URL检测+隐私保护 +/// ============================================================ + +import 'dart:async'; + +import 'package:flutter/services.dart'; + +import '../../core/storage/app_kv_store.dart'; +import '../../core/utils/logger.dart'; +import '../../shared/widgets/app_toast.dart'; +import '../../features/inspiration/services/chat_message_service.dart'; + +class ClipboardMonitorService { + ClipboardMonitorService._(); + + static final instance = ClipboardMonitorService._(); + + static const _keyEnabled = 'clipboard_monitor_enabled'; + static const _readlaterConvId = 'readlater'; + static const _monitorInterval = Duration(seconds: 3); + + String? _lastClipboardText; + Timer? _monitorTimer; + bool _enabled = false; + + bool get isEnabled => _enabled; + + // ============================================================ + // 启动监控 + // ============================================================ + + void startMonitor() { + if (_enabled) return; + + _enabled = true; + _monitorTimer?.cancel(); + _monitorTimer = Timer.periodic(_monitorInterval, (_) { + _checkClipboard(); + }); + + Log.i('ClipboardMonitor: 剪贴板监控已启动 (每3秒检查)'); + } + + // ============================================================ + // 停止监控 + // ============================================================ + + void stopMonitor() { + _monitorTimer?.cancel(); + _monitorTimer = null; + _enabled = false; + _lastClipboardText = null; + + Log.i('ClipboardMonitor: 剪贴板监控已停止'); + } + + // ============================================================ + // 检查剪贴板 + // ============================================================ + + Future _checkClipboard() async { + if (!_enabled) return; + + try { + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + final text = clipboardData?.text; + + if (text == null || text.isEmpty || text == _lastClipboardText) return; + + _lastClipboardText = text; + + if (_isUrl(text)) { + Log.i('ClipboardMonitor: 检测到URL — $text'); + await _saveToReadlater(text); + } + } catch (e) { + Log.e('ClipboardMonitor: 检查剪贴板失败', e); + } + } + + // ============================================================ + // 检测URL — 仅匹配http/https协议链接 + // ============================================================ + + bool _isUrl(String text) { + final trimmed = text.trim(); + if (trimmed.isEmpty) return false; + + final urlRegex = RegExp( + r'^https?://[^\s<>"{}|\\^`\[\]]+$', + caseSensitive: false, + ); + return urlRegex.hasMatch(trimmed); + } + + // ============================================================ + // 保存到稍后读 + // ============================================================ + + Future _saveToReadlater(String url) async { + try { + await ChatMessageService.sendLink( + conversationId: _readlaterConvId, + url: url, + sourceApp: '剪贴板监控', + ); + + AppToast.show('📋 检测到链接,已保存到稍后读'); + + Log.i('ClipboardMonitor: URL已保存到稍后读 — $url'); + } catch (e) { + Log.e('ClipboardMonitor: 保存到稍后读失败', e); + } + } + + // ============================================================ + // 开关设置 + // ============================================================ + + Future setEnabled(bool enabled) async { + _enabled = enabled; + await AppKVStore.setBool(_keyEnabled, enabled); + + if (enabled) { + startMonitor(); + } else { + stopMonitor(); + } + + Log.i('ClipboardMonitor: 开关设置为 ${enabled ? "开启" : "关闭"}'); + } + + bool getEnabled() { + return AppKVStore.getBool(_keyEnabled) ?? false; + } + + // ============================================================ + // 初始化 — 根据存储的开关状态决定是否启动 + // ============================================================ + + Future initFromStore() async { + final stored = AppKVStore.getBool(_keyEnabled) ?? false; + if (stored) { + startMonitor(); + } + } + + // ============================================================ + // 释放资源 + // ============================================================ + + void dispose() { + stopMonitor(); + } +} diff --git a/lib/core/services/backup_service.dart b/lib/core/services/data/backup_service.dart similarity index 98% rename from lib/core/services/backup_service.dart rename to lib/core/services/data/backup_service.dart index c2eded28..e0096b3b 100644 --- a/lib/core/services/backup_service.dart +++ b/lib/core/services/data/backup_service.dart @@ -14,9 +14,9 @@ import 'package:crypto/crypto.dart'; import 'package:hive/hive.dart'; import 'package:path_provider/path_provider.dart'; -import '../storage/database/app_database.dart'; -import '../storage/kv_storage.dart'; -import '../utils/logger.dart'; +import '../../storage/database/app_database.dart'; +import '../../storage/kv_storage.dart'; +import '../../utils/logger.dart'; class BackupService { BackupService._(); diff --git a/lib/core/services/data_export_service.dart b/lib/core/services/data/data_export_service.dart similarity index 97% rename from lib/core/services/data_export_service.dart rename to lib/core/services/data/data_export_service.dart index 7a37a74a..df918bcf 100644 --- a/lib/core/services/data_export_service.dart +++ b/lib/core/services/data/data_export_service.dart @@ -12,9 +12,9 @@ import 'dart:io'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; -import '../storage/database/app_database.dart'; -import '../storage/kv_storage.dart'; -import '../utils/logger.dart'; +import '../../storage/database/app_database.dart'; +import '../../storage/kv_storage.dart'; +import '../../utils/logger.dart'; class DataExportService { DataExportService._(); diff --git a/lib/core/services/data/home_widget_service.dart b/lib/core/services/data/home_widget_service.dart new file mode 100644 index 00000000..46fb8fde --- /dev/null +++ b/lib/core/services/data/home_widget_service.dart @@ -0,0 +1,201 @@ +/// ============================================================ +/// 闲言APP — 桌面小组件数据管理服务 +/// 创建时间: 2026-05-15 +/// 更新时间: 2026-05-15 +/// 作用: 基于home_widget库管理桌面小组件数据推送与交互 +/// 上次更新: E13 初始创建,支持稍后读计数/预览/每日一句/点击回调 +/// ============================================================ + +import 'package:home_widget/home_widget.dart'; + +import '../../utils/logger.dart'; + +class HomeWidgetService { + HomeWidgetService._(); + + static final instance = HomeWidgetService._(); + + static const String _androidWidgetName = 'XianyanReadlaterWidget'; + static const String _iosWidgetName = 'XianyanReadlaterWidget'; + static const String _appGroupId = 'group.com.xianyan.share'; + + static const String _keyReadlaterCount = 'readlater_count'; + static const String _keyReadlaterPreviewText = 'readlater_preview_text'; + static const String _keyReadlaterPreviewAuthor = 'readlater_preview_author'; + static const String _keyDailySentence = 'daily_sentence'; + static const String _keyDailySentenceAuthor = 'daily_sentence_author'; + + bool _initialized = false; + + bool get isInitialized => _initialized; + + // ============================================================ + // 初始化 + // ============================================================ + + Future init() async { + if (_initialized) return; + + try { + await HomeWidget.setAppGroupId(_appGroupId); + _initialized = true; + Log.i('HomeWidgetService: 初始化完成 (appGroup: $_appGroupId)'); + } catch (e) { + Log.e('HomeWidgetService: 初始化失败', e); + } + } + + // ============================================================ + // 更新稍后读未读数 + // ============================================================ + + Future updateReadlaterCount(int count) async { + try { + await _ensureInit(); + await HomeWidget.saveWidgetData(_keyReadlaterCount, count); + await _notifyUpdate(); + Log.i('HomeWidgetService: 稍后读未读数更新为 $count'); + } catch (e) { + Log.e('HomeWidgetService: 更新稍后读未读数失败', e); + } + } + + // ============================================================ + // 更新最新稍后读内容预览 + // ============================================================ + + Future updateReadlaterPreview(String text, String? author) async { + try { + await _ensureInit(); + await HomeWidget.saveWidgetData( + _keyReadlaterPreviewText, + text.length > 80 ? '${text.substring(0, 80)}...' : text, + ); + await HomeWidget.saveWidgetData( + _keyReadlaterPreviewAuthor, + author ?? '', + ); + await _notifyUpdate(); + Log.i('HomeWidgetService: 稍后读预览已更新'); + } catch (e) { + Log.e('HomeWidgetService: 更新稍后读预览失败', e); + } + } + + // ============================================================ + // 更新每日一句 + // ============================================================ + + Future updateDailySentence(String text, String author) async { + try { + await _ensureInit(); + await HomeWidget.saveWidgetData(_keyDailySentence, text); + await HomeWidget.saveWidgetData( + _keyDailySentenceAuthor, + author, + ); + await _notifyUpdate(); + Log.i('HomeWidgetService: 每日一句已更新'); + } catch (e) { + Log.e('HomeWidgetService: 更新每日一句失败', e); + } + } + + // ============================================================ + // 获取小组件点击数据 + // ============================================================ + + Future handleWidgetClick() async { + try { + await _ensureInit(); + final data = await HomeWidget.getWidgetData('clicked_data'); + if (data != null && data.isNotEmpty) { + Log.i('HomeWidgetService: 小组件点击数据 — $data'); + } + } catch (e) { + Log.e('HomeWidgetService: 获取小组件点击数据失败', e); + } + } + + // ============================================================ + // 注册交互回调 + // ============================================================ + + void registerInteractivityCallback() { + try { + HomeWidget.registerInteractivityCallback( + _backgroundCallback, + ); + Log.i('HomeWidgetService: 交互回调已注册'); + } catch (e) { + Log.e('HomeWidgetService: 注册交互回调失败', e); + } + } + + // ============================================================ + // 后台回调 — 处理小组件交互 + // ============================================================ + + static void _backgroundCallback(Uri? uri) { + if (uri == null) return; + Log.i('HomeWidgetService: 后台回调 — ${uri.toString()}'); + + final action = uri.host; + switch (action) { + case 'open_readlater': + Log.i('HomeWidgetService: 打开稍后读列表'); + break; + case 'open_sentence': + final id = uri.queryParameters['id']; + Log.i('HomeWidgetService: 打开句子详情 id=$id'); + break; + default: + Log.i('HomeWidgetService: 未知操作 — $action'); + } + } + + // ============================================================ + // 通知原生小组件刷新 + // ============================================================ + + Future _notifyUpdate() async { + try { + await HomeWidget.updateWidget( + androidName: _androidWidgetName, + iOSName: _iosWidgetName, + ); + } catch (e) { + Log.w('HomeWidgetService: 通知小组件刷新失败 (可能未安装小组件)', e); + } + } + + // ============================================================ + // 确保已初始化 + // ============================================================ + + Future _ensureInit() async { + if (!_initialized) { + await init(); + } + } + + // ============================================================ + // 读取当前小组件数据(调试用) + // ============================================================ + + Future> debugGetAllData() async { + try { + await _ensureInit(); + return { + _keyReadlaterCount: await HomeWidget.getWidgetData(_keyReadlaterCount), + _keyReadlaterPreviewText: await HomeWidget.getWidgetData(_keyReadlaterPreviewText), + _keyReadlaterPreviewAuthor: await HomeWidget.getWidgetData(_keyReadlaterPreviewAuthor), + _keyDailySentence: await HomeWidget.getWidgetData(_keyDailySentence), + _keyDailySentenceAuthor: await HomeWidget.getWidgetData(_keyDailySentenceAuthor), + }; + } catch (e) { + Log.e('HomeWidgetService: 读取小组件数据失败', e); + return {}; + } + } +} diff --git a/lib/core/services/jinrishici_sdk_service.dart b/lib/core/services/data/jinrishici_sdk_service.dart similarity index 97% rename from lib/core/services/jinrishici_sdk_service.dart rename to lib/core/services/data/jinrishici_sdk_service.dart index a8243039..dc0318f5 100644 --- a/lib/core/services/jinrishici_sdk_service.dart +++ b/lib/core/services/data/jinrishici_sdk_service.dart @@ -8,8 +8,8 @@ import 'package:dio/dio.dart'; -import '../storage/app_kv_store.dart'; -import '../utils/logger.dart'; +import '../../storage/app_kv_store.dart'; +import '../../utils/logger.dart'; class JinrishiciSdkService { JinrishiciSdkService._(); diff --git a/lib/core/services/settings_export_service.dart b/lib/core/services/data/settings_export_service.dart similarity index 98% rename from lib/core/services/settings_export_service.dart rename to lib/core/services/data/settings_export_service.dart index 3c008bcc..6a87efc3 100644 --- a/lib/core/services/settings_export_service.dart +++ b/lib/core/services/data/settings_export_service.dart @@ -13,8 +13,8 @@ import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:file_picker/file_picker.dart'; -import '../storage/kv_storage.dart'; -import '../utils/logger.dart'; +import '../../storage/kv_storage.dart'; +import '../../utils/logger.dart'; class SettingsExportService { SettingsExportService._(); diff --git a/lib/core/services/app_lock_service.dart b/lib/core/services/device/app_lock_service.dart similarity index 98% rename from lib/core/services/app_lock_service.dart rename to lib/core/services/device/app_lock_service.dart index 957c7f9e..abeabc56 100644 --- a/lib/core/services/app_lock_service.dart +++ b/lib/core/services/device/app_lock_service.dart @@ -11,8 +11,8 @@ import 'dart:async'; import 'package:flutter/services.dart'; import 'package:local_auth/local_auth.dart'; -import '../storage/kv_storage.dart'; -import '../utils/logger.dart'; +import '../../storage/kv_storage.dart'; +import '../../utils/logger.dart'; class AppLockService { AppLockService._(); diff --git a/lib/core/services/battery_optimization_service.dart b/lib/core/services/device/battery_optimization_service.dart similarity index 97% rename from lib/core/services/battery_optimization_service.dart rename to lib/core/services/device/battery_optimization_service.dart index d9344d0b..6e1ca08c 100644 --- a/lib/core/services/battery_optimization_service.dart +++ b/lib/core/services/device/battery_optimization_service.dart @@ -10,8 +10,8 @@ import 'dart:async'; import 'package:battery_plus/battery_plus.dart'; -import '../storage/kv_storage.dart'; -import '../utils/logger.dart'; +import '../../storage/kv_storage.dart'; +import '../../utils/logger.dart'; class BatteryOptimizationService { BatteryOptimizationService._(); diff --git a/lib/core/services/device_info_service.dart b/lib/core/services/device/device_info_service.dart similarity index 96% rename from lib/core/services/device_info_service.dart rename to lib/core/services/device/device_info_service.dart index 8c2e7783..392af5cf 100644 --- a/lib/core/services/device_info_service.dart +++ b/lib/core/services/device/device_info_service.dart @@ -13,10 +13,10 @@ import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../../features/file_transfer/services/ip_location_service.dart'; -import '../network/api_client.dart'; -import '../network/api_response.dart'; -import '../utils/logger.dart'; +import '../../../features/file_transfer/services/ip_location_service.dart'; +import '../../network/api_client.dart'; +import '../../network/api_response.dart'; +import '../../utils/logger.dart'; class DeviceInfoService { DeviceInfoService._(); diff --git a/lib/core/services/haptic_service.dart b/lib/core/services/device/haptic_service.dart similarity index 100% rename from lib/core/services/haptic_service.dart rename to lib/core/services/device/haptic_service.dart diff --git a/lib/core/services/screen_wake_service.dart b/lib/core/services/device/screen_wake_service.dart similarity index 97% rename from lib/core/services/screen_wake_service.dart rename to lib/core/services/device/screen_wake_service.dart index b3dd444a..e3b8d697 100644 --- a/lib/core/services/screen_wake_service.dart +++ b/lib/core/services/device/screen_wake_service.dart @@ -8,8 +8,8 @@ import 'package:wakelock_plus/wakelock_plus.dart'; -import '../storage/kv_storage.dart'; -import '../utils/logger.dart'; +import '../../storage/kv_storage.dart'; +import '../../utils/logger.dart'; /// 屏幕超时策略 enum ScreenTimeoutMode { diff --git a/lib/core/services/connectivity_service.dart b/lib/core/services/network/connectivity_service.dart similarity index 97% rename from lib/core/services/connectivity_service.dart rename to lib/core/services/network/connectivity_service.dart index a8d73956..e0fb58a0 100644 --- a/lib/core/services/connectivity_service.dart +++ b/lib/core/services/network/connectivity_service.dart @@ -11,8 +11,8 @@ import 'dart:async'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../utils/logger.dart'; -import '../../shared/widgets/app_toast.dart'; +import '../../utils/logger.dart'; +import '../../../shared/widgets/app_toast.dart'; enum NetworkType { wifi, mobile, ethernet, vpn, none, other } diff --git a/lib/core/services/deep_link_service.dart b/lib/core/services/network/deep_link_service.dart similarity index 98% rename from lib/core/services/deep_link_service.dart rename to lib/core/services/network/deep_link_service.dart index c441243a..dae2439f 100644 --- a/lib/core/services/deep_link_service.dart +++ b/lib/core/services/network/deep_link_service.dart @@ -9,8 +9,8 @@ import 'package:app_links/app_links.dart'; import 'package:go_router/go_router.dart'; -import '../../core/router/app_router.dart'; -import '../../core/utils/logger.dart'; +import '../../../core/router/app_router.dart'; +import '../../../core/utils/logger.dart'; class DeepLinkService { DeepLinkService._(); diff --git a/lib/core/services/network_proxy_service.dart b/lib/core/services/network/network_proxy_service.dart similarity index 97% rename from lib/core/services/network_proxy_service.dart rename to lib/core/services/network/network_proxy_service.dart index 76637fa6..d6705329 100644 --- a/lib/core/services/network_proxy_service.dart +++ b/lib/core/services/network/network_proxy_service.dart @@ -8,8 +8,8 @@ import 'dart:io'; -import '../storage/kv_storage.dart'; -import '../utils/logger.dart'; +import '../../storage/kv_storage.dart'; +import '../../utils/logger.dart'; enum ProxyType { none('不使用', 'none'), diff --git a/lib/core/services/network/og_metadata_service.dart b/lib/core/services/network/og_metadata_service.dart new file mode 100644 index 00000000..83b7fddc --- /dev/null +++ b/lib/core/services/network/og_metadata_service.dart @@ -0,0 +1,216 @@ +// ============================================================ +// 闲言APP — 链接OG元数据异步抓取服务 +// 创建时间: 2026-05-15 +// 更新时间: 2026-05-15 +// 作用: 链接消息OG元数据异步抓取,用于稍后读链接消息预览 +// 上次更新: 初始创建,支持compute/isolate解析+5秒超时+静默降级 +// ============================================================ + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; + +import '../../utils/logger.dart'; + +/// OG元数据模型 +class OgMetadata { + const OgMetadata({ + this.title, + this.description, + this.imageUrl, + this.siteName, + }); + + final String? title; + final String? description; + final String? imageUrl; + final String? siteName; + + bool get isEmpty => + title == null && + description == null && + imageUrl == null && + siteName == null; + + bool get isNotEmpty => !isEmpty; + + Map toJson() => { + if (title != null) 'title': title, + if (description != null) 'description': description, + if (imageUrl != null) 'imageUrl': imageUrl, + if (siteName != null) 'siteName': siteName, + }; + + @override + String toString() => + 'OgMetadata(title: $title, description: $description, ' + 'imageUrl: $imageUrl, siteName: $siteName)'; +} + +/// 链接OG元数据抓取服务 +class OgMetadataService { + OgMetadataService._(); + + static final Dio _dio = Dio(BaseOptions( + connectTimeout: const Duration(seconds: 5), + receiveTimeout: const Duration(seconds: 5), + sendTimeout: const Duration(seconds: 5), + headers: { + 'User-Agent': + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ' + 'AppleWebKit/605.1.15 (KHTML, like Gecko) ' + 'Version/17.0 Mobile/15E148 Safari/604.1', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + }, + responseType: ResponseType.plain, + followRedirects: true, + maxRedirects: 5, + )); + + /// 异步抓取链接OG元数据 + /// 完全异步非阻塞,失败时静默降级返回null + static Future fetch(String url) async { + try { + final response = await _dio.get(url); + final html = response.data; + if (html == null || html.isEmpty) return null; + + final metadata = await compute(_parseHtmlIsolate, _ParseArgs(html, url)); + return metadata; + } on DioException catch (e) { + Log.w('OG元数据抓取网络异常: $url', e.message); + return null; + } catch (e) { + Log.w('OG元数据抓取失败: $url', e); + return null; + } + } + + /// 在isolate中解析HTML,提取OG标签 + /// 此方法必须是顶级函数或静态方法,供compute调用 + static OgMetadata? parseHtml(String html, String url) { + return _parseHtmlIsolate(_ParseArgs(html, url)); + } +} + +/// compute传参封装(isolate只能传一个参数) +class _ParseArgs { + const _ParseArgs(this.html, this.url); + + final String html; + final String url; +} + +/// isolate中执行的HTML解析函数 +OgMetadata? _parseHtmlIsolate(_ParseArgs args) { + try { + final html = args.html; + final url = args.url; + + String? ogTitle = _extractMetaContent(html, 'og:title'); + String? ogDescription = _extractMetaContent(html, 'og:description'); + String? ogImage = _extractMetaContent(html, 'og:image'); + final String? ogSiteName = _extractMetaContent(html, 'og:site_name'); + + // 降级:使用title标签 + ogTitle ??= _extractTitleTag(html); + + // 降级:使用meta description + ogDescription ??= _extractMetaContent(html, 'description'); + + // 降级:使用favicon作为图片 + ogImage ??= _extractFavicon(html, url); + + final metadata = OgMetadata( + title: ogTitle, + description: ogDescription, + imageUrl: ogImage, + siteName: ogSiteName, + ); + + return metadata.isEmpty ? null : metadata; + } catch (_) { + return null; + } +} + +/// 提取meta标签content属性值 +/// 支持 property="og:title" 和 name="description" 两种格式 +String? _extractMetaContent(String html, String property) { + var pattern = RegExp( + r''']+(?:property|name)=["']''' + property + r'''["'][^>]*>''', + caseSensitive: false, + ); + var match = pattern.firstMatch(html); + if (match != null) { + final content = _extractContentAttr(match.group(0)!); + if (content != null && content.isNotEmpty) return content; + } + + pattern = RegExp( + r''']+content=["']([^"']*)["'][^>]*(?:property|name)=["']''' + + property + + r'''["'][^>]*>''', + caseSensitive: false, + ); + match = pattern.firstMatch(html); + if (match != null) { + final content = _extractContentAttr(match.group(0)!); + if (content != null && content.isNotEmpty) return content; + } + + return null; +} + +/// 从meta标签字符串中提取content属性值 +String? _extractContentAttr(String metaTag) { + final contentPattern = RegExp( + r'''content=["']([^"']*)["']''', + caseSensitive: false, + ); + final match = contentPattern.firstMatch(metaTag); + return match?.group(1)?.trim(); +} + +/// 提取标签内容 +String? _extractTitleTag(String html) { + final pattern = RegExp(r'<title[^>]*>(.*?)', caseSensitive: false); + final match = pattern.firstMatch(html); + return match?.group(1)?.trim(); +} + +/// 提取favicon URL +String? _extractFavicon(String html, String baseUrl) { + var pattern = RegExp( + r''']+rel=["'](?:shortcut )?icon["'][^>]+href=["']([^"']*)["']''', + caseSensitive: false, + ); + var match = pattern.firstMatch(html); + if (match != null) { + return _resolveUrl(match.group(1)!, baseUrl); + } + + pattern = RegExp( + r''']+href=["']([^"']*)["'][^>]+rel=["'](?:shortcut )?icon["']''', + caseSensitive: false, + ); + match = pattern.firstMatch(html); + if (match != null) { + return _resolveUrl(match.group(1)!, baseUrl); + } + + return null; +} + +/// 将相对URL转为绝对URL +String? _resolveUrl(String href, String baseUrl) { + if (href.startsWith('http://') || href.startsWith('https://')) { + return href; + } + try { + final base = Uri.parse(baseUrl); + return base.resolve(href).toString(); + } catch (_) { + return null; + } +} diff --git a/lib/core/services/local_notification_service.dart b/lib/core/services/notification/local_notification_service.dart similarity index 91% rename from lib/core/services/local_notification_service.dart rename to lib/core/services/notification/local_notification_service.dart index ed0c92f0..a969333a 100644 --- a/lib/core/services/local_notification_service.dart +++ b/lib/core/services/notification/local_notification_service.dart @@ -3,7 +3,7 @@ /// 创建时间: 2026-05-02 /// 更新时间: 2026-05-13 /// 作用: 本地推送通知管理 (初始化/调度/取消/点击处理) -/// 上次更新: 增加运势推送路由 +/// 上次更新: 增加运势推送路由+E6稍后读提醒 /// ============================================================ import 'dart:io'; @@ -14,10 +14,10 @@ import 'package:go_router/go_router.dart'; import 'package:timezone/data/latest_all.dart' as tz; import 'package:timezone/timezone.dart' as tz; -import '../router/app_router.dart'; -import '../services/notification_scheduler.dart'; -import '../storage/app_kv_store.dart'; -import '../utils/logger.dart'; +import '../../router/app_router.dart'; +import 'notification_scheduler.dart'; +import '../../storage/app_kv_store.dart'; +import '../../utils/logger.dart'; class LocalNotificationService { LocalNotificationService._(); @@ -116,6 +116,8 @@ class LocalNotificationService { router.go('/countdown'); case 'daily_fortune': router.go('/daily-fortune'); + case 'readlater': + router.go('/readlater-chat'); default: router.go('/home'); } @@ -262,6 +264,26 @@ class LocalNotificationService { return scheduledDate; } + static Future scheduleReadLaterReminder({ + required int unreadCount, + int hour = 20, + int minute = 0, + }) async { + if (unreadCount <= 0) return; + await scheduleDaily( + id: 100, + title: '📖 稍后读提醒', + body: '你有 $unreadCount 条稍后读内容待阅读', + hour: hour, + minute: minute, + payload: 'readlater', + ); + } + + static Future cancelReadLaterReminder() async { + await cancel(100); + } + static Future setupDailyNotifications(WidgetRef ref) async { if (!NotificationScheduler.isNotificationsEnabled) { await cancelAll(); diff --git a/lib/core/services/notification_scheduler.dart b/lib/core/services/notification/notification_scheduler.dart similarity index 97% rename from lib/core/services/notification_scheduler.dart rename to lib/core/services/notification/notification_scheduler.dart index d8c36f6a..d2842380 100644 --- a/lib/core/services/notification_scheduler.dart +++ b/lib/core/services/notification/notification_scheduler.dart @@ -6,9 +6,9 @@ /// 上次更新: 增加运势推送调度 /// ============================================================ -import '../../core/services/local_notification_service.dart'; -import '../../core/storage/app_kv_store.dart'; -import '../../core/utils/logger.dart'; +import 'local_notification_service.dart'; +import '../../storage/app_kv_store.dart'; +import '../../utils/logger.dart'; class NotificationScheduler { NotificationScheduler._(); @@ -147,11 +147,9 @@ class NotificationScheduler { static bool get isFortuneEnabled => AppKVStore.getBool(_keyFortuneEnabled) ?? false; - static int get fortuneHour => - AppKVStore.getInt(_keyFortuneHour) ?? 8; + static int get fortuneHour => AppKVStore.getInt(_keyFortuneHour) ?? 8; - static int get fortuneMinute => - AppKVStore.getInt(_keyFortuneMinute) ?? 0; + static int get fortuneMinute => AppKVStore.getInt(_keyFortuneMinute) ?? 0; static Future setNotificationsEnabled(bool v) async { await AppKVStore.setBool(_keyNotificationsEnabled, v); diff --git a/lib/core/services/notification_service.dart b/lib/core/services/notification/notification_service.dart similarity index 99% rename from lib/core/services/notification_service.dart rename to lib/core/services/notification/notification_service.dart index 7ec6127a..eb0a6017 100644 --- a/lib/core/services/notification_service.dart +++ b/lib/core/services/notification/notification_service.dart @@ -14,7 +14,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:timezone/data/latest_all.dart' as tz; import 'package:timezone/timezone.dart' as tz; -import '../utils/logger.dart'; +import '../../utils/logger.dart'; class NotificationService { NotificationService._(); diff --git a/lib/core/services/readlater_reminder_service.dart b/lib/core/services/notification/readlater_reminder_service.dart similarity index 97% rename from lib/core/services/readlater_reminder_service.dart rename to lib/core/services/notification/readlater_reminder_service.dart index a6501aac..d03d421d 100644 --- a/lib/core/services/readlater_reminder_service.dart +++ b/lib/core/services/notification/readlater_reminder_service.dart @@ -11,9 +11,9 @@ import 'dart:async'; import 'package:battery_plus/battery_plus.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../utils/logger.dart'; +import '../../utils/logger.dart'; import 'notification_service.dart'; -import '../../features/user_center/services/user_center_service.dart'; +import '../../../features/user_center/services/user_center_service.dart'; class ReadlaterReminderService { ReadlaterReminderService._(); diff --git a/lib/core/services/readlater/readlater_ai_service.dart b/lib/core/services/readlater/readlater_ai_service.dart new file mode 100644 index 00000000..9b4497e9 --- /dev/null +++ b/lib/core/services/readlater/readlater_ai_service.dart @@ -0,0 +1,268 @@ +/// ============================================================ +/// 闲言APP — 稍后读AI摘要生成服务 +/// 创建时间: 2026-05-15 +/// 更新时间: 2026-05-15 +/// 作用: 基于Supabase Edge Functions为稍后读消息生成AI摘要 +/// 上次更新: E15 初始创建,支持单条摘要/批量摘要/每日摘要/智能标签 +/// ============================================================ + +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../../core/constants/app_constants.dart'; +import '../../../core/utils/logger.dart'; +import '../../../features/inspiration/models/chat_message.dart'; + +class ReadlaterAiService { + ReadlaterAiService._(); + + static final instance = ReadlaterAiService._(); + + static const String _edgeFunctionName = 'generate-readlater-summary'; + + SupabaseClient get _client => Supabase.instance.client; + + String? get _currentUserId { + try { + return _client.auth.currentUser?.id; + } catch (_) { + return null; + } + } + + // ============================================================ + // 生成单条消息摘要 + // ============================================================ + + Future generateSummary(ChatMessage message) async { + try { + final userId = _currentUserId; + if (userId == null) { + Log.w('AiSummary: 未登录,无法生成摘要'); + return null; + } + + final content = _buildContent(message); + if (content.isEmpty) { + Log.w('AiSummary: 消息内容为空,跳过'); + return null; + } + + final response = await _client.functions.invoke( + _edgeFunctionName, + body: { + 'content': content, + 'type': message.type.id, + 'action': 'summary', + 'user_id': userId, + }, + ); + + final data = response.data as Map?; + final summary = data?['summary'] as String?; + + if (summary != null && summary.isNotEmpty) { + Log.i('AiSummary: 生成摘要成功 — ${message.id}'); + } + + return summary; + } catch (e) { + Log.e('AiSummary: 生成摘要失败 — ${message.id}', e); + return null; + } + } + + // ============================================================ + // 批量生成摘要 + // ============================================================ + + Future> batchSummarize( + List messages, + ) async { + final result = {}; + + if (messages.isEmpty) return result; + + try { + final userId = _currentUserId; + if (userId == null) { + Log.w('AiSummary: 未登录,无法批量生成摘要'); + return result; + } + + final contents = >[]; + for (final msg in messages) { + final content = _buildContent(msg); + if (content.isNotEmpty) { + contents.add({ + 'id': msg.id, + 'content': content, + 'type': msg.type.id, + }); + } + } + + if (contents.isEmpty) return result; + + final response = await _client.functions.invoke( + _edgeFunctionName, + body: { + 'messages': contents, + 'action': 'batch_summary', + 'user_id': userId, + }, + ); + + final data = response.data as Map?; + final summaries = data?['summaries'] as Map?; + + if (summaries != null) { + for (final entry in summaries.entries) { + final value = entry.value; + if (value is String && value.isNotEmpty) { + result[entry.key] = value; + } + } + } + + Log.i('AiSummary: 批量摘要完成 ${result.length}/${messages.length}'); + } catch (e) { + Log.e('AiSummary: 批量摘要失败', e); + } + + return result; + } + + // ============================================================ + // 生成每日摘要 + // ============================================================ + + Future generateDailySummary( + List todayMessages, + ) async { + if (todayMessages.isEmpty) return null; + + try { + final userId = _currentUserId; + if (userId == null) { + Log.w('AiSummary: 未登录,无法生成每日摘要'); + return null; + } + + final contents = todayMessages.map((msg) { + return { + 'id': msg.id, + 'content': _buildContent(msg), + 'type': msg.type.id, + 'author': msg.author ?? '', + }; + }).toList(); + + final response = await _client.functions.invoke( + _edgeFunctionName, + body: { + 'messages': contents, + 'action': 'daily', + 'user_id': userId, + 'date': DateTime.now().toIso8601String().substring(0, 10), + }, + ); + + final data = response.data as Map?; + final summary = data?['summary'] as String?; + + if (summary != null) { + Log.i('AiSummary: 每日摘要生成成功'); + } + + return summary; + } catch (e) { + Log.e('AiSummary: 每日摘要生成失败', e); + return null; + } + } + + // ============================================================ + // 智能分类标签建议 + // ============================================================ + + Future> suggestTags(String content) async { + if (content.trim().isEmpty) return []; + + try { + final userId = _currentUserId; + if (userId == null) { + Log.w('AiSummary: 未登录,无法建议标签'); + return []; + } + + final response = await _client.functions.invoke( + _edgeFunctionName, + body: { + 'content': content, + 'action': 'tags', + 'user_id': userId, + }, + ); + + final data = response.data as Map?; + final tags = data?['tags'] as List?; + + if (tags != null) { + final result = tags.map((e) => e.toString()).toList(); + Log.i('AiSummary: 标签建议 — $result'); + return result; + } + + return []; + } catch (e) { + Log.e('AiSummary: 标签建议失败', e); + return []; + } + } + + // ============================================================ + // 将摘要写入消息ext字段 + // ============================================================ + + ChatMessage applySummary(ChatMessage message, String summary) { + final newExt = Map.from(message.ext ?? {}); + newExt['aiSummary'] = summary; + return message.copyWith(ext: newExt); + } + + /// 从消息ext中读取AI摘要 + String? getSummary(ChatMessage message) { + return message.ext?['aiSummary'] as String?; + } + + // ============================================================ + // 工具方法 + // ============================================================ + + String _buildContent(ChatMessage message) { + final buffer = StringBuffer(); + if (message.text.isNotEmpty) { + buffer.write(message.text); + } + if (message.richContent != null && message.richContent!.isNotEmpty) { + if (buffer.isNotEmpty) buffer.write('\n'); + buffer.write(message.richContent); + } + return buffer.toString().trim(); + } + + // ============================================================ + // 初始化Supabase + // ============================================================ + + static Future ensureInitialized() async { + try { + Supabase.instance.client; + } catch (_) { + await Supabase.initialize( + url: AppConstants.supabaseUrl, + anonKey: AppConstants.supabaseAnonKey, + ); + } + } +} diff --git a/lib/core/services/readlater/readlater_collab_service.dart b/lib/core/services/readlater/readlater_collab_service.dart new file mode 100644 index 00000000..f70c98fc --- /dev/null +++ b/lib/core/services/readlater/readlater_collab_service.dart @@ -0,0 +1,394 @@ +/// ============================================================ +/// 闲言APP — 稍后读协作服务 +/// 创建时间: 2026-05-15 +/// 更新时间: 2026-05-15 +/// 作用: 与好友共享稍后读列表,基于Supabase实现实时协作 +/// 上次更新: E17 初始创建,支持共享列表CRUD/邀请/分享/实时订阅 +/// ============================================================ + +import 'dart:async'; + +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../../core/constants/app_constants.dart'; +import '../../../core/utils/logger.dart'; +import '../../../features/inspiration/models/chat_message.dart'; + +// ============================================================ +// 共享列表数据模型 +// ============================================================ + +class SharedReadlaterList { + const SharedReadlaterList({ + required this.id, + required this.name, + required this.ownerId, + this.memberIds = const [], + this.messageCount = 0, + required this.createdAt, + }); + + final String id; + final String name; + final String ownerId; + final List memberIds; + final int messageCount; + final DateTime createdAt; + + Map toJson() => { + 'id': id, + 'name': name, + 'owner_id': ownerId, + 'member_ids': memberIds, + 'message_count': messageCount, + 'created_at': createdAt.toIso8601String(), + }; + + factory SharedReadlaterList.fromJson(Map json) { + return SharedReadlaterList( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + ownerId: json['owner_id'] as String? ?? '', + memberIds: (json['member_ids'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + [], + messageCount: json['message_count'] as int? ?? 0, + createdAt: json['created_at'] != null + ? DateTime.parse(json['created_at'] as String) + : DateTime.now(), + ); + } +} + +// ============================================================ +// 稍后读协作服务 +// ============================================================ + +class ReadlaterCollabService { + ReadlaterCollabService._(); + + static final instance = ReadlaterCollabService._(); + + static const String _listsTable = 'readlater_shared_lists'; + static const String _membersTable = 'readlater_shared_members'; + static const String _messagesTable = 'readlater_shared_messages'; + + SupabaseClient get _client => Supabase.instance.client; + + String? get _currentUserId { + try { + return _client.auth.currentUser?.id; + } catch (_) { + return null; + } + } + + RealtimeChannel? _channel; + final _listChangeController = StreamController.broadcast(); + + /// 监听共享列表变更 + Stream get onListChanged => _listChangeController.stream; + + // ============================================================ + // 创建共享列表 + // ============================================================ + + Future createSharedList(String name) async { + final userId = _currentUserId; + if (userId == null) { + Log.w('CollabService: 未登录,无法创建共享列表'); + return ''; + } + + try { + final now = DateTime.now().toUtc().toIso8601String(); + + final listRow = await _client.from(_listsTable).insert({ + 'name': name.trim(), + 'owner_id': userId, + 'message_count': 0, + 'created_at': now, + }).select().single(); + + final listId = listRow['id'] as String; + + await _client.from(_membersTable).insert({ + 'list_id': listId, + 'user_id': userId, + 'role': 'owner', + 'joined_at': now, + }); + + Log.i('CollabService: 创建共享列表「$name」($listId)'); + return listId; + } catch (e) { + Log.e('CollabService: 创建共享列表失败', e); + return ''; + } + } + + // ============================================================ + // 邀请好友加入 + // ============================================================ + + Future inviteMember(String listId, String userId) async { + final myId = _currentUserId; + if (myId == null) { + Log.w('CollabService: 未登录,无法邀请'); + return; + } + + try { + final now = DateTime.now().toUtc().toIso8601String(); + + await _client.from(_membersTable).insert({ + 'list_id': listId, + 'user_id': userId, + 'role': 'member', + 'joined_at': now, + }); + + Log.i('CollabService: 已邀请用户 $userId 加入列表 $listId'); + } catch (e) { + Log.e('CollabService: 邀请成员失败', e); + } + } + + // ============================================================ + // 分享消息到共享列表 + // ============================================================ + + Future shareToSharedList(String listId, ChatMessage message) async { + final userId = _currentUserId; + if (userId == null) { + Log.w('CollabService: 未登录,无法分享'); + return; + } + + try { + final now = DateTime.now().toUtc().toIso8601String(); + + await _client.from(_messagesTable).insert({ + 'list_id': listId, + 'message_id': message.id, + 'shared_by': userId, + 'content': message.text, + 'type': message.type.id, + 'author': message.author, + 'source': message.source, + 'meta': message.meta, + 'ext': message.ext, + 'shared_at': now, + }); + + await _client.rpc('increment_shared_list_count', params: { + 'list_id_input': listId, + }); + + Log.i('CollabService: 消息 ${message.id} 已分享到列表 $listId'); + } catch (e) { + Log.e('CollabService: 分享消息失败', e); + } + } + + // ============================================================ + // 获取共享列表消息 + // ============================================================ + + Future> getSharedListMessages(String listId) async { + try { + final response = await _client + .from(_messagesTable) + .select() + .eq('list_id', listId) + .order('shared_at', ascending: false); + + final messages = []; + for (final row in response) { + try { + final message = ChatMessage( + id: row['message_id'] as String? ?? '', + type: ChatMessageType.fromId( + row['type'] as String? ?? 'readlater_sentence', + ), + role: ChatMessageRole.assistant, + text: row['content'] as String? ?? '', + author: row['author'] as String?, + source: row['source'] as String?, + meta: row['meta'] as Map?, + ext: row['ext'] as Map?, + timestamp: row['shared_at'] != null + ? DateTime.parse(row['shared_at'] as String) + : DateTime.now(), + ); + messages.add(message); + } catch (e) { + Log.e('CollabService: 解析共享消息失败', e); + } + } + + Log.i('CollabService: 获取列表消息 ${messages.length} 条'); + return messages; + } catch (e) { + Log.e('CollabService: 获取共享列表消息失败', e); + return []; + } + } + + // ============================================================ + // 获取我参与的共享列表 + // ============================================================ + + Future> getMySharedLists() async { + final userId = _currentUserId; + if (userId == null) { + Log.w('CollabService: 未登录,无法获取共享列表'); + return []; + } + + try { + final memberRows = await _client + .from(_membersTable) + .select('list_id') + .eq('user_id', userId); + + final listIds = memberRows + .map((r) => r['list_id'] as String) + .toList(); + + if (listIds.isEmpty) return []; + + final listRows = await _client + .from(_listsTable) + .select() + .inFilter('id', listIds) + .order('created_at', ascending: false); + + final result = []; + for (final row in listRows) { + try { + final membersResponse = await _client + .from(_membersTable) + .select('user_id') + .eq('list_id', row['id'] as String); + + final memberIds = membersResponse + .map((m) => m['user_id'] as String) + .toList(); + + final list = SharedReadlaterList( + id: row['id'] as String, + name: row['name'] as String? ?? '', + ownerId: row['owner_id'] as String? ?? '', + memberIds: memberIds, + messageCount: row['message_count'] as int? ?? 0, + createdAt: row['created_at'] != null + ? DateTime.parse(row['created_at'] as String) + : DateTime.now(), + ); + result.add(list); + } catch (e) { + Log.e('CollabService: 解析共享列表失败', e); + } + } + + Log.i('CollabService: 获取共享列表 ${result.length} 个'); + return result; + } catch (e) { + Log.e('CollabService: 获取共享列表失败', e); + return []; + } + } + + // ============================================================ + // 退出共享列表 + // ============================================================ + + Future leaveSharedList(String listId) async { + final userId = _currentUserId; + if (userId == null) return; + + try { + await _client + .from(_membersTable) + .delete() + .eq('list_id', listId) + .eq('user_id', userId); + + Log.i('CollabService: 已退出共享列表 $listId'); + } catch (e) { + Log.e('CollabService: 退出共享列表失败', e); + } + } + + // ============================================================ + // 实时订阅共享列表变更 + // ============================================================ + + void subscribeToListChanges() { + final userId = _currentUserId; + if (userId == null) return; + + try { + _channel?.unsubscribe(); + _channel = _client + .channel('readlater_collab_$userId') + .onPostgresChanges( + event: PostgresChangeEvent.all, + schema: 'public', + table: _messagesTable, + callback: (PostgresChangePayload payload) { + final row = payload.newRecord; + final list = SharedReadlaterList( + id: row['list_id'] as String? ?? '', + name: '', + ownerId: '', + createdAt: DateTime.now(), + ); + _listChangeController.add(list); + Log.i('CollabService: 共享列表变更通知'); + }, + ) + .subscribe(); + + Log.i('CollabService: 已订阅共享列表变更'); + } catch (e) { + Log.e('CollabService: 订阅共享列表变更失败', e); + } + } + + // ============================================================ + // 取消订阅 + // ============================================================ + + void unsubscribeFromListChanges() { + _channel?.unsubscribe(); + _channel = null; + Log.i('CollabService: 已取消订阅共享列表变更'); + } + + // ============================================================ + // 初始化Supabase + // ============================================================ + + static Future ensureInitialized() async { + try { + Supabase.instance.client; + } catch (_) { + await Supabase.initialize( + url: AppConstants.supabaseUrl, + anonKey: AppConstants.supabaseAnonKey, + ); + } + } + + // ============================================================ + // 释放资源 + // ============================================================ + + void dispose() { + unsubscribeFromListChanges(); + _listChangeController.close(); + } +} diff --git a/lib/core/services/readlater/readlater_device_sync_service.dart b/lib/core/services/readlater/readlater_device_sync_service.dart new file mode 100644 index 00000000..1b8a2719 --- /dev/null +++ b/lib/core/services/readlater/readlater_device_sync_service.dart @@ -0,0 +1,301 @@ +/// ============================================================ +/// 闲言APP — 稍后读跨设备同步服务 +/// 创建时间: 2026-05-15 +/// 更新时间: 2026-05-15 +/// 作用: 通过文件传输助手的同账号/局域网设备同步稍后读内容 +/// 上次更新: 初始创建,支持发现设备、打包发送、接收导入 +/// ============================================================ + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'package:xianyan/core/utils/logger.dart'; +import 'package:xianyan/features/file_transfer/models/transfer_device.dart'; +import 'package:xianyan/features/file_transfer/providers/transfer_provider.dart'; +import 'package:xianyan/features/inspiration/models/chat_message.dart'; +import 'package:xianyan/features/inspiration/services/chat_message_service.dart'; +import 'package:xianyan/shared/widgets/app_toast.dart'; + +class ReadlaterDeviceSyncService { + ReadlaterDeviceSyncService._(); + + static final instance = ReadlaterDeviceSyncService._(); + + static const String _syncFilePrefix = 'readlater_sync_'; + static const String _syncDirName = 'readlater_sync'; + + /// 发现可同步的同账号在线设备 + Future> discoverDevices(WidgetRef ref) async { + try { + final transferNotifier = ref.read(transferProvider.notifier); + await transferNotifier.discoverMyDevices(); + final state = ref.read(transferProvider); + final myDevices = state.myDevices.where((d) => d.isOnline).toList(); + Log.i('ReadlaterSync: 发现 ${myDevices.length} 台在线设备'); + return myDevices; + } catch (e) { + Log.e('ReadlaterSync: 设备发现失败', e); + AppToast.showWarning('设备发现失败,请检查网络连接'); + return []; + } + } + + /// 将稍后读内容打包发送到指定设备 + Future sendToDevice({ + required WidgetRef ref, + required TransferDevice device, + required List messages, + }) async { + if (messages.isEmpty) { + AppToast.showInfo('没有稍后读内容可同步'); + return false; + } + + try { + final syncData = _serializeMessages(messages); + final filePath = await _saveSyncFile(syncData); + if (filePath == null) { + Log.e('ReadlaterSync: 临时文件创建失败'); + AppToast.showWarning('同步文件创建失败'); + return false; + } + + final transferNotifier = ref.read(transferProvider.notifier); + await transferNotifier.sendFileToMyDevice( + device: device, + filePath: filePath, + ); + + Log.i('ReadlaterSync: 已向 ${device.displayAlias} 发送稍后读同步文件'); + AppToast.showSuccess('稍后读内容已发送到 ${device.displayAlias}'); + return true; + } catch (e) { + Log.e('ReadlaterSync: 发送失败', e); + AppToast.showWarning('同步发送失败: $e'); + return false; + } + } + + /// 将稍后读内容导出为文件并传输 + Future sendAsFile({ + required WidgetRef ref, + required TransferDevice device, + }) async { + try { + final messages = await _loadReadlaterMessages(); + if (messages.isEmpty) { + AppToast.showInfo('没有稍后读内容可导出'); + return false; + } + return sendToDevice(ref: ref, device: device, messages: messages); + } catch (e) { + Log.e('ReadlaterSync: 导出文件传输失败', e); + AppToast.showWarning('导出传输失败'); + return false; + } + } + + /// 处理接收到的稍后读同步数据 + Future handleReceivedSyncData(String filePath) async { + try { + final file = File(filePath); + if (!await file.exists()) { + Log.w('ReadlaterSync: 同步文件不存在: $filePath'); + return; + } + + final content = await file.readAsString(); + final syncData = jsonDecode(content) as Map; + final imported = await _importSyncData(syncData); + + await file.delete(); + Log.i('ReadlaterSync: 导入 $imported 条稍后读消息,临时文件已清理'); + AppToast.showSuccess('已导入 $imported 条稍后读内容'); + } catch (e) { + Log.e('ReadlaterSync: 处理同步数据失败', e); + AppToast.showWarning('稍后读数据导入失败'); + } + } + + /// 扫描传输目录中未处理的稍后读同步文件 + Future scanAndImportPendingSyncFiles() async { + try { + final appDocDir = await getApplicationDocumentsDirectory(); + final syncDir = Directory('${appDocDir.path}/$_syncDirName'); + if (!await syncDir.exists()) return 0; + + int totalImported = 0; + await for (final entity in syncDir.list()) { + if (entity is File && entity.path.contains(_syncFilePrefix)) { + final imported = await _importFromFile(entity); + totalImported += imported; + } + } + + if (totalImported > 0) { + Log.i('ReadlaterSync: 扫描导入 $totalImported 条稍后读消息'); + } + return totalImported; + } catch (e) { + Log.e('ReadlaterSync: 扫描同步文件失败', e); + return 0; + } + } + + // ============================================================ + // 私有方法 + // ============================================================ + + /// 序列化稍后读消息为同步数据格式 + Map _serializeMessages(List messages) { + return { + 'version': 1, + 'type': 'readlater_sync', + 'exportTime': DateTime.now().toIso8601String(), + 'count': messages.length, + 'messages': messages.map((m) => _messageToJson(m)).toList(), + }; + } + + /// 将 ChatMessage 转为可序列化的 Map + Map _messageToJson(ChatMessage msg) { + return { + 'id': msg.id, + 'type': msg.type.id, + 'role': msg.role.id, + 'text': msg.text, + 'author': msg.author, + 'source': msg.source, + 'category': msg.category, + 'timestamp': msg.timestamp.toIso8601String(), + 'isRead': msg.isRead, + 'meta': msg.meta, + 'ext': msg.ext, + 'richContent': msg.richContent, + 'replyToId': msg.replyToId, + }; + } + + /// 保存同步数据为临时文件 + Future _saveSyncFile(Map syncData) async { + try { + final appDocDir = await getApplicationDocumentsDirectory(); + final syncDir = Directory('${appDocDir.path}/$_syncDirName'); + if (!await syncDir.exists()) { + await syncDir.create(recursive: true); + } + + final timestamp = DateTime.now().millisecondsSinceEpoch; + final fileName = '$_syncFilePrefix$timestamp.json'; + final file = File('${syncDir.path}/$fileName'); + + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(syncData), + ); + Log.d('ReadlaterSync: 同步文件已保存: ${file.path}'); + return file.path; + } catch (e) { + Log.e('ReadlaterSync: 保存同步文件失败', e); + return null; + } + } + + /// 导入同步数据到稍后读会话 + Future _importSyncData(Map syncData) async { + final type = syncData['type'] as String?; + if (type != 'readlater_sync') { + Log.w('ReadlaterSync: 无效的同步数据类型: $type'); + return 0; + } + + final messagesList = syncData['messages'] as List?; + if (messagesList == null || messagesList.isEmpty) return 0; + + int imported = 0; + for (final msgJson in messagesList) { + try { + final msgMap = msgJson as Map; + final msgType = msgMap['type'] as String? ?? ''; + final text = msgMap['text'] as String? ?? ''; + final author = msgMap['author'] as String?; + final source = msgMap['source'] as String?; + final meta = msgMap['meta'] as Map? ?? {}; + + if (msgType == 'readlater_sentence') { + await ChatMessageService.sendReadLaterSentence( + conversationId: 'readlater', + text: text, + author: author, + source: source, + feedType: meta['feedType'] as String?, + feedName: meta['feedName'] as String?, + likeCount: meta['likeCount'] as int?, + views: meta['views'] as int?, + sentenceId: meta['sentenceId'] as String?, + ); + } else if (msgType == 'link') { + await ChatMessageService.sendLink( + conversationId: 'readlater', + url: meta['url'] as String? ?? text, + title: meta['title'] as String?, + description: meta['description'] as String?, + imageUrl: meta['imageUrl'] as String?, + sourceApp: meta['siteName'] as String?, + ); + } else if (msgType == 'document') { + await ChatMessageService.sendDocument( + conversationId: 'readlater', + fileName: meta['fileName'] as String? ?? '未知文档', + filePath: meta['filePath'] as String? ?? '', + fileType: meta['mimeType'] as String? ?? 'application/octet-stream', + fileSize: meta['fileSize'] as int? ?? 0, + ); + } else { + await ChatMessageService.sendText( + conversationId: 'readlater', + content: text, + author: author, + source: source, + category: msgMap['category'] as String?, + meta: meta, + ); + } + imported++; + } catch (e) { + Log.w('ReadlaterSync: 导入单条消息失败: $e'); + } + } + return imported; + } + + /// 从文件导入 + Future _importFromFile(File file) async { + try { + final content = await file.readAsString(); + final syncData = jsonDecode(content) as Map; + final imported = await _importSyncData(syncData); + await file.delete(); + return imported; + } catch (e) { + Log.e('ReadlaterSync: 文件导入失败: ${file.path}', e); + return 0; + } + } + + /// 加载稍后读会话的全部消息 + Future> _loadReadlaterMessages() async { + try { + final records = await ChatMessageService.getMessages( + 'readlater', + limit: 9999, + ); + return records.map((r) => ChatMessage.fromDrift(r)).toList(); + } catch (e) { + Log.e('ReadlaterSync: 加载稍后读消息失败', e); + return []; + } + } +} diff --git a/lib/core/services/readlater/readlater_sync_service.dart b/lib/core/services/readlater/readlater_sync_service.dart new file mode 100644 index 00000000..c471ec0d --- /dev/null +++ b/lib/core/services/readlater/readlater_sync_service.dart @@ -0,0 +1,350 @@ +/// ============================================================ +/// 闲言APP — 稍后读内容云端同步服务 +/// 创建时间: 2026-05-15 +/// 更新时间: 2026-05-15 +/// 作用: 稍后读消息的云端同步,基于Supabase实现上传/下载/全量同步 +/// 上次更新: E11 初始创建,支持upload/download/fullSync/autoSync +/// ============================================================ + +import 'dart:convert'; + +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../../../core/constants/app_constants.dart'; +import '../../../core/storage/app_kv_store.dart'; +import '../../../core/utils/logger.dart'; +import '../../../features/inspiration/models/chat_message.dart'; + +// ============================================================ +// 同步结果模型 +// ============================================================ + +class SyncResult { + const SyncResult({ + this.uploaded = 0, + this.downloaded = 0, + this.conflicts = 0, + this.success = false, + }); + + final int uploaded; + final int downloaded; + final int conflicts; + final bool success; + + String get summary => + '同步完成: ↑$uploaded ↓$downloaded ⚡$conflicts ${success ? "✅" : "❌"}'; + + SyncResult copyWith({ + int? uploaded, + int? downloaded, + int? conflicts, + bool? success, + }) { + return SyncResult( + uploaded: uploaded ?? this.uploaded, + downloaded: downloaded ?? this.downloaded, + conflicts: conflicts ?? this.conflicts, + success: success ?? this.success, + ); + } +} + +// ============================================================ +// 稍后读同步服务 +// ============================================================ + +class ReadlaterSyncService { + ReadlaterSyncService._(); + + static final instance = ReadlaterSyncService._(); + + static const String _tableName = 'readlater_messages'; + static const String _keyLastSyncTime = 'readlater_last_sync_time'; + static const String _keyAutoSync = 'readlater_auto_sync'; + + SupabaseClient get _client => Supabase.instance.client; + + String? get _currentUserId { + try { + return _client.auth.currentUser?.id; + } catch (_) { + return null; + } + } + + // ============================================================ + // 上传稍后读消息到云端 + // ============================================================ + + Future uploadMessage(ChatMessage message) async { + final userId = _currentUserId; + if (userId == null) { + Log.w('ReadlaterSync: 未登录,无法上传'); + return false; + } + + try { + final now = DateTime.now().toUtc().toIso8601String(); + final row = { + 'user_id': userId, + 'message_id': message.id, + 'type': message.type.id, + 'content': message.text, + 'meta_json': jsonEncode({ + 'author': message.author, + 'source': message.source, + 'category': message.category, + 'meta': message.meta, + 'ext': message.ext, + 'attachments': message.attachments.map((a) => a.toJson()).toList(), + 'role': message.role.id, + 'conversationId': message.conversationId, + 'isRead': message.isRead, + 'readCount': message.readCount, + 'replyToId': message.replyToId, + 'richContent': message.richContent, + 'timestamp': message.timestamp.toIso8601String(), + }), + 'created_at': message.timestamp.toUtc().toIso8601String(), + 'updated_at': now, + }; + + await _client.from(_tableName).upsert( + row, + onConflict: 'user_id,message_id', + ); + + Log.i('ReadlaterSync: 上传成功 ${message.id}'); + return true; + } catch (e) { + Log.e('ReadlaterSync: 上传失败 ${message.id}', e); + return false; + } + } + + // ============================================================ + // 下载云端稍后读消息 + // ============================================================ + + Future> downloadMessages() async { + final userId = _currentUserId; + if (userId == null) { + Log.w('ReadlaterSync: 未登录,无法下载'); + return []; + } + + try { + final response = await _client + .from(_tableName) + .select() + .eq('user_id', userId) + .order('updated_at', ascending: false); + + final messages = []; + for (final row in response) { + try { + final metaJson = + row['meta_json'] as String? ?? '{}'; + final metaMap = + jsonDecode(metaJson) as Map; + + final message = ChatMessage( + id: row['message_id'] as String? ?? '', + type: ChatMessageType.fromId( + row['type'] as String? ?? 'readlater_sentence', + ), + role: ChatMessageRole.fromId( + metaMap['role'] as String? ?? 'assistant', + ), + text: row['content'] as String? ?? '', + conversationId: metaMap['conversationId'] as String?, + author: metaMap['author'] as String?, + source: metaMap['source'] as String?, + category: metaMap['category'] as String?, + timestamp: metaMap['timestamp'] != null + ? DateTime.parse(metaMap['timestamp'] as String) + : DateTime.parse( + row['created_at'] as String? ?? + DateTime.now().toIso8601String(), + ), + isRead: metaMap['isRead'] as bool? ?? false, + readCount: metaMap['readCount'] as int? ?? 0, + meta: metaMap['meta'] as Map?, + ext: metaMap['ext'] as Map?, + attachments: (metaMap['attachments'] as List?) + ?.map( + (a) => ChatMessageAttachment.fromJson( + a as Map), + ) + .toList() ?? + [], + replyToId: metaMap['replyToId'] as String?, + richContent: metaMap['richContent'] as String?, + ); + messages.add(message); + } catch (e) { + Log.e('ReadlaterSync: 解析行失败 ${row['message_id']}', e); + } + } + + Log.i('ReadlaterSync: 下载完成 ${messages.length} 条'); + return messages; + } catch (e) { + Log.e('ReadlaterSync: 下载失败', e); + return []; + } + } + + // ============================================================ + // 全量同步(上传本地 + 下载云端,按timestamp去重) + // ============================================================ + + Future fullSync(List localMessages) async { + final userId = _currentUserId; + if (userId == null) { + Log.w('ReadlaterSync: 未登录,无法同步'); + return const SyncResult(); + } + + int uploaded = 0; + int downloaded = 0; + int conflicts = 0; + + try { + // 1. 上传本地消息 + for (final msg in localMessages) { + final ok = await uploadMessage(msg); + if (ok) uploaded++; + } + + // 2. 下载云端消息 + final cloudMessages = await downloadMessages(); + + // 3. 按message_id去重,冲突策略:以updated_at较新的为准 + final localMap = {}; + for (final m in localMessages) { + localMap[m.id] = m; + } + + final cloudMap = >{}; + try { + final response = await _client + .from(_tableName) + .select() + .eq('user_id', userId); + for (final row in response) { + final msgId = row['message_id'] as String?; + if (msgId != null) cloudMap[msgId] = Map.from(row as Map); + } + } catch (_) {} + + for (final cloudMsg in cloudMessages) { + if (localMap.containsKey(cloudMsg.id)) { + final cloudRow = cloudMap[cloudMsg.id]; + if (cloudRow != null) { + final cloudUpdated = DateTime.tryParse( + cloudRow['updated_at'] as String? ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0); + final localMsg = localMap[cloudMsg.id]!; + final localUpdated = localMsg.timestamp; + + if (cloudUpdated.isAfter(localUpdated)) { + conflicts++; + } + } + } else { + downloaded++; + } + } + + // 4. 更新最后同步时间 + await _saveLastSyncTime(DateTime.now()); + + Log.i( + 'ReadlaterSync: 全量同步完成 ↑$uploaded ↓$downloaded ⚡$conflicts', + ); + return SyncResult( + uploaded: uploaded, + downloaded: downloaded, + conflicts: conflicts, + success: true, + ); + } catch (e) { + Log.e('ReadlaterSync: 全量同步失败', e); + return SyncResult( + uploaded: uploaded, + downloaded: downloaded, + conflicts: conflicts, + ); + } + } + + // ============================================================ + // 最后同步时间 + // ============================================================ + + DateTime? getLastSyncTime() { + final raw = AppKVStore.getString(_keyLastSyncTime); + if (raw == null || raw.isEmpty) return null; + return DateTime.tryParse(raw); + } + + Future _saveLastSyncTime(DateTime time) async { + await AppKVStore.setString( + _keyLastSyncTime, + time.toIso8601String(), + ); + } + + // ============================================================ + // 自动同步开关 + // ============================================================ + + void setAutoSync(bool enabled) { + AppKVStore.setBool(_keyAutoSync, enabled); + Log.i('ReadlaterSync: 自动同步 ${enabled ? "开启" : "关闭"}'); + } + + bool getAutoSync() { + return AppKVStore.getBool(_keyAutoSync) ?? false; + } + + // ============================================================ + // 删除云端消息 + // ============================================================ + + Future deleteMessage(String messageId) async { + final userId = _currentUserId; + if (userId == null) return false; + + try { + await _client + .from(_tableName) + .delete() + .eq('user_id', userId) + .eq('message_id', messageId); + + Log.i('ReadlaterSync: 删除成功 $messageId'); + return true; + } catch (e) { + Log.e('ReadlaterSync: 删除失败 $messageId', e); + return false; + } + } + + // ============================================================ + // 初始化 Supabase(如尚未初始化) + // ============================================================ + + static Future ensureInitialized() async { + try { + Supabase.instance.client; + } catch (_) { + await Supabase.initialize( + url: AppConstants.supabaseUrl, + anonKey: AppConstants.supabaseAnonKey, + ); + } + } +} diff --git a/lib/core/services/readlater/sharing_receiver_service.dart b/lib/core/services/readlater/sharing_receiver_service.dart new file mode 100644 index 00000000..5dae7778 --- /dev/null +++ b/lib/core/services/readlater/sharing_receiver_service.dart @@ -0,0 +1,325 @@ +/// ============================================================ +/// 闲言APP — 统一分享接收服务 +/// 创建时间: 2026-05-15 +/// 更新时间: 2026-05-15 +/// 作用: 接收其他App通过系统分享面板发送的内容,写入稍后读会话 +/// 上次更新: v13.0.0 新增分享唤起自动导航到稍后读会话 +/// ============================================================ + +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; + +import '../../../core/utils/logger.dart'; +import '../../../features/inspiration/services/chat_message_service.dart'; +import '../../../shared/widgets/app_toast.dart'; + +class SharingReceiverService { + SharingReceiverService._(); + + static final SharingReceiverService _instance = SharingReceiverService._(); + + factory SharingReceiverService() => _instance; + + static const String _readLaterConvId = 'readlater'; + + static final RegExp _urlRegex = RegExp(r'^https?://', caseSensitive: false); + + StreamSubscription>? _mediaSub; + + GlobalKey? _navigatorKey; + + void setNavigatorKey(GlobalKey key) { + _navigatorKey = key; + } + + /// 初始化分享监听,在main.dart中调用 + Future init() async { + Log.i('分享接收服务初始化开始'); + + if (kIsWeb) { + _initWeb(); + } else if (Platform.isWindows) { + _initWindows(); + } else { + await _initMobile(); + } + + Log.i('分享接收服务初始化完成'); + } + + /// 释放资源 + void dispose() { + _mediaSub?.cancel(); + _mediaSub = null; + Log.i('分享接收服务已释放'); + } + + // ============================================================ + // 移动端 (Android / iOS) + // ============================================================ + + Future _initMobile() async { + final instance = ReceiveSharingIntent.instance; + + final initialMedia = await instance.getInitialMedia(); + if (initialMedia.isNotEmpty) { + for (final media in initialMedia) { + _handleSharedMedia(media); + } + } + + _mediaSub = instance.getMediaStream().listen((mediaList) { + for (final media in mediaList) { + _handleSharedMedia(media); + } + }); + } + + // ============================================================ + // Web端 + // ============================================================ + + void _initWeb() { + final uri = Uri.tryParse(Uri.base.toString()); + if (uri != null) { + _handleWebShare(uri); + } + } + + /// Web端URL参数解析 + void _handleWebShare(Uri uri) { + final text = uri.queryParameters['text']; + final url = uri.queryParameters['url']; + final title = uri.queryParameters['title']; + + if (url != null && url.isNotEmpty) { + _handleText(url); + } else if (text != null && text.isNotEmpty) { + _handleText(text, title: title); + } + } + + // ============================================================ + // Windows端 + // ============================================================ + + void _initWindows() { + final args = Platform.executableArguments; + if (args.isNotEmpty) { + _handleWindowsShare(args); + } + } + + /// Windows端命令行参数解析 + void _handleWindowsShare(List args) { + for (final arg in args) { + final file = File(arg); + if (file.existsSync()) { + _handleFile(path: arg, mimeType: _guessMimeType(arg)); + } else { + _handleText(arg); + } + } + } + + // ============================================================ + // 核心处理方法 + // ============================================================ + + /// 根据SharedMediaType分发处理 + Future _handleSharedMedia(SharedMediaFile media) async { + switch (media.type) { + case SharedMediaType.text: + final text = media.message ?? media.path; + if (text.isNotEmpty) { + _handleText(text); + } + case SharedMediaType.url: + _handleText(media.path); + case SharedMediaType.image: + _handleFile( + path: media.path, + mimeType: media.mimeType ?? 'image/*', + thumbnail: media.thumbnail, + ); + case SharedMediaType.video: + _handleFile( + path: media.path, + mimeType: media.mimeType ?? 'video/*', + thumbnail: media.thumbnail, + duration: media.duration, + ); + case SharedMediaType.file: + _handleFile( + path: media.path, + mimeType: media.mimeType ?? _guessMimeType(media.path), + ); + } + } + + /// 处理文本/链接分享 + Future _handleText(String text, {String? title}) async { + try { + if (_urlRegex.hasMatch(text)) { + await ChatMessageService.sendLink( + conversationId: _readLaterConvId, + url: text, + title: title, + sourceApp: '分享', + ); + Log.i('分享链接已写入稍后读: $text'); + AppToast.showSuccess('🔗 链接已保存到稍后读'); + _navigateToReadlater(); + } else { + await ChatMessageService.sendText( + conversationId: _readLaterConvId, + content: text, + source: '分享', + ); + Log.i( + '分享文本已写入稍后读: ${text.length > 50 ? '${text.substring(0, 50)}...' : text}', + ); + AppToast.showSuccess('📝 文本已保存到稍后读'); + _navigateToReadlater(); + } + } catch (e) { + Log.e('分享文本处理失败', e); + AppToast.showError('分享保存失败'); + } + } + + /// 处理文件分享 + Future _handleFile({ + required String path, + required String mimeType, + String? thumbnail, + int? duration, + }) async { + try { + final fileName = _extractFileName(path); + + if (mimeType.startsWith('image/')) { + await ChatMessageService.sendImage( + conversationId: _readLaterConvId, + content: path, + meta: { + 'mimeType': mimeType, + 'fileName': fileName, + if (thumbnail != null) 'thumbnail': thumbnail, + }, + ); + Log.i('分享图片已写入稍后读: $fileName'); + AppToast.showSuccess('🖼️ 图片已保存到稍后读'); + _navigateToReadlater(); + } else if (mimeType.startsWith('video/')) { + await ChatMessageService.sendVideo( + conversationId: _readLaterConvId, + content: path, + meta: { + 'mimeType': mimeType, + 'fileName': fileName, + if (thumbnail != null) 'thumbnail': thumbnail, + if (duration != null) 'duration': duration, + }, + ); + Log.i('分享视频已写入稍后读: $fileName'); + AppToast.showSuccess('🎬 视频已保存到稍后读'); + _navigateToReadlater(); + } else if (mimeType.startsWith('application/')) { + await ChatMessageService.sendDocument( + conversationId: _readLaterConvId, + fileName: fileName, + filePath: path, + fileType: mimeType, + fileSize: await _getFileSize(path), + meta: {'mimeType': mimeType}, + ); + Log.i('分享文档已写入稍后读: $fileName'); + AppToast.showSuccess('📄 文档已保存到稍后读'); + _navigateToReadlater(); + } else { + await ChatMessageService.sendFile( + conversationId: _readLaterConvId, + content: path, + meta: {'mimeType': mimeType, 'fileName': fileName}, + ); + Log.i('分享文件已写入稍后读: $fileName'); + AppToast.showSuccess('📁 文件已保存到稍后读'); + _navigateToReadlater(); + } + } catch (e) { + Log.e('分享文件处理失败', e); + AppToast.showError('分享保存失败'); + } + } + + // ============================================================ + // 工具方法 + // ============================================================ + + void _navigateToReadlater() { + try { + final nav = _navigatorKey?.currentState; + if (nav != null) { + nav.pushNamed('/readlater-chat'); + Log.i('已自动导航到稍后读会话'); + } + } catch (e) { + Log.w('自动导航到稍后读失败: $e'); + } + } + + /// 从路径提取文件名 + String _extractFileName(String path) { + if (path.isEmpty) return '未知文件'; + return path.split(Platform.pathSeparator).last; + } + + /// 根据文件扩展名猜测MIME类型 + String _guessMimeType(String path) { + final ext = path.split('.').last.toLowerCase(); + const mimeMap = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'bmp': 'image/bmp', + 'mp4': 'video/mp4', + 'avi': 'video/avi', + 'mov': 'video/quicktime', + 'mkv': 'video/x-matroska', + 'pdf': 'application/pdf', + 'doc': 'application/msword', + 'docx': + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls': 'application/vnd.ms-excel', + 'xlsx': + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt': 'application/vnd.ms-powerpoint', + 'pptx': + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'zip': 'application/zip', + 'rar': 'application/x-rar-compressed', + '7z': 'application/x-7z-compressed', + 'txt': 'text/plain', + 'json': 'application/json', + }; + return mimeMap[ext] ?? 'application/octet-stream'; + } + + /// 获取文件大小 + Future _getFileSize(String path) async { + try { + final file = File(path); + if (await file.exists()) { + return await file.length(); + } + } catch (_) {} + return 0; + } +} diff --git a/lib/core/services/smart_mode_service.dart b/lib/core/services/smart_mode_service.dart index 7050b6b7..4ea24c9b 100644 --- a/lib/core/services/smart_mode_service.dart +++ b/lib/core/services/smart_mode_service.dart @@ -12,7 +12,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../storage/app_kv_store.dart'; import '../utils/logger.dart'; -import 'connectivity_service.dart'; +import 'network/connectivity_service.dart'; enum BrowseMode { hd, standard, saver } @@ -39,8 +39,9 @@ class SmartModeService { } if (_autoMode) { _updateModeByNetwork(ConnectivityService.currentType); - _subscription = - ConnectivityService.onTypeChange.listen(_updateModeByNetwork); + _subscription = ConnectivityService.onTypeChange.listen( + _updateModeByNetwork, + ); } Log.i('SmartModeService: 初始化完成 (mode=$_currentMode, auto=$_autoMode)'); } @@ -56,9 +57,7 @@ class SmartModeService { NetworkType.other => BrowseMode.standard, }; if (newMode != _currentMode) { - Log.i( - 'SmartModeService: 自动切换 ${_currentMode.name} → ${newMode.name}', - ); + Log.i('SmartModeService: 自动切换 ${_currentMode.name} → ${newMode.name}'); _currentMode = newMode; } } @@ -68,8 +67,9 @@ class SmartModeService { await AppKVStore.setBool(_keyAutoMode, auto); if (auto) { _updateModeByNetwork(ConnectivityService.currentType); - _subscription ??= - ConnectivityService.onTypeChange.listen(_updateModeByNetwork); + _subscription ??= ConnectivityService.onTypeChange.listen( + _updateModeByNetwork, + ); } else { _subscription?.cancel(); _subscription = null; diff --git a/lib/core/utils/level_utils.dart b/lib/core/utils/level_utils.dart new file mode 100644 index 00000000..eca63906 --- /dev/null +++ b/lib/core/utils/level_utils.dart @@ -0,0 +1,27 @@ +/// ============================================================ +/// 闲言APP — 等级工具方法 +/// 创建时间: 2026-05-14 +/// 更新时间: 2026-05-14 +/// 作用: 等级颜色/称号映射,供多页面复用 +/// 上次更新: 初始创建 +/// ============================================================ + +import 'package:flutter/material.dart'; + +Color getLevelColor(int level) { + if (level >= 10) return const Color(0xFFE74C3C); + if (level >= 8) return const Color(0xFFFF6600); + if (level >= 6) return const Color(0xFFFFC000); + if (level >= 4) return const Color(0xFF70AD47); + return const Color(0xFF5B9BD5); +} + +String getLevelTitle(int level) { + const titles = { + 1: '新手', 2: '学徒', 3: '初学者', 4: '进阶者', 5: '学者', + 6: '达人', 7: '专家', 8: '大师', 9: '宗师', 10: '传说', + }; + return titles[level] ?? '新手'; +} + +String getLevelBadge(int level) => 'Lv.$level ${getLevelTitle(level)}'; diff --git a/lib/core/utils/receipt_helper.dart b/lib/core/utils/receipt_helper.dart index 052a8f72..16cf41be 100644 --- a/lib/core/utils/receipt_helper.dart +++ b/lib/core/utils/receipt_helper.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 回执(Receipt)验证工具 /// 创建时间: 2026-04-30 -/// 更新时间: 2026-04-30 +/// 更新时间: 2026-05-15 /// 作用: 生成HMAC-SHA256签名的回执,替代邮箱验证码 -/// 上次更新: v9.0.0 新增delete_account回执action +/// 上次更新: v10.1.0 新增changesecq回执action /// ============================================================ import 'dart:convert'; @@ -62,7 +62,8 @@ enum ReceiptAction { changeemail('changeemail'), changemobile('changemobile'), receiptLogin('receipt_login'), - deleteAccount('delete_account'); + deleteAccount('delete_account'), + changeSecQuestion('changesecq'); const ReceiptAction(this.value); diff --git a/lib/editor/services/image/image_import_service.dart b/lib/editor/services/image/image_import_service.dart index d9b616d4..aff3d50f 100644 --- a/lib/editor/services/image/image_import_service.dart +++ b/lib/editor/services/image/image_import_service.dart @@ -17,7 +17,7 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:xianyan/core/services/permission_service.dart'; +import 'package:xianyan/core/services/auth/permission_service.dart'; import 'package:xianyan/core/utils/logger.dart'; import 'package:xianyan/editor/services/export/image_compress_service.dart'; import 'package:xianyan/editor/services/image/image_info_service.dart'; diff --git a/lib/features/achievement/presentation/achievement_page.dart b/lib/features/achievement/presentation/achievement_page.dart index 85139e48..35903bad 100644 --- a/lib/features/achievement/presentation/achievement_page.dart +++ b/lib/features/achievement/presentation/achievement_page.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 成就中心页面 // 创建时间: 2026-04-29 -// 更新时间: 2026-05-04 +// 更新时间: 2026-05-14 // 作用: 成就展示/领取/头衔进度 -// 上次更新: emoji替换为CupertinoIcon +// 上次更新: _ProfileSummary增加等级标签+EXP进度条 // ============================================================ import 'package:fl_chart/fl_chart.dart'; @@ -17,7 +17,9 @@ import '../../../core/theme/app_radius.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../core/theme/app_typography.dart'; import '../../../core/theme/app_theme.dart'; +import '../../../core/utils/level_utils.dart'; import '../../../shared/widgets/glass_container.dart'; +import '../../auth/providers/auth_provider.dart'; import '../models/achievement_models.dart'; import '../providers/achievement_provider.dart'; @@ -52,6 +54,7 @@ class _AchievementPageState extends ConsumerState { Widget build(BuildContext context) { final state = ref.watch(achievementProvider); final ext = Theme.of(context).extension()!; + final user = ref.watch(authProvider).user; return CupertinoPageScaffold( navigationBar: const CupertinoNavigationBar( @@ -75,7 +78,14 @@ class _AchievementPageState extends ConsumerState { ) else ...[ SliverToBoxAdapter( - child: _ProfileSummary(data: state.myData, ext: ext) + child: _ProfileSummary( + data: state.myData, + ext: ext, + level: user?.level ?? 1, + exp: user?.exp ?? 0, + expToNext: user?.expToNext ?? 0, + expProgress: user?.expProgress ?? 0.0, + ) .animate() .fadeIn(duration: 400.ms) .slideY( @@ -187,9 +197,20 @@ class _AchievementPageState extends ConsumerState { } class _ProfileSummary extends StatelessWidget { - const _ProfileSummary({required this.data, required this.ext}); + const _ProfileSummary({ + required this.data, + required this.ext, + this.level = 1, + this.exp = 0, + this.expToNext = 0, + this.expProgress = 0.0, + }); final MyAchievementData? data; final AppThemeExtension ext; + final int level; + final int exp; + final int expToNext; + final double expProgress; @override Widget build(BuildContext context) { @@ -301,6 +322,75 @@ class _ProfileSummary extends StatelessWidget { ], ), const SizedBox(height: AppSpacing.md), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: getLevelColor(level).withValues(alpha: 0.08), + borderRadius: AppRadius.mdBorder, + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 2, + ), + decoration: BoxDecoration( + color: getLevelColor(level).withValues(alpha: 0.15), + borderRadius: AppRadius.pillBorder, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.star_fill, + size: 12, + color: getLevelColor(level), + ), + const SizedBox(width: 4), + Text( + getLevelBadge(level), + style: AppTypography.caption1.copyWith( + color: getLevelColor(level), + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ClipRRect( + borderRadius: AppRadius.smBorder, + child: LinearProgressIndicator( + value: expProgress.clamp(0.0, 1.0), + backgroundColor: ext.bgSecondary, + valueColor: AlwaysStoppedAnimation( + getLevelColor(level), + ), + minHeight: 6, + ), + ), + const SizedBox(height: 2), + Text( + '$exp / $expToNext EXP', + style: AppTypography.caption2.copyWith( + color: ext.textHint, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.md), Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ diff --git a/lib/features/achievement/presentation/badge_wall_page.dart b/lib/features/achievement/presentation/badge_wall_page.dart new file mode 100644 index 00000000..543c9e97 --- /dev/null +++ b/lib/features/achievement/presentation/badge_wall_page.dart @@ -0,0 +1,729 @@ +// ============================================================ +// 闲言APP — 勋章墙页面 +// 创建时间: 2026-05-14 +// 更新时间: 2026-05-14 +// 作用: 勋章展示/展示位设置/稀有度展示 +// 上次更新: 初始创建 +// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart' hide Badge; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/theme/app_radius.dart'; +import '../../../core/theme/app_spacing.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/theme/app_typography.dart'; +import '../../../shared/widgets/glass_container.dart'; +import '../providers/badge_provider.dart'; +import '../shared/widgets/badge_icon.dart'; + +class BadgeWallPage extends ConsumerStatefulWidget { + const BadgeWallPage({super.key}); + + @override + ConsumerState createState() => _BadgeWallPageState(); +} + +class _BadgeWallPageState extends ConsumerState { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(badgeProvider.notifier).loadBadges(); + }); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(badgeProvider); + final ext = Theme.of(context).extension()!; + + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('🎖️ 勋章墙'), + previousPageTitle: '返回', + ), + child: SafeArea( + child: CupertinoScrollbar( + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + CupertinoSliverRefreshControl( + onRefresh: () => + ref.read(badgeProvider.notifier).loadBadges(), + ), + if (state.isLoading && state.badges.isEmpty) + const SliverFillRemaining( + child: Center(child: CupertinoActivityIndicator()), + ) + else ...[ + SliverToBoxAdapter( + child: _BadgeStatsHeader( + stats: state.stats, + ext: ext, + ) + .animate() + .fadeIn(duration: 400.ms) + .slideY(begin: 0.08, end: 0, duration: 400.ms), + ), + const SliverToBoxAdapter( + child: SizedBox(height: AppSpacing.md), + ), + SliverToBoxAdapter( + child: _BadgeGrid( + badges: state.badges, + displayBadgeIds: state.displayBadgeIds, + ext: ext, + onBadgeTap: _onBadgeTap, + ) + .animate() + .fadeIn(duration: 400.ms, delay: 100.ms) + .slideY(begin: 0.06, end: 0, duration: 400.ms), + ), + const SliverToBoxAdapter( + child: SizedBox(height: AppSpacing.md), + ), + SliverToBoxAdapter( + child: _DisplaySlotSection( + badges: state.badges, + displayBadgeIds: state.displayBadgeIds, + stats: state.stats, + isSettingDisplay: state.isSettingDisplay, + ext: ext, + onRemoveDisplay: _onRemoveDisplay, + ) + .animate() + .fadeIn(duration: 400.ms, delay: 200.ms) + .slideY(begin: 0.06, end: 0, duration: 400.ms), + ), + const SliverToBoxAdapter( + child: SizedBox(height: AppSpacing.xl), + ), + ], + ], + ), + ), + ), + ); + } + + /// 点击已解锁勋章 — 切换展示状态 + void _onBadgeTap(Badge badge) { + if (!badge.isUnlocked) return; + + final state = ref.read(badgeProvider); + final currentIds = List.from(state.displayBadgeIds); + + if (currentIds.contains(badge.id)) { + currentIds.remove(badge.id); + } else { + if (currentIds.length >= state.stats.maxDisplay) { + _showMaxDisplayToast(state.stats.maxDisplay); + return; + } + currentIds.add(badge.id); + } + + _setDisplayBadges(currentIds); + } + + /// 移除展示位 + void _onRemoveDisplay(Badge badge) { + final state = ref.read(badgeProvider); + final currentIds = List.from(state.displayBadgeIds); + currentIds.remove(badge.id); + _setDisplayBadges(currentIds); + } + + /// 调用设置展示勋章接口 + Future _setDisplayBadges(List badgeIds) async { + final success = await ref + .read(badgeProvider.notifier) + .setDisplayBadge(badgeIds); + if (!mounted) return; + if (!success) { + _showErrorToast('设置展示勋章失败'); + } + } + + /// 展示位已满提示 + void _showMaxDisplayToast(int maxDisplay) { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('💡 提示'), + content: Text('展示位最多 $maxDisplay 个,请先移除已有展示'), + actions: [ + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () => Navigator.pop(ctx), + child: const Text('知道了'), + ), + ], + ), + ); + } + + /// 错误提示 + void _showErrorToast(String message) { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('❌ 错误'), + content: Text(message), + actions: [ + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () => Navigator.pop(ctx), + child: const Text('确定'), + ), + ], + ), + ); + } +} + +// ============================================================ +// 顶部统计区 +// ============================================================ + +class _BadgeStatsHeader extends StatelessWidget { + const _BadgeStatsHeader({ + required this.stats, + required this.ext, + }); + + final BadgeStats stats; + final AppThemeExtension ext; + + @override + Widget build(BuildContext context) { + final unlockProgress = + stats.total > 0 ? stats.unlocked / stats.total : 0.0; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: GlassContainer( + depth: GlassDepth.elevated, + borderRadius: AppRadius.lgBorder, + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + children: [ + Row( + children: [ + _StatChip( + icon: '🔓', + label: '已解锁', + value: '${stats.unlocked}/${stats.total}', + color: ext.accent, + ), + const SizedBox(width: AppSpacing.md), + _StatChip( + icon: '🌟', + label: '展示位', + value: '${stats.displayedCount}/${stats.maxDisplay}', + color: BadgeRarityColors.getColor('legendary'), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + ClipRRect( + borderRadius: AppRadius.smBorder, + child: LinearProgressIndicator( + value: unlockProgress.clamp(0.0, 1.0), + backgroundColor: ext.bgSecondary, + valueColor: AlwaysStoppedAnimation(ext.accent), + minHeight: 6, + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '解锁进度', + style: AppTypography.caption2.copyWith( + color: ext.textHint, + ), + ), + Text( + '${(unlockProgress * 100).toInt()}%', + style: AppTypography.caption2.copyWith( + color: ext.accent, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _StatChip extends StatelessWidget { + const _StatChip({ + required this.icon, + required this.label, + required this.value, + required this.color, + }); + + final String icon; + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: AppRadius.mdBorder, + ), + child: Row( + children: [ + Text(icon, style: const TextStyle(fontSize: 20)), + const SizedBox(width: AppSpacing.sm), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: AppTypography.headline.copyWith( + color: color, + fontWeight: FontWeight.bold, + ), + ), + Text( + label, + style: AppTypography.caption2.copyWith( + color: color.withValues(alpha: 0.7), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +// ============================================================ +// 勋章网格 +// ============================================================ + +class _BadgeGrid extends StatelessWidget { + const _BadgeGrid({ + required this.badges, + required this.displayBadgeIds, + required this.ext, + required this.onBadgeTap, + }); + + final List badges; + final List displayBadgeIds; + final AppThemeExtension ext; + final void Function(Badge) onBadgeTap; + + @override + Widget build(BuildContext context) { + if (badges.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Text( + '暂无勋章数据', + style: AppTypography.subhead.copyWith(color: ext.textHint), + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: AppSpacing.sm, + crossAxisSpacing: AppSpacing.sm, + childAspectRatio: 0.72, + ), + itemCount: badges.length, + itemBuilder: (context, index) { + final badge = badges[index]; + return _BadgeCard( + badge: badge, + isDisplayed: displayBadgeIds.contains(badge.id), + ext: ext, + onTap: () => onBadgeTap(badge), + ) + .animate() + .fadeIn( + duration: 300.ms, + delay: (index * 40).ms, + ) + .scale( + begin: const Offset(0.92, 0.92), + end: const Offset(1.0, 1.0), + duration: 300.ms, + curve: Curves.easeOutCubic, + delay: (index * 40).ms, + ); + }, + ), + ); + } +} + +// ============================================================ +// 单个勋章卡片 +// ============================================================ + +class _BadgeCard extends StatelessWidget { + const _BadgeCard({ + required this.badge, + required this.isDisplayed, + required this.ext, + required this.onTap, + }); + + final Badge badge; + final bool isDisplayed; + final AppThemeExtension ext; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final rarityColor = BadgeRarityColors.getColor(badge.rarity); + + return GestureDetector( + onTap: badge.isUnlocked ? onTap : null, + child: GlassContainer( + depth: badge.isUnlocked ? GlassDepth.elevated : GlassDepth.base, + borderRadius: AppRadius.lgBorder, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.md, + ), + borderColor: isDisplayed + ? rarityColor.withValues(alpha: 0.8) + : null, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + alignment: Alignment.center, + children: [ + BadgeIcon( + icon: badge.icon, + rarity: badge.rarity, + size: 52, + isUnlocked: badge.isUnlocked, + ), + if (isDisplayed) + Positioned( + right: 0, + top: 0, + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: rarityColor, + shape: BoxShape.circle, + ), + child: const Icon( + CupertinoIcons.star_fill, + size: 10, + color: CupertinoColors.white, + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Text( + badge.name, + style: AppTypography.caption1.copyWith( + color: badge.isUnlocked ? ext.textPrimary : ext.textHint, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + if (badge.isUnlocked) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: rarityColor.withValues(alpha: 0.12), + borderRadius: AppRadius.pillBorder, + ), + child: Text( + BadgeRarityColors.getLabel(badge.rarity), + style: AppTypography.caption2.copyWith( + color: rarityColor, + fontWeight: FontWeight.w600, + fontSize: 10, + ), + ), + ) + else + Text( + badge.condition.isNotEmpty ? badge.condition : badge.description, + style: AppTypography.caption2.copyWith( + color: ext.textHint, + fontSize: 10, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } +} + +// ============================================================ +// 底部展示位设置区 +// ============================================================ + +class _DisplaySlotSection extends StatelessWidget { + const _DisplaySlotSection({ + required this.badges, + required this.displayBadgeIds, + required this.stats, + required this.isSettingDisplay, + required this.ext, + required this.onRemoveDisplay, + }); + + final List badges; + final List displayBadgeIds; + final BadgeStats stats; + final bool isSettingDisplay; + final AppThemeExtension ext; + final void Function(Badge) onRemoveDisplay; + + @override + Widget build(BuildContext context) { + final displayedBadges = badges.where((b) => b.isDisplayed).toList(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: GlassContainer( + depth: GlassDepth.elevated, + borderRadius: AppRadius.lgBorder, + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('🌟', style: TextStyle(fontSize: 18)), + const SizedBox(width: AppSpacing.sm), + Text( + '展示位', + style: AppTypography.title3.copyWith( + color: ext.textPrimary, + ), + ), + const Spacer(), + if (isSettingDisplay) + const CupertinoActivityIndicator() + else + Text( + '${displayBadgeIds.length}/${stats.maxDisplay}', + style: AppTypography.caption1.copyWith( + color: ext.textHint, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(stats.maxDisplay, (index) { + if (index < displayedBadges.length) { + final badge = displayedBadges[index]; + return _DisplaySlot( + badge: badge, + ext: ext, + onRemove: () => onRemoveDisplay(badge), + ); + } + return _EmptySlot(ext: ext); + }), + ), + const SizedBox(height: AppSpacing.sm), + Center( + child: Text( + '点击已解锁勋章可设为展示/取消展示', + style: AppTypography.caption2.copyWith( + color: ext.textHint, + ), + ), + ), + ], + ), + ), + ); + } +} + +// ============================================================ +// 展示位 — 已占用 +// ============================================================ + +class _DisplaySlot extends StatelessWidget { + const _DisplaySlot({ + required this.badge, + required this.ext, + required this.onRemove, + }); + + final Badge badge; + final AppThemeExtension ext; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final rarityColor = BadgeRarityColors.getColor(badge.rarity); + + return GestureDetector( + onTap: onRemove, + child: Column( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + rarityColor.withValues(alpha: 0.2), + rarityColor.withValues(alpha: 0.05), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + border: Border.all( + color: rarityColor.withValues(alpha: 0.7), + width: 2.5, + ), + boxShadow: [ + BoxShadow( + color: rarityColor.withValues(alpha: 0.25), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ], + ), + child: Center( + child: Text( + badge.icon, + style: const TextStyle(fontSize: 26), + ), + ), + ), + Positioned( + right: -4, + top: -4, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: ext.errorColor, + shape: BoxShape.circle, + border: Border.all( + color: ext.bgPrimary, + width: 1.5, + ), + ), + child: const Icon( + CupertinoIcons.xmark, + size: 10, + color: CupertinoColors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + badge.name, + style: AppTypography.caption2.copyWith( + color: ext.textSecondary, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } +} + +// ============================================================ +// 展示位 — 空位 +// ============================================================ + +class _EmptySlot extends StatelessWidget { + const _EmptySlot({required this.ext}); + + final AppThemeExtension ext; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: ext.bgSecondary.withValues(alpha: 0.4), + border: Border.all( + color: ext.textHint.withValues(alpha: 0.2), + width: 1.5, + ), + ), + child: Center( + child: Icon( + CupertinoIcons.plus, + size: 24, + color: ext.textHint.withValues(alpha: 0.4), + ), + ), + ), + const SizedBox(height: 4), + Text( + '空位', + style: AppTypography.caption2.copyWith( + color: ext.textHint, + ), + ), + ], + ); + } +} diff --git a/lib/features/achievement/providers/badge_provider.dart b/lib/features/achievement/providers/badge_provider.dart new file mode 100644 index 00000000..2d510dcf --- /dev/null +++ b/lib/features/achievement/providers/badge_provider.dart @@ -0,0 +1,303 @@ +// ============================================================ +// 闲言APP — 勋章墙状态管理 +// 创建时间: 2026-05-14 +// 更新时间: 2026-05-14 +// 作用: 勋章列表加载/展示位设置状态管理 +// 上次更新: 初始创建 +// ============================================================ + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/network/api_client.dart'; +import '../../../core/network/api_exception.dart'; +import '../../../core/network/api_response.dart'; +import '../../../core/utils/logger.dart'; + +// ============================================================ +// Badge 数据模型 +// ============================================================ + +class Badge { + const Badge({ + required this.id, + required this.name, + required this.icon, + required this.description, + this.rarity = 'common', + this.type = '', + this.condition = '', + this.conditionValue = 0, + this.isUnlocked = false, + this.isDisplayed = false, + this.unlockedAt, + this.expReward = 0, + this.scoreReward = 0, + }); + + final int id; + final String name; + final String icon; + final String description; + final String rarity; + final String type; + final String condition; + final int conditionValue; + final bool isUnlocked; + final bool isDisplayed; + final String? unlockedAt; + final int expReward; + final int scoreReward; + + factory Badge.fromJson(Map json) => Badge( + id: json['id'] as int? ?? 0, + name: json['name'] as String? ?? '', + icon: json['icon'] as String? ?? '🎖️', + description: json['description'] as String? ?? '', + rarity: json['rarity'] as String? ?? 'common', + type: json['type'] as String? ?? '', + condition: json['condition'] as String? ?? '', + conditionValue: json['condition_value'] as int? ?? 0, + isUnlocked: json['is_unlocked'] as bool? ?? false, + isDisplayed: json['is_displayed'] as bool? ?? false, + unlockedAt: json['unlocked_at'] as String?, + expReward: json['exp_reward'] as int? ?? 0, + scoreReward: json['score_reward'] as int? ?? 0, + ); + + Badge copyWith({ + bool? isUnlocked, + bool? isDisplayed, + String? unlockedAt, + }) => + Badge( + id: id, + name: name, + icon: icon, + description: description, + rarity: rarity, + type: type, + condition: condition, + conditionValue: conditionValue, + isUnlocked: isUnlocked ?? this.isUnlocked, + isDisplayed: isDisplayed ?? this.isDisplayed, + unlockedAt: unlockedAt ?? this.unlockedAt, + expReward: expReward, + scoreReward: scoreReward, + ); +} + +// ============================================================ +// Badge 统计信息 +// ============================================================ + +class BadgeStats { + const BadgeStats({ + this.total = 0, + this.unlocked = 0, + this.displayedCount = 0, + this.maxDisplay = 3, + }); + + final int total; + final int unlocked; + final int displayedCount; + final int maxDisplay; + + factory BadgeStats.fromJson(Map json) => BadgeStats( + total: json['total'] as int? ?? 0, + unlocked: json['unlocked'] as int? ?? 0, + displayedCount: json['displayed_count'] as int? ?? 0, + maxDisplay: json['max_display'] as int? ?? 3, + ); + + BadgeStats copyWith({ + int? total, + int? unlocked, + int? displayedCount, + int? maxDisplay, + }) => + BadgeStats( + total: total ?? this.total, + unlocked: unlocked ?? this.unlocked, + displayedCount: displayedCount ?? this.displayedCount, + maxDisplay: maxDisplay ?? this.maxDisplay, + ); +} + +// ============================================================ +// Badge 状态 +// ============================================================ + +class BadgeState { + const BadgeState({ + this.isLoading = false, + this.error, + this.badges = const [], + this.stats = const BadgeStats(), + this.displayBadgeIds = const [], + this.isSettingDisplay = false, + }); + + final bool isLoading; + final String? error; + final List badges; + final BadgeStats stats; + final List displayBadgeIds; + final bool isSettingDisplay; + + List get unlockedBadges => + badges.where((b) => b.isUnlocked).toList(); + + List get lockedBadges => + badges.where((b) => !b.isUnlocked).toList(); + + List get displayedBadges => + badges.where((b) => b.isDisplayed).toList(); + + BadgeState copyWith({ + bool? isLoading, + String? error, + bool clearError = false, + List? badges, + BadgeStats? stats, + List? displayBadgeIds, + bool? isSettingDisplay, + }) => + BadgeState( + isLoading: isLoading ?? this.isLoading, + error: clearError ? null : (error ?? this.error), + badges: badges ?? this.badges, + stats: stats ?? this.stats, + displayBadgeIds: displayBadgeIds ?? this.displayBadgeIds, + isSettingDisplay: isSettingDisplay ?? this.isSettingDisplay, + ); +} + +// ============================================================ +// Badge Notifier +// ============================================================ + +class BadgeNotifier extends StateNotifier { + BadgeNotifier() : super(const BadgeState()); + + static final ApiClient _api = ApiClient.instance; + static const String _basePath = '/api/achievement'; + + /// 加载勋章列表 + Future loadBadges() async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final response = await _api.get>( + '$_basePath/badges', + ); + final apiResp = ApiResponse>.fromJson( + response.data as Map, + ); + if (!apiResp.isSuccess) { + throw ApiException(code: apiResp.code, message: apiResp.msg); + } + final data = apiResp.data ?? {}; + final list = (data['list'] as List? ?? []) + .map((e) => Badge.fromJson(e as Map)) + .toList(); + final stats = BadgeStats.fromJson(data['stats'] as Map? ?? {}); + final displayIds = list + .where((b) => b.isDisplayed) + .map((b) => b.id) + .toList(); + state = state.copyWith( + badges: list, + stats: stats, + displayBadgeIds: displayIds, + isLoading: false, + ); + } on DioException catch (e) { + Log.e('加载勋章列表失败', e); + state = state.copyWith( + isLoading: false, + error: _handleDioError(e).message, + ); + } catch (e) { + Log.e('加载勋章列表失败', e); + state = state.copyWith(isLoading: false, error: '加载失败'); + } + } + + /// 设置展示勋章 + Future setDisplayBadge(List badgeIds) async { + if (badgeIds.length > state.stats.maxDisplay) { + Log.w('展示勋章数量超过上限'); + return false; + } + state = state.copyWith(isSettingDisplay: true); + try { + final response = await _api.post>( + '$_basePath/badgeDisplay', + data: {'badge_ids': badgeIds}, + ); + final respData = response.data as Map; + final code = respData['code'] as int? ?? 0; + if (code != 1) { + throw ApiException( + code: code, + message: respData['msg'] as String? ?? '设置失败', + ); + } + final updatedBadges = state.badges.map((b) { + final shouldDisplay = badgeIds.contains(b.id); + return b.copyWith(isDisplayed: shouldDisplay); + }).toList(); + state = state.copyWith( + badges: updatedBadges, + displayBadgeIds: badgeIds, + isSettingDisplay: false, + stats: state.stats.copyWith( + displayedCount: badgeIds.length, + ), + ); + return true; + } on DioException catch (e) { + Log.e('设置展示勋章失败', e); + state = state.copyWith(isSettingDisplay: false); + return false; + } catch (e) { + Log.e('设置展示勋章失败', e); + state = state.copyWith(isSettingDisplay: false); + return false; + } + } + + static ApiException _handleDioError(DioException e) { + switch (e.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return const ApiException(code: -1, message: '连接超时,请检查网络'); + case DioExceptionType.connectionError: + return const ApiException(code: -2, message: '网络连接失败'); + default: + if (e.response?.data != null) { + try { + final data = e.response?.data; + if (data is Map) { + return ApiException( + code: data['code'] as int? ?? e.response?.statusCode ?? -5, + message: data['msg'] as String? ?? '请求失败', + ); + } + } catch (_) {} + } + return const ApiException(code: -5, message: '未知网络错误'); + } + } +} + +// ============================================================ +// Provider +// ============================================================ + +final badgeProvider = + StateNotifierProvider((ref) { + return BadgeNotifier(); +}); diff --git a/lib/features/achievement/shared/widgets/badge_icon.dart b/lib/features/achievement/shared/widgets/badge_icon.dart new file mode 100644 index 00000000..cb04ea39 --- /dev/null +++ b/lib/features/achievement/shared/widgets/badge_icon.dart @@ -0,0 +1,152 @@ +// ============================================================ +// 闲言APP — 勋章图标组件 +// 创建时间: 2026-05-14 +// 更新时间: 2026-05-14 +// 作用: 勋章图标展示,支持稀有度边框/锁定状态 +// 上次更新: 初始创建 +// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart' show Theme; + +import '../../../../core/theme/app_theme.dart'; + +// ============================================================ +// 稀有度颜色映射 +// ============================================================ + +class BadgeRarityColors { + BadgeRarityColors._(); + + static const Map _colorValues = { + 'common': 0xFF8B8B8B, + 'rare': 0xFF5B9BD5, + 'epic': 0xFF9B59B6, + 'legendary': 0xFFF39C12, + }; + + static Color getColor(String rarity) { + final value = _colorValues[rarity] ?? _colorValues['common']!; + return Color(value); + } + + static String getLabel(String rarity) { + return switch (rarity) { + 'common' => '普通', + 'rare' => '稀有', + 'epic' => '史诗', + 'legendary' => '传说', + _ => '普通', + }; + } + + static String getEmoji(String rarity) { + return switch (rarity) { + 'common' => '⚪', + 'rare' => '🔵', + 'epic' => '🟣', + 'legendary' => '🟡', + _ => '⚪', + }; + } +} + +// ============================================================ +// BadgeIcon 组件 +// ============================================================ + +class BadgeIcon extends StatelessWidget { + const BadgeIcon({ + super.key, + required this.icon, + required this.rarity, + this.size = 48.0, + this.isUnlocked = true, + this.showBorder = true, + }); + + final String icon; + final String rarity; + final double size; + final bool isUnlocked; + final bool showBorder; + + @override + Widget build(BuildContext context) { + final rarityColor = BadgeRarityColors.getColor(rarity); + final ext = Theme.of(context).extension()!; + + if (!isUnlocked) { + return _buildLocked(ext); + } + return _buildUnlocked(rarityColor, ext); + } + + /// 已解锁状态 + Widget _buildUnlocked(Color rarityColor, AppThemeExtension ext) { + final iconSize = size * 0.5; + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + rarityColor.withValues(alpha: 0.15), + rarityColor.withValues(alpha: 0.05), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + border: showBorder + ? Border.all( + color: rarityColor.withValues(alpha: 0.6), + width: 2.5, + ) + : null, + boxShadow: [ + BoxShadow( + color: rarityColor.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Center( + child: Text( + icon, + style: TextStyle(fontSize: iconSize), + textAlign: TextAlign.center, + ), + ), + ); + } + + /// 未解锁状态 + Widget _buildLocked(AppThemeExtension ext) { + final iconSize = size * 0.35; + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: ext.bgSecondary.withValues(alpha: 0.5), + border: showBorder + ? Border.all( + color: ext.textHint.withValues(alpha: 0.3), + width: 1.5, + ) + : null, + ), + child: Center( + child: Text( + '🔒', + style: TextStyle(fontSize: iconSize), + textAlign: TextAlign.center, + ), + ), + ); + } +} diff --git a/lib/features/auth/models/user_model.dart b/lib/features/auth/models/user_model.dart index eca71650..6ff61225 100644 --- a/lib/features/auth/models/user_model.dart +++ b/lib/features/auth/models/user_model.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 用户数据模型 /// 创建时间: 2026-04-28 -/// 更新时间: 2026-05-11 +/// 更新时间: 2026-05-15 /// 作用: 用户信息数据模型,对应后端 tool_user 表 -/// 上次更新: v10.0.0 UserDevice新增ipCity/ipRange字段 +/// 上次更新: v10.1.0 UserModel新增secQuestion/secQuestionText字段 /// ============================================================ class UserModel { @@ -16,6 +16,10 @@ class UserModel { this.email = '', this.mobile = '', this.score = 0, + this.level = 1, + this.exp = 0, + this.expToNext = 0, + this.expProgress = 0.0, this.money = '0.00', this.titleId = 1, this.signinDays = 0, @@ -32,6 +36,8 @@ class UserModel { this.devices = const [], this.extra, this.profileSlug = '', + this.secQuestion = 0, + this.secQuestionText = '', }); final int id; @@ -42,6 +48,10 @@ class UserModel { final String email; final String mobile; final int score; + final int level; + final int exp; + final int expToNext; + final double expProgress; final String money; final int titleId; final int signinDays; @@ -59,6 +69,10 @@ class UserModel { final List devices; final UserExtra? extra; final String profileSlug; + final int secQuestion; + final String secQuestionText; + + bool get hasSecQuestion => secQuestion > 0; String get displayName => nickname.isNotEmpty ? nickname : username; @@ -92,6 +106,10 @@ class UserModel { email: json['email'] as String? ?? '', mobile: json['mobile'] as String? ?? '', score: json['score'] as int? ?? 0, + level: json['level'] as int? ?? 1, + exp: json['exp'] as int? ?? 0, + expToNext: json['exp_to_next'] as int? ?? 0, + expProgress: (json['exp_progress'] as num?)?.toDouble() ?? 0.0, money: json['money']?.toString() ?? '0.00', titleId: json['title_id'] as int? ?? 1, signinDays: json['signin_days'] as int? ?? 0, @@ -124,6 +142,8 @@ class UserModel { ? UserExtra.fromJson(json['extra'] as Map) : null, profileSlug: json['profile_slug'] as String? ?? '', + secQuestion: json['sec_question'] as int? ?? 0, + secQuestionText: json['sec_question_text'] as String? ?? '', ); } @@ -137,6 +157,10 @@ class UserModel { 'email': email, 'mobile': mobile, 'score': score, + 'level': level, + 'exp': exp, + 'exp_to_next': expToNext, + 'exp_progress': expProgress, 'money': money, 'title_id': titleId, 'signin_days': signinDays, @@ -153,6 +177,8 @@ class UserModel { 'devices': devices.map((e) => e.toJson()).toList(), 'extra': extra?.toJson(), 'profile_slug': profileSlug, + 'sec_question': secQuestion, + 'sec_question_text': secQuestionText, }; } @@ -165,6 +191,10 @@ class UserModel { String? email, String? mobile, int? score, + int? level, + int? exp, + int? expToNext, + double? expProgress, String? money, int? titleId, int? signinDays, @@ -181,6 +211,8 @@ class UserModel { List? devices, UserExtra? extra, String? profileSlug, + int? secQuestion, + String? secQuestionText, }) { return UserModel( id: id ?? this.id, @@ -191,6 +223,10 @@ class UserModel { email: email ?? this.email, mobile: mobile ?? this.mobile, score: score ?? this.score, + level: level ?? this.level, + exp: exp ?? this.exp, + expToNext: expToNext ?? this.expToNext, + expProgress: expProgress ?? this.expProgress, money: money ?? this.money, titleId: titleId ?? this.titleId, signinDays: signinDays ?? this.signinDays, @@ -207,6 +243,8 @@ class UserModel { devices: devices ?? this.devices, extra: extra ?? this.extra, profileSlug: profileSlug ?? this.profileSlug, + secQuestion: secQuestion ?? this.secQuestion, + secQuestionText: secQuestionText ?? this.secQuestionText, ); } } diff --git a/lib/features/auth/presentation/register_section.dart b/lib/features/auth/presentation/register_section.dart index 7f41f5cb..05252638 100644 --- a/lib/features/auth/presentation/register_section.dart +++ b/lib/features/auth/presentation/register_section.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 注册区域组件 /// 创建时间: 2026-05-10 -/// 更新时间: 2026-05-10 +/// 更新时间: 2026-05-15 /// 作用: 登录页面的注册区域,分步式注册流程 -/// 上次更新: 从 login_page.dart 拆分,独立管理注册状态 +/// 上次更新: v10.1.0 Step3新增密保问题选填 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -18,6 +18,7 @@ import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/glass_container.dart'; import '../providers/auth_provider.dart'; import '../services/email_service.dart'; +import '../services/user_security_service.dart'; import 'login_form_sections.dart'; class RegisterSection extends ConsumerStatefulWidget { @@ -42,12 +43,17 @@ class _RegisterSectionState extends ConsumerState { bool _subscribeEmail = false; bool _emsSending = false; int _emsCountdown = 0; + bool _showSecQuestion = false; + int? _selectedSecQuestion; + String _selectedSecQuestionText = ''; + List _secQuestions = []; final _usernameController = TextEditingController(); final _emailController = TextEditingController(); final _regPasswordController = TextEditingController(); final _regConfirmPasswordController = TextEditingController(); final _regCodeController = TextEditingController(); + final _secAnswerController = TextEditingController(); @override void initState() { @@ -57,6 +63,19 @@ class _RegisterSectionState extends ConsumerState { _regPasswordController.addListener(() => setState(() {})); _regConfirmPasswordController.addListener(() => setState(() {})); _regCodeController.addListener(() => setState(() {})); + _secAnswerController.addListener(() => setState(() {})); + _loadSecQuestions(); + } + + Future _loadSecQuestions() async { + try { + final questions = await UserSecurityService.secQuestions(); + if (mounted) { + setState(() => _secQuestions = questions); + } + } catch (e) { + Log.w('加载密保问题列表失败: $e'); + } } @override @@ -66,6 +85,7 @@ class _RegisterSectionState extends ConsumerState { _regPasswordController.dispose(); _regConfirmPasswordController.dispose(); _regCodeController.dispose(); + _secAnswerController.dispose(); super.dispose(); } @@ -385,6 +405,125 @@ class _RegisterSectionState extends ConsumerState { obscureText: true, ), const SizedBox(height: AppSpacing.md), + GestureDetector( + onTap: () => setState(() => _showSecQuestion = !_showSecQuestion), + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: AppRadius.mdBorder, + border: Border.all( + color: _showSecQuestion + ? ext.accent.withValues(alpha: 0.4) + : ext.textHint.withValues(alpha: 0.15), + ), + ), + child: Row( + children: [ + Icon( + CupertinoIcons.shield, + size: 16, + color: _showSecQuestion ? ext.accent : ext.textHint, + ), + const SizedBox(width: AppSpacing.sm), + Text( + '🛡️ 密保问题(选填)', + style: AppTypography.subhead.copyWith( + color: _showSecQuestion + ? ext.accent + : ext.textSecondary, + ), + ), + const Spacer(), + Text( + _selectedSecQuestionText.isNotEmpty ? '已选择' : '增强账号安全', + style: AppTypography.caption1.copyWith( + color: ext.textHint, + ), + ), + const SizedBox(width: 4), + AnimatedRotation( + duration: const Duration(milliseconds: 200), + turns: _showSecQuestion ? 0.5 : 0, + child: Icon( + CupertinoIcons.chevron_down, + size: 14, + color: ext.textHint, + ), + ), + ], + ), + ), + ), + if (_showSecQuestion) ...[ + const SizedBox(height: AppSpacing.sm), + CupertinoButton( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + color: ext.bgSecondary, + borderRadius: AppRadius.mdBorder, + onPressed: _secQuestions.isEmpty + ? null + : () => _showSecQuestionPicker(context), + child: Row( + children: [ + Icon( + CupertinoIcons.question_circle, + size: 16, + color: ext.textSecondary, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + _selectedSecQuestionText.isNotEmpty + ? _selectedSecQuestionText + : '选择密保问题', + style: AppTypography.subhead.copyWith( + color: _selectedSecQuestionText.isNotEmpty + ? ext.textPrimary + : ext.textHint, + ), + ), + ), + Icon( + CupertinoIcons.chevron_right, + size: 14, + color: ext.textHint, + ), + ], + ), + ), + if (_selectedSecQuestion != null) ...[ + const SizedBox(height: AppSpacing.sm), + Container( + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: AppRadius.mdBorder, + ), + child: CupertinoTextField( + controller: _secAnswerController, + placeholder: '输入密保答案(1-50位)', + placeholderStyle: AppTypography.body.copyWith( + color: ext.textHint, + ), + style: AppTypography.body.copyWith(color: ext.textPrimary), + decoration: null, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + maxLength: 50, + ), + ), + ], + ], + const SizedBox(height: AppSpacing.md), GestureDetector( onTap: () => setState(() => _subscribeEmail = !_subscribeEmail), behavior: HitTestBehavior.opaque, @@ -394,7 +533,9 @@ class _RegisterSectionState extends ConsumerState { width: 20, height: 20, decoration: BoxDecoration( - color: _subscribeEmail ? ext.accent : CupertinoColors.transparent, + color: _subscribeEmail + ? ext.accent + : CupertinoColors.transparent, borderRadius: BorderRadius.circular(5), border: Border.all( color: _subscribeEmail ? ext.accent : ext.textHint, @@ -529,7 +670,15 @@ class _RegisterSectionState extends ConsumerState { final success = await ref .read(authProvider.notifier) - .register(username: username, password: password, email: email); + .register( + username: username, + password: password, + email: email, + secQuestion: _selectedSecQuestion, + secAnswer: _secAnswerController.text.trim().isNotEmpty + ? _secAnswerController.text.trim() + : null, + ); if (success && mounted) { AppToast.showSuccess('注册成功,欢迎加入!'); widget.onRegisterSuccess(); @@ -585,4 +734,79 @@ class _RegisterSectionState extends ConsumerState { return _emsCountdown > 0; }); } + + void _showSecQuestionPicker(BuildContext context) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => Container( + height: 260, + color: ext.bgElevated.withValues(alpha: 0.95), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CupertinoButton( + child: Text( + '取消', + style: AppTypography.subhead.copyWith( + color: ext.textSecondary, + ), + ), + onPressed: () => Navigator.pop(ctx), + ), + Text( + '选择密保问题', + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + CupertinoButton( + child: Text( + '确定', + style: AppTypography.subhead.copyWith( + color: ext.accent, + fontWeight: FontWeight.w600, + ), + ), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + Expanded( + child: CupertinoPicker( + itemExtent: 36, + scrollController: FixedExtentScrollController( + initialItem: _selectedSecQuestion != null + ? _secQuestions.indexWhere( + (q) => q.id == _selectedSecQuestion, + ) + : 0, + ), + onSelectedItemChanged: (index) { + if (index < _secQuestions.length) { + setState(() { + _selectedSecQuestion = _secQuestions[index].id; + _selectedSecQuestionText = _secQuestions[index].question; + }); + } + }, + children: _secQuestions.map((q) { + return Center( + child: Text( + q.question, + style: AppTypography.subhead.copyWith( + color: ext.textPrimary, + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ), + ); + } } diff --git a/lib/features/auth/providers/auth_provider.dart b/lib/features/auth/providers/auth_provider.dart index 184d3163..24a6a112 100644 --- a/lib/features/auth/providers/auth_provider.dart +++ b/lib/features/auth/providers/auth_provider.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 认证状态管理 /// 创建时间: 2026-04-28 -/// 更新时间: 2026-05-14 +/// 更新时间: 2026-05-15 /// 作用: 管理用户登录状态、Token、用户信息 -/// 上次更新: v6.3.2 构造函数同步加载缓存用户消除加载闪烁; refreshUser更新缓存; logout清缓存 +/// 上次更新: v10.1.0 register增加密保参数; changePassword支持多验证方式 /// ============================================================ import 'dart:convert'; @@ -11,7 +11,7 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/network/api_exception.dart'; -import '../../../core/services/device_info_service.dart'; +import '../../../core/services/device/device_info_service.dart'; import '../../../core/storage/app_kv_store.dart'; import '../../../core/storage/secure_storage.dart'; import '../../../core/utils/logger.dart'; @@ -180,6 +180,8 @@ class AuthNotifier extends StateNotifier { required String email, String? mobile, String? mobileCode, + int? secQuestion, + String? secAnswer, }) async { state = state.copyWith(isLoading: true, clearError: true); try { @@ -189,6 +191,8 @@ class AuthNotifier extends StateNotifier { email: email, mobile: mobile, mobileCode: mobileCode, + secQuestion: secQuestion, + secAnswer: secAnswer, ); await _saveUserCache(user); state = state.copyWith(user: user, isLoggedIn: true, isLoading: false); @@ -241,15 +245,19 @@ class AuthNotifier extends StateNotifier { } Future changePassword({ - required String oldPassword, required String newPassword, + String? oldPassword, + String? secAnswer, String? userId, + String verifyMethod = 'password', }) async { try { await AuthService.changePassword( - oldPassword: oldPassword, newPassword: newPassword, + oldPassword: oldPassword, + secAnswer: secAnswer, userId: userId, + verifyMethod: verifyMethod, ); return true; } on ApiException catch (e) { diff --git a/lib/features/auth/services/auth_service.dart b/lib/features/auth/services/auth_service.dart index f50fdce8..37512ce2 100644 --- a/lib/features/auth/services/auth_service.dart +++ b/lib/features/auth/services/auth_service.dart @@ -1,13 +1,13 @@ /// ============================================================ /// 闲言APP — 认证服务门面 /// 创建时间: 2026-04-29 -/// 更新时间: 2026-05-10 +/// 更新时间: 2026-05-15 /// 作用: 统一认证入口,委托给UserSecurityService和UserCenterService -/// 上次更新: v9.0.0 同步设备信息参数+二维码登录+账号注销+设备管理 +/// 上次更新: v10.1.0 新增密保问题接口+多验证方式支持 /// ============================================================ import '../models/user_model.dart'; -import '../../../core/services/token_service.dart'; +import '../../../core/services/auth/token_service.dart'; import 'user_security_service.dart'; import '../../user_center/services/user_center_service.dart'; @@ -122,6 +122,8 @@ class AuthService { required String email, String? mobile, String? mobileCode, + int? secQuestion, + String? secAnswer, }) async { return UserSecurityService.register( username: username, @@ -129,6 +131,8 @@ class AuthService { email: email, mobile: mobile, mobileCode: mobileCode, + secQuestion: secQuestion, + secAnswer: secAnswer, ); } @@ -136,16 +140,20 @@ class AuthService { // 密码管理 // ============================================================ - /// 修改密码 + /// 修改密码 (支持多验证方式) static Future changePassword({ - required String oldPassword, required String newPassword, + String? oldPassword, + String? secAnswer, String? userId, + String verifyMethod = 'password', }) async { return UserSecurityService.changePassword( - oldPassword: oldPassword, newPassword: newPassword, + oldPassword: oldPassword, + secAnswer: secAnswer, userId: userId, + verifyMethod: verifyMethod, ); } @@ -168,11 +176,18 @@ class AuthService { // 邮箱/手机变更 // ============================================================ - /// 修改邮箱 (回执验证) + /// 修改邮箱 (支持回执/密保验证) static Future> changeEmail({ required String email, + String verifyMethod = 'receipt', + String? secAnswer, + String? userId, }) async { - return UserCenterService.changeEmail(email: email); + return UserCenterService.changeEmail( + email: email, + verifyMethod: verifyMethod, + secAnswer: secAnswer, + ); } /// 修改手机号 (回执验证) @@ -354,4 +369,32 @@ class AuthService { static Future checkToken() async { return TokenService.checkToken(); } + + // ============================================================ + // 密保问题 (v10.1.0新增) + // ============================================================ + + /// 获取预置密保问题列表 + static Future> secQuestions() async { + return UserSecurityService.secQuestions(); + } + + /// 修改/设置密保问题 + static Future> changeSecQuestion({ + required int secQuestion, + required String secAnswer, + required String verifyMethod, + String? oldPassword, + String? secAnswerOld, + String? userId, + }) async { + return UserSecurityService.changeSecQuestion( + secQuestion: secQuestion, + secAnswer: secAnswer, + verifyMethod: verifyMethod, + oldPassword: oldPassword, + secAnswerOld: secAnswerOld, + userId: userId, + ); + } } diff --git a/lib/features/auth/services/user_security_service.dart b/lib/features/auth/services/user_security_service.dart index 4bd6da55..1715ee71 100644 --- a/lib/features/auth/services/user_security_service.dart +++ b/lib/features/auth/services/user_security_service.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 用户安全服务 /// 创建时间: 2026-04-29 -/// 更新时间: 2026-05-10 -/// 作用: 用户安全相关API封装(登录/注册/密码/邮箱/手机/回执验证/第三方/二维码登录) -/// 上次更新: v9.0.0 新增设备信息参数+二维码登录4接口+账号注销 +/// 更新时间: 2026-05-15 +/// 作用: 用户安全相关API封装(登录/注册/密码/邮箱/手机/回执验证/第三方/二维码登录/密保问题) +/// 上次更新: v10.1.0 新增密保问题接口+多验证方式支持 /// ============================================================ import 'dart:convert'; @@ -280,6 +280,8 @@ class UserSecurityService { required String email, String? mobile, String? mobileCode, + int? secQuestion, + String? secAnswer, }) async { try { final receipt = ReceiptHelper.generate(ReceiptAction.register, email); @@ -297,6 +299,10 @@ class UserSecurityService { data['mobile_code'] = mobileCode; } } + if (secQuestion != null && secQuestion > 0 && secAnswer != null && secAnswer.isNotEmpty) { + data['sec_question'] = secQuestion; + data['sec_answer'] = secAnswer; + } final response = await _api.post>( '$_basePath/register', @@ -319,19 +325,44 @@ class UserSecurityService { // 密码管理 // ============================================================ - /// 修改密码 (非测试用户需回执验证) + /// 修改密码 (支持多验证方式: password/sec_question/receipt) static Future changePassword({ - required String oldPassword, required String newPassword, + String? oldPassword, + String? secAnswer, String? userId, + String verifyMethod = 'password', }) async { try { final data = { - 'oldpassword': oldPassword, + 'verify_method': verifyMethod, 'newpassword': newPassword, }; - if (userId != null && userId.isNotEmpty) { + switch (verifyMethod) { + case 'password': + if (oldPassword == null || oldPassword.isEmpty) { + throw const ApiException(code: 0, message: '请输入旧密码'); + } + data['oldpassword'] = oldPassword; + break; + case 'sec_question': + if (secAnswer == null || secAnswer.isEmpty) { + throw const ApiException(code: 0, message: '请输入密保答案'); + } + data['sec_answer'] = secAnswer; + break; + case 'receipt': + if (userId == null || userId.isEmpty) { + throw const ApiException(code: 0, message: '用户ID不能为空'); + } + final receipt = ReceiptHelper.generate(ReceiptAction.changepwd, userId); + data['receipt'] = receipt.receipt; + data['sig'] = receipt.sig; + break; + } + + if (verifyMethod == 'password' && userId != null && userId.isNotEmpty) { final receipt = ReceiptHelper.generate(ReceiptAction.changepwd, userId); data['receipt'] = receipt.receipt; data['sig'] = receipt.sig; @@ -393,14 +424,37 @@ class UserSecurityService { // 邮箱/手机变更 (回执验证) // ============================================================ - /// 修改邮箱 (需登录,回执验证) - static Future changeEmail({required String email}) async { + /// 修改邮箱 (需登录,支持回执/密保验证) + static Future changeEmail({ + required String email, + String verifyMethod = 'receipt', + String? secAnswer, + String? userId, + }) async { try { - final receipt = ReceiptHelper.generate(ReceiptAction.changeemail, email); + final data = { + 'email': email, + 'verify_method': verifyMethod, + }; + + switch (verifyMethod) { + case 'sec_question': + if (secAnswer == null || secAnswer.isEmpty) { + throw const ApiException(code: 0, message: '请输入密保答案'); + } + data['sec_answer'] = secAnswer; + break; + case 'receipt': + default: + final receipt = ReceiptHelper.generate(ReceiptAction.changeemail, email); + data['receipt'] = receipt.receipt; + data['sig'] = receipt.sig; + break; + } final response = await _api.post>( '$_basePath/changeemail', - data: {'email': email, 'receipt': receipt.receipt, 'sig': receipt.sig}, + data: data, ); final apiResp = ApiResponse>.fromJson( response.data as Map, @@ -758,6 +812,93 @@ class UserSecurityService { } } + // ============================================================ + // 密保问题 (v10.1.0新增) + // ============================================================ + + /// 获取预置密保问题列表 (无需登录) + static Future> secQuestions() async { + try { + final response = await _api.get>( + '$_basePath/secQuestions', + ); + final apiResp = ApiResponse>.fromJson( + response.data as Map, + ); + if (!apiResp.isSuccess) { + throw ApiException(code: apiResp.code, message: apiResp.msg); + } + final questionsData = apiResp.data?['questions'] as List? ?? []; + return questionsData + .map((e) => SecQuestionItem.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + throw _handleDioError(e); + } + } + + /// 修改/设置密保问题 (需登录) + /// verify_method: password / sec_question / receipt + /// 首次设置: 仅支持 password 或 receipt + /// 修改密保: 需验证身份(旧密码/旧密保答案/回执 三选一) + static Future> changeSecQuestion({ + required int secQuestion, + required String secAnswer, + required String verifyMethod, + String? oldPassword, + String? secAnswerOld, + String? userId, + }) async { + try { + final data = { + 'sec_question': secQuestion, + 'sec_answer': secAnswer, + 'verify_method': verifyMethod, + }; + + switch (verifyMethod) { + case 'password': + if (oldPassword == null || oldPassword.isEmpty) { + throw const ApiException(code: 0, message: '请输入密码'); + } + data['oldpassword'] = oldPassword; + break; + case 'sec_question': + if (secAnswerOld == null || secAnswerOld.isEmpty) { + throw const ApiException(code: 0, message: '请输入旧密保答案'); + } + data['sec_answer_old'] = secAnswerOld; + break; + case 'receipt': + if (userId == null || userId.isEmpty) { + throw const ApiException(code: 0, message: '用户ID不能为空'); + } + final receipt = ReceiptHelper.generate( + ReceiptAction.changeSecQuestion, + userId, + ); + data['receipt'] = receipt.receipt; + data['sig'] = receipt.sig; + break; + } + + final response = await _api.post>( + '$_basePath/changeSecQuestion', + data: data, + ); + final apiResp = ApiResponse>.fromJson( + response.data as Map, + ); + if (!apiResp.isSuccess) { + throw ApiException(code: apiResp.code, message: apiResp.msg); + } + Log.i('密保问题设置成功'); + return apiResp.data ?? {}; + } on DioException catch (e) { + throw _handleDioError(e); + } + } + // ============================================================ // 私有方法 // ============================================================ @@ -837,6 +978,7 @@ class EmsEvent { static const String changepwd = 'changepwd'; static const String resetpwd = 'resetpwd'; static const String changeemail = 'changeemail'; + static const String changesecquestion = 'changesecquestion'; } /// 二维码生成结果 @@ -880,3 +1022,18 @@ class QrcodePollResult { bool get isCancelled => status == 'cancelled'; bool get isTerminal => isConfirmed || isExpired || isCancelled; } + +/// 密保问题项 +class SecQuestionItem { + const SecQuestionItem({required this.id, required this.question}); + + final int id; + final String question; + + factory SecQuestionItem.fromJson(Map json) { + return SecQuestionItem( + id: json['id'] as int? ?? 0, + question: json['question'] as String? ?? '', + ); + } +} diff --git a/lib/features/collaboration/canvas/pages/canvas_page.dart b/lib/features/collaboration/canvas/pages/canvas_page.dart index e9be12f5..ade91994 100644 --- a/lib/features/collaboration/canvas/pages/canvas_page.dart +++ b/lib/features/collaboration/canvas/pages/canvas_page.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 协作画布全屏页面 // 创建时间: 2026-05-14 -// 更新时间: 2026-05-14 +// 更新时间: 2026-05-15 // 作用: 全屏协作画布,支持绘制/同步/导出PNG/参与者显示 -// 上次更新: 初始创建 +// 上次更新: 修复deviceId传入错误,使用本机deviceId替代peerDeviceId // ============================================================ import 'dart:io'; @@ -20,6 +20,7 @@ import 'package:xianyan/core/theme/app_spacing.dart'; import 'package:xianyan/core/theme/app_typography.dart'; import 'package:xianyan/core/theme/app_radius.dart'; import 'package:xianyan/core/utils/logger.dart'; +import 'package:xianyan/features/file_transfer/providers/shared_signaling_provider.dart'; import '../providers/canvas_provider.dart'; import '../widgets/canvas_painter.dart'; import '../widgets/canvas_toolbar.dart'; @@ -50,9 +51,13 @@ class _CanvasPageState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { final notifier = ref.read(canvasProvider.notifier); notifier.setUserId(widget.userId); - if (widget.peerDeviceId != null) { - notifier.joinCanvas(widget.canvasId, widget.peerDeviceId!); - } + final deviceId = + ref.read(sharedSignalingProvider).deviceId ?? widget.userId; + notifier.joinCanvas( + widget.canvasId, + deviceId, + peerDeviceId: widget.peerDeviceId, + ); }); } @@ -94,8 +99,7 @@ class _CanvasPageState extends ConsumerState { child: SafeArea( child: Column( children: [ - if (canvasState.isConnected) - _buildSyncStatusBar(ext, canvasState), + if (canvasState.isConnected) _buildSyncStatusBar(ext, canvasState), Expanded( child: RepaintBoundary( key: _repaintBoundaryKey, @@ -276,8 +280,9 @@ class _CanvasPageState extends ConsumerState { Future _exportPng() async { try { - final boundary = _repaintBoundaryKey.currentContext - ?.findRenderObject() as RenderRepaintBoundary?; + final boundary = + _repaintBoundaryKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; if (boundary == null) return; final image = await boundary.toImage(pixelRatio: 3.0); @@ -291,10 +296,7 @@ class _CanvasPageState extends ConsumerState { await file.writeAsBytes(byteData.buffer.asUint8List()); if (!mounted) return; - await Share.shareXFiles( - [XFile(file.path)], - text: '🎨 闲言协作画布', - ); + await Share.shareXFiles([XFile(file.path)], text: '🎨 闲言协作画布'); Log.i('CanvasPage: exported PNG to ${file.path}'); } catch (e) { Log.e('CanvasPage: export failed: $e'); diff --git a/lib/features/collaboration/canvas/providers/canvas_provider.dart b/lib/features/collaboration/canvas/providers/canvas_provider.dart index f5bd7da8..354b828e 100644 --- a/lib/features/collaboration/canvas/providers/canvas_provider.dart +++ b/lib/features/collaboration/canvas/providers/canvas_provider.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 协作画布Riverpod状态管理 // 创建时间: 2026-05-14 -// 更新时间: 2026-05-14 +// 更新时间: 2026-05-15 // 作用: CanvasState + CanvasNotifier,封装Engine和SyncService -// 上次更新: 初始创建 +// 上次更新: 修复participants回调未绑定、joinCanvas传递peerDeviceId // ============================================================ import 'dart:ui'; @@ -11,6 +11,7 @@ import 'dart:ui'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:xianyan/core/utils/logger.dart'; +import 'package:xianyan/features/file_transfer/providers/shared_signaling_provider.dart'; import 'package:xianyan/features/file_transfer/services/signaling_service.dart'; import '../models/stroke.dart'; import '../services/canvas_engine.dart'; @@ -75,6 +76,7 @@ class CanvasNotifier extends StateNotifier { _syncService.onRemoteStroke = _handleRemoteStroke; _syncService.onRemoteSnapshot = _handleRemoteSnapshot; _syncService.onRemoteCursor = _handleRemoteCursor; + _syncService.onParticipantsChanged = _handleParticipantsChanged; } final SignalingService _signaling; @@ -126,8 +128,8 @@ class CanvasNotifier extends StateNotifier { _engine.clearCanvas(); } - void joinCanvas(String canvasId, String deviceId) { - _syncService.joinCanvas(canvasId, deviceId); + void joinCanvas(String canvasId, String deviceId, {String? peerDeviceId}) { + _syncService.joinCanvas(canvasId, deviceId, peerId: peerDeviceId); state = state.copyWith( canvasId: canvasId, isConnected: true, @@ -173,6 +175,10 @@ class CanvasNotifier extends StateNotifier { state = state.copyWith(remoteCursors: updated); } + void _handleParticipantsChanged(List participants) { + state = state.copyWith(participants: participants); + } + @override void dispose() { _engine.removeListener(_onEngineChanged); @@ -183,6 +189,6 @@ class CanvasNotifier extends StateNotifier { } final canvasProvider = StateNotifierProvider((ref) { - final signaling = SignalingService(); + final signaling = ref.watch(sharedSignalingProvider); return CanvasNotifier(signaling); }); diff --git a/lib/features/collaboration/canvas/services/canvas_sync_service.dart b/lib/features/collaboration/canvas/services/canvas_sync_service.dart index 1e472376..ac1c5371 100644 --- a/lib/features/collaboration/canvas/services/canvas_sync_service.dart +++ b/lib/features/collaboration/canvas/services/canvas_sync_service.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 协作画布实时同步服务 // 创建时间: 2026-05-14 -// 更新时间: 2026-05-14 +// 更新时间: 2026-05-15 // 作用: 通过信令服务实现画布笔画的实时广播和接收 -// 上次更新: 初始创建 +// 上次更新: 修复participants列表为空、to字段缺失 // ============================================================ import 'dart:async'; @@ -21,13 +21,15 @@ typedef ParticipantsCallback = void Function(List participantIds); class CanvasSyncService { CanvasSyncService({required SignalingService signaling}) - : _signaling = signaling; + : _signaling = signaling; final SignalingService _signaling; StreamSubscription? _messageSub; String? _canvasId; String? _deviceId; + String? _peerId; + Set _participants = {}; String? get canvasId => _canvasId; StrokeCallback? onRemoteStroke; @@ -35,17 +37,18 @@ class CanvasSyncService { CursorCallback? onRemoteCursor; ParticipantsCallback? onParticipantsChanged; - void joinCanvas(String canvasId, String deviceId) { + void joinCanvas(String canvasId, String deviceId, {String? peerId}) { _canvasId = canvasId; _deviceId = deviceId; + _peerId = peerId; + _participants = {deviceId}; _messageSub?.cancel(); _messageSub = _signaling.onMessage.listen(_handleSignalingMessage); - _sendCanvasMessage(SignalingMessageType.canvasJoin, { - 'canvasId': canvasId, - }); + _sendCanvasMessage(SignalingMessageType.canvasJoin, {'canvasId': canvasId}); + onParticipantsChanged?.call(_participants.toList()); Log.i('CanvasSync: joined canvas $canvasId'); } @@ -60,6 +63,8 @@ class CanvasSyncService { _messageSub = null; _canvasId = null; _deviceId = null; + _peerId = null; + _participants.clear(); } void broadcastStroke(Stroke stroke) { @@ -99,11 +104,15 @@ class CanvasSyncService { _signaling.sendCustomMessage(msg); } - void _sendCanvasMessage(SignalingMessageType type, Map payload) { + void _sendCanvasMessage( + SignalingMessageType type, + Map payload, + ) { if (_deviceId == null || !_signaling.isConnected) return; final msg = SignalingMessage( type: type, from: _deviceId!, + to: _peerId, payload: payload, ); _signaling.sendCustomMessage(msg); @@ -179,6 +188,8 @@ class CanvasSyncService { if (payload == null) return; final joinedCanvasId = payload['canvasId'] as String? ?? ''; if (joinedCanvasId == _canvasId) { + _participants.add(message.from); + onParticipantsChanged?.call(_participants.toList()); Log.i('CanvasSync: peer ${message.from} joined canvas $_canvasId'); } } @@ -188,6 +199,8 @@ class CanvasSyncService { if (payload == null) return; final leftCanvasId = payload['canvasId'] as String? ?? ''; if (leftCanvasId == _canvasId) { + _participants.remove(message.from); + onParticipantsChanged?.call(_participants.toList()); Log.i('CanvasSync: peer ${message.from} left canvas $_canvasId'); } } diff --git a/lib/features/collaboration/screen_share/providers/screen_share_provider.dart b/lib/features/collaboration/screen_share/providers/screen_share_provider.dart index e35d9f8e..f1aa8513 100644 --- a/lib/features/collaboration/screen_share/providers/screen_share_provider.dart +++ b/lib/features/collaboration/screen_share/providers/screen_share_provider.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 屏幕共享Riverpod状态管理 // 创建时间: 2026-05-14 -// 更新时间: 2026-05-14 +// 更新时间: 2026-05-15 // 作用: ScreenShareState + ScreenShareNotifier,管理共享/观看/热区/操作日志 -// 上次更新: 初始创建 +// 上次更新: v12.2.0 使用sharedSignalingProvider替代独立SignalingService实例 // ============================================================ import 'dart:async'; @@ -12,6 +12,7 @@ import 'dart:ui'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; import 'package:xianyan/core/utils/logger.dart'; +import 'package:xianyan/features/file_transfer/providers/shared_signaling_provider.dart'; import 'package:xianyan/features/file_transfer/services/signaling_service.dart'; import '../models/input_action.dart'; @@ -409,6 +410,6 @@ class ScreenShareNotifier extends StateNotifier { final screenShareProvider = StateNotifierProvider((ref) { - final signaling = SignalingService(); + final signaling = ref.watch(sharedSignalingProvider); return ScreenShareNotifier(signaling); }); diff --git a/lib/features/daily_card/presentation/widgets/card_renderer.dart b/lib/features/daily_card/presentation/widgets/card_renderer.dart index 3226cb23..50c0a189 100644 --- a/lib/features/daily_card/presentation/widgets/card_renderer.dart +++ b/lib/features/daily_card/presentation/widgets/card_renderer.dart @@ -197,7 +197,6 @@ class CardRenderer extends StatelessWidget { return Expanded( child: SingleChildScrollView( child: Column( - crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ if (_displayTitle.isNotEmpty) @@ -219,7 +218,6 @@ class CardRenderer extends StatelessWidget { _displayText, fontSize: 28, fontWeight: FontWeight.w700, - height: 1.5, ), ], ), @@ -475,7 +473,6 @@ class CardRenderer extends StatelessWidget { return Expanded( child: SingleChildScrollView( child: Column( - crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ if (_displayTitle.isNotEmpty) @@ -586,7 +583,6 @@ class CardRenderer extends StatelessWidget { return Expanded( child: SingleChildScrollView( child: Column( - crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ if (_displayTitle.isNotEmpty) @@ -694,7 +690,6 @@ class CardRenderer extends StatelessWidget { child: SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( - crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ if (_displayTitle.isNotEmpty) diff --git a/lib/features/file_transfer/models/transfer_device.dart b/lib/features/file_transfer/models/transfer_device.dart index 3d3bfd2a..74d52dd8 100644 --- a/lib/features/file_transfer/models/transfer_device.dart +++ b/lib/features/file_transfer/models/transfer_device.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 传输设备模型 // 创建时间: 2026-05-09 -// 更新时间: 2026-05-14 +// 更新时间: 2026-05-15 // 作用: 文件传输助手远程设备数据模型 — 设备信息/配对状态/传输偏好 -// 上次更新: v6.7.0 fromSignaling支持web设备类型+displayAlias适配Web浏览器 +// 上次更新: v12.19.0 新增accountAlias字段,displayAlias优先使用账号昵称 // ============================================================ import 'transfer_enums.dart'; @@ -27,6 +27,7 @@ class TransferDevice { this.userId, this.ipCity, this.ipRange, + this.accountAlias, }); final String id; @@ -46,6 +47,7 @@ class TransferDevice { final String? userId; final String? ipCity; final String? ipRange; + final String? accountAlias; String get displayEmoji => deviceType.emoji; String get displayType => deviceType.label; @@ -56,6 +58,7 @@ class TransferDevice { } String get displayAlias { + if (accountAlias != null && accountAlias!.isNotEmpty) return accountAlias!; if (deviceType == DeviceType.web) return alias.isNotEmpty ? alias : 'Web浏览器'; if (alias != '闲言设备' && alias != '未知设备') return alias; @@ -63,9 +66,6 @@ class TransferDevice { if (deviceModel != null && deviceModel!.isNotEmpty) { parts.add(deviceModel!); } - if (ip != null && ip!.isNotEmpty) { - parts.add(ip!); - } if (parts.isNotEmpty) return parts.join(' · '); return alias; } @@ -103,6 +103,7 @@ class TransferDevice { String? userId, String? ipCity, String? ipRange, + String? accountAlias, }) { return TransferDevice( id: id ?? this.id, @@ -122,6 +123,7 @@ class TransferDevice { userId: userId ?? this.userId, ipCity: ipCity ?? this.ipCity, ipRange: ipRange ?? this.ipRange, + accountAlias: accountAlias ?? this.accountAlias, ); } @@ -143,6 +145,7 @@ class TransferDevice { 'userId': userId, 'ipCity': ipCity, 'ipRange': ipRange, + 'accountAlias': accountAlias, }; factory TransferDevice.fromJson(Map json) { @@ -170,6 +173,7 @@ class TransferDevice { userId: json['userId'] as String?, ipCity: json['ipCity'] as String?, ipRange: json['ipRange'] as String?, + accountAlias: json['accountAlias'] as String?, ); } @@ -233,6 +237,11 @@ class TransferDevice { final isWeb = resolvedDeviceType == DeviceType.web || rawDeviceType == 'browser'; + final accountAlias = + data['accountAlias'] as String? ?? + data['nickname'] as String? ?? + data['username'] as String?; + return TransferDevice( id: deviceId, alias: @@ -256,6 +265,7 @@ class TransferDevice { userId: data['userId'] as String?, ipCity: data['ipCity'] as String?, ipRange: data['ipRange'] as String?, + accountAlias: accountAlias, ); } diff --git a/lib/features/file_transfer/presentation/pages/file_transfer_debug_panel.dart b/lib/features/file_transfer/presentation/pages/file_transfer_debug_panel.dart new file mode 100644 index 00000000..a6e38103 --- /dev/null +++ b/lib/features/file_transfer/presentation/pages/file_transfer_debug_panel.dart @@ -0,0 +1,155 @@ +// ============================================================ +// 闲言APP — 文件传输调试面板 Mixin +// 创建时间: 2026-05-15 +// 更新时间: 2026-05-15 +// 作用: 从file_transfer_page.dart拆分 — 调试按钮/面板/模拟方法 +// 上次更新: 初始拆分 — 包含调试面板/模拟接收/模拟发现/模拟WebRTC/打印状态 +// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_spacing.dart'; +import 'package:xianyan/core/theme/app_typography.dart'; +import 'package:xianyan/core/theme/app_radius.dart'; +import 'package:xianyan/features/file_transfer/models/models.dart'; +import 'package:xianyan/features/file_transfer/providers/providers.dart'; + +mixin FileTransferDebugPanel + on ConsumerState { + Widget buildDebugButton(AppThemeExtension ext) { + return GestureDetector( + onTap: () => showDebugPanel(ext), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: CupertinoColors.systemOrange.withValues(alpha: 0.15), + borderRadius: AppRadius.smBorder, + ), + child: Text( + '🐛', + style: TextStyle(fontSize: 16, color: ext.iconSecondary), + ), + ), + ); + } + + void showDebugPanel(AppThemeExtension ext) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + title: Text( + '🐛 调试面板', + style: AppTypography.headline.copyWith(color: ext.textPrimary), + ), + actions: [ + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + simulateIncomingTransfer(); + }, + child: const Text('📥 模拟接收文件'), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + simulateDeviceDiscovery(); + }, + child: const Text('🔍 模拟设备发现'), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + simulateWebRtcConnection(); + }, + child: const Text('🌐 模拟WebRTC连接'), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + printTransferState(); + }, + child: const Text('📊 打印传输状态'), + ), + ], + cancelButton: CupertinoActionSheetAction( + isDefaultAction: true, + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + ), + ); + } + + void simulateIncomingTransfer() { + final task = TransferTask( + id: 'sim-${DateTime.now().millisecondsSinceEpoch}', + sessionId: 'sim-session', + peer: TransferDevice( + id: 'sim-device', + alias: '模拟设备', + deviceType: DeviceType.desktop, + port: 53317, + pairingMethod: PairingMethod.lan, + preferredTransport: TransportType.localsendHttp, + lastSeen: DateTime.now(), + isOnline: true, + isVerified: true, + ), + transport: TransportType.localsendHttp, + direction: TransferDirection.receive, + status: TransferTaskStatus.completed, + fileName: '模拟文件_${DateTime.now().millisecondsSinceEpoch}.png', + fileSize: 1024 * 512, + transferredBytes: 1024 * 512, + speed: 12.5 * 1024 * 1024, + mimeType: 'image/png', + startTime: DateTime.now().subtract(const Duration(seconds: 5)), + endTime: DateTime.now(), + ); + + ref.read(transferProvider.notifier).addSimulatedTask(task); + } + + void simulateDeviceDiscovery() { + final devices = List.generate( + 3, + (i) => TransferDevice( + id: 'sim-device-$i', + alias: '模拟设备 $i', + deviceType: i == 0 + ? DeviceType.mobile + : i == 1 + ? DeviceType.desktop + : DeviceType.web, + ip: '192.168.1.${100 + i}', + port: 53317, + pairingMethod: PairingMethod.lan, + preferredTransport: TransportType.localsendHttp, + lastSeen: DateTime.now(), + isOnline: true, + isVerified: i == 0, + ), + ); + + ref.read(deviceDiscoveryProvider.notifier).addSimulatedDevices(devices); + } + + void simulateWebRtcConnection() { + ref.read(transferProvider.notifier).connectSignaling(); + } + + void printTransferState() { + final state = ref.read(transferProvider); + debugPrint('=== 传输状态 ==='); + debugPrint('活跃任务: ${state.activeCount}'); + debugPrint('发送字节: ${state.totalSentBytes}'); + debugPrint('接收字节: ${state.totalReceivedBytes}'); + debugPrint('消息数: ${state.messages.length}'); + debugPrint('配对设备: ${state.pairedDevices.length}'); + } +} diff --git a/lib/features/file_transfer/presentation/pages/file_transfer_device_actions.dart b/lib/features/file_transfer/presentation/pages/file_transfer_device_actions.dart index d6034e68..a7a16691 100644 --- a/lib/features/file_transfer/presentation/pages/file_transfer_device_actions.dart +++ b/lib/features/file_transfer/presentation/pages/file_transfer_device_actions.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 文件传输设备操作 Mixin // 创建时间: 2026-05-12 -// 更新时间: 2026-05-14 +// 更新时间: 2026-05-15 // 作用: 文件传输助手设备相关操作(上下文菜单/重命名/详情/消息/配对管理/我的设备卡片) -// 上次更新: 修复循环导入 — mixin改为泛型,不再反向导入file_transfer_page.dart +// 上次更新: v12.19.0 本机设备灰色样式+账号昵称显示+移除IP行 // ============================================================ import 'package:flutter/cupertino.dart'; @@ -380,14 +380,116 @@ mixin FileTransferDeviceActions // ============================================================ Widget buildMyDeviceCard(AppThemeExtension ext, TransferDevice device) { - final customAlias = ref - .watch(transferProvider) - .peerStatuses[device.id] - ?.customAlias; + final transferState = ref.watch(transferProvider); + final localFp = transferState.localFingerprint; + final isLocal = + localFp != null && + localFp.isNotEmpty && + device.fingerprint != null && + device.fingerprint == localFp; + + final customAlias = transferState.peerStatuses[device.id]?.customAlias; final displayName = customAlias ?? device.displayAlias; + if (isLocal) { + return _buildLocalDeviceCard(ext, device, displayName); + } + return _buildRemoteDeviceCard(ext, device, displayName); + } + + Widget _buildLocalDeviceCard( + AppThemeExtension ext, + TransferDevice device, + String displayName, + ) { + return Container( + margin: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.xs, + ), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: AppRadius.lgBorder, + border: Border.all(color: ext.overlaySubtle), + ), + child: Column( + children: [ + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: ext.bgCard, + borderRadius: AppRadius.mdBorder, + ), + child: Center( + child: Text( + device.displayEmoji, + style: const TextStyle(fontSize: 22), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + displayName, + style: AppTypography.subhead.copyWith( + color: ext.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: AppSpacing.xs), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: ext.bgCard, + borderRadius: AppRadius.pillBorder, + ), + child: Text( + '📱 本机', + style: AppTypography.caption2.copyWith( + color: ext.textHint, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + if (device.hasIpCity) + Text( + device.displayLocation, + style: AppTypography.caption2.copyWith( + color: ext.textHint, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildRemoteDeviceCard( + AppThemeExtension ext, + TransferDevice device, + String displayName, + ) { return GestureDetector( onLongPress: () => showDeviceContextMenu(device, true), + onTap: () => navigateToDeviceChat(device), child: Container( margin: const EdgeInsets.symmetric( horizontal: AppSpacing.md, @@ -430,11 +532,14 @@ mixin FileTransferDeviceActions children: [ Row( children: [ - Text( - displayName, - style: AppTypography.subhead.copyWith( - color: ext.textPrimary, - fontWeight: FontWeight.w600, + Flexible( + child: Text( + displayName, + style: AppTypography.subhead.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: AppSpacing.xs), @@ -473,20 +578,19 @@ mixin FileTransferDeviceActions ), ), ), - if (device.hasIp) ...[ - Icon( - CupertinoIcons.globe, - size: 12, - color: ext.textHint, - ), - const SizedBox(width: 2), - Text( - device.ip!, - style: AppTypography.caption2.copyWith( - color: ext.textHint, + if (device.accountAlias != null && + device.accountAlias!.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + right: AppSpacing.xs, + ), + child: Text( + '👤 ${device.accountAlias}', + style: AppTypography.caption2.copyWith( + color: ext.textHint, + ), ), ), - ], ], ), ], diff --git a/lib/features/file_transfer/presentation/pages/file_transfer_discovery_tab.dart b/lib/features/file_transfer/presentation/pages/file_transfer_discovery_tab.dart new file mode 100644 index 00000000..aabc17e8 --- /dev/null +++ b/lib/features/file_transfer/presentation/pages/file_transfer_discovery_tab.dart @@ -0,0 +1,514 @@ +// ============================================================ +// 闲言APP — 文件传输发现设备Tab Mixin +// 创建时间: 2026-05-15 +// 更新时间: 2026-05-15 +// 作用: 从file_transfer_page.dart拆分 — 发现设备Tab的UI构建 +// 上次更新: v6.8.0 局域网访问横幅增加二维码弹窗 +// ============================================================ + +import 'package:flutter/cupertino.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:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_spacing.dart'; +import 'package:xianyan/core/theme/app_typography.dart'; +import 'package:xianyan/core/theme/app_radius.dart'; +import 'package:xianyan/features/file_transfer/models/models.dart'; +import 'package:xianyan/features/file_transfer/providers/providers.dart'; +import 'package:xianyan/features/file_transfer/presentation/widgets/device_card.dart'; +import 'package:xianyan/features/file_transfer/presentation/widgets/recent_messages_section.dart'; +import 'package:xianyan/features/file_transfer/presentation/pages/transfer_chat_page.dart'; +import 'package:xianyan/features/file_transfer/presentation/pages/device_pairing_page.dart'; +import 'package:xianyan/shared/widgets/app_toast.dart'; +import 'package:xianyan/features/file_transfer/presentation/pages/file_transfer_device_actions.dart'; + +mixin FileTransferDiscoveryTab + on ConsumerState, FileTransferDeviceActions { + Widget buildDevicesTab(AppThemeExtension ext, TransferState state) { + final discoveryState = ref.watch(deviceDiscoveryProvider); + + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: RecentMessagesSection( + messages: state.messages, + onMessageTap: (msg) => onMessageTap(msg), + ), + ), + if (state.lanAccessUrl != null) + SliverToBoxAdapter( + child: buildLanAccessBanner(ext, state.lanAccessUrl!), + ), + SliverToBoxAdapter(child: buildDiscoveryControls(ext, discoveryState)), + if (discoveryState.discoveredDevices.isEmpty) + SliverFillRemaining(child: buildEmptyDevices(ext, discoveryState)) + else + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final device = discoveryState.discoveredDevices[index]; + final paired = state.isPairedWith(device.id); + final pairing = state.isPairing(device.id); + final customAlias = state.peerStatuses[device.id]?.customAlias; + return DeviceCard( + device: device, + onTap: () => navigateToDeviceChat(device), + onPair: paired ? null : () => pairWithDeviceAction(device), + onLongPress: () => showDeviceContextMenu(device, paired), + isPaired: paired, + isPairing: pairing, + customAlias: customAlias, + ); + }, childCount: discoveryState.discoveredDevices.length), + ), + const SliverPadding(padding: EdgeInsets.only(bottom: 80)), + ], + ); + } + + Widget buildLanAccessBanner(AppThemeExtension ext, String lanUrl) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + child: GestureDetector( + onTap: () => _showLanAccessSheet(ext, lanUrl), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: ext.accent.withValues(alpha: 0.08), + borderRadius: AppRadius.lgBorder, + border: Border.all(color: ext.accent.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + const Text('🌐', style: TextStyle(fontSize: 18)), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '局域网访问', + style: AppTypography.caption1.copyWith( + color: ext.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + lanUrl, + style: AppTypography.footnote.copyWith( + color: ext.accent, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + Icon( + CupertinoIcons.qrcode_viewfinder, + size: 20, + color: ext.accent.withValues(alpha: 0.6), + ), + ], + ), + ), + ), + ); + } + + void _showLanAccessSheet(AppThemeExtension ext, String lanUrl) { + showCupertinoModalPopup( + context: context, + builder: (ctx) { + return Container( + decoration: BoxDecoration( + color: ext.bgPrimary, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.md, + AppSpacing.lg, + AppSpacing.xl, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(bottom: AppSpacing.md), + decoration: BoxDecoration( + color: ext.iconDisabled, + borderRadius: AppRadius.pillBorder, + ), + ), + Text( + '局域网访问', + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.lg), + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: ext.bgCard, + borderRadius: AppRadius.lgBorder, + border: Border.all(color: ext.bgElevated), + ), + child: QrImageView( + data: lanUrl, + size: 200, + eyeStyle: QrEyeStyle( + eyeShape: QrEyeShape.square, + color: ext.textPrimary, + ), + dataModuleStyle: QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: ext.textPrimary, + ), + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + lanUrl, + style: AppTypography.footnote.copyWith( + color: ext.accent, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.sm), + Text( + '📱 其他设备扫描此二维码即可在浏览器中访问文件传输页面', + style: AppTypography.caption1.copyWith(color: ext.textHint), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + SizedBox( + width: double.infinity, + child: CupertinoButton( + color: ext.accent, + borderRadius: AppRadius.lgBorder, + onPressed: () { + Clipboard.setData(ClipboardData(text: lanUrl)); + Navigator.pop(ctx); + AppToast.showSuccess('已复制局域网地址'); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + CupertinoIcons.doc_on_clipboard, + size: 16, + color: CupertinoColors.white, + ), + const SizedBox(width: AppSpacing.xs), + Text( + '复制链接', + style: AppTypography.subhead.copyWith( + color: CupertinoColors.white, + ), + ), + ], + ), + ), + ), + ], + ), + ); + }, + ); + } + + Widget buildDiscoveryControls( + AppThemeExtension ext, + DeviceDiscoveryState discoveryState, + ) { + return Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + discoveryState.isScanning ? '正在扫描附近设备...' : '附近设备', + style: AppTypography.subhead.copyWith( + color: ext.textSecondary, + ), + ), + ), + if (discoveryState.isScanning) + SizedBox( + width: 16, + height: 16, + child: CupertinoActivityIndicator(color: ext.accent), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + buildMethodChips(ext, discoveryState), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: CupertinoButton( + color: discoveryState.isScanning + ? ext.bgSecondary + : ext.accent, + borderRadius: AppRadius.lgBorder, + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + onPressed: () { + if (discoveryState.isScanning) { + ref.read(deviceDiscoveryProvider.notifier).stopScan(); + } else { + startFullScan(); + } + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!discoveryState.isScanning) ...[ + const Icon( + CupertinoIcons.search, + size: 16, + color: CupertinoColors.white, + ), + const SizedBox(width: AppSpacing.xs), + ], + Text( + discoveryState.isScanning ? '停止扫描' : '开始扫描', + style: AppTypography.subhead.copyWith( + color: discoveryState.isScanning + ? ext.textSecondary + : CupertinoColors.white, + ), + ), + ], + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + CupertinoButton( + color: ext.bgSecondary, + borderRadius: AppRadius.lgBorder, + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.sm, + horizontal: AppSpacing.md, + ), + onPressed: () => navigateToPairing(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(CupertinoIcons.plus, size: 16, color: ext.accent), + const SizedBox(width: AppSpacing.xs), + Text( + '手动', + style: AppTypography.subhead.copyWith(color: ext.accent), + ), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget buildMethodChips(AppThemeExtension ext, DeviceDiscoveryState state) { + final methods = PairingMethod.values.where( + (m) => state.isMethodAvailable(m), + ); + + return Wrap( + spacing: AppSpacing.xs, + runSpacing: AppSpacing.xs, + children: methods.map((method) { + final isActive = state.isMethodActive(method); + return GestureDetector( + onTap: () { + if (state.isScanning) { + AppToast.showWarning('请先停止当前扫描,再切换配对方式'); + return; + } + startSingleMethodScan(method); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: isActive ? ext.accent.withValues(alpha: 0.15) : ext.bgCard, + borderRadius: AppRadius.pillBorder, + border: Border.all(color: isActive ? ext.accent : ext.bgElevated), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(method.emoji, style: const TextStyle(fontSize: 12)), + const SizedBox(width: 2), + Text( + method.label, + style: AppTypography.caption2.copyWith( + color: isActive ? ext.accent : ext.textSecondary, + ), + ), + ], + ), + ), + ); + }).toList(), + ); + } + + Widget buildEmptyDevices(AppThemeExtension ext, DeviceDiscoveryState state) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + state.isScanning + ? CupertinoIcons.antenna_radiowaves_left_right + : CupertinoIcons.device_phone_portrait, + size: 64, + color: ext.iconDisabled, + ) + .animate(onPlay: (c) => c.repeat()) + .shimmer( + duration: 2.seconds, + color: ext.accent.withValues(alpha: 0.3), + ), + const SizedBox(height: AppSpacing.md), + Text( + state.isScanning ? '正在搜索附近设备...' : '暂无发现设备', + style: AppTypography.body.copyWith(color: ext.textSecondary), + ), + const SizedBox(height: AppSpacing.sm), + Text( + state.isScanning ? '请确保对方设备已开启传输服务' : '点击"开始扫描"搜索附近设备', + style: AppTypography.footnote.copyWith(color: ext.textHint), + ), + if (!state.isScanning) ...[ + const SizedBox(height: AppSpacing.lg), + CupertinoButton( + color: ext.accent, + borderRadius: AppRadius.lgBorder, + onPressed: () => navigateToPairing(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + CupertinoIcons.plus, + size: 16, + color: CupertinoColors.white, + ), + const SizedBox(width: AppSpacing.xs), + Text( + '手动配对', + style: AppTypography.subhead.copyWith( + color: CupertinoColors.white, + ), + ), + ], + ), + ), + ], + ], + ), + ); + } + + void onMessageTap(TransferMessage msg) { + TransferDevice? device; + if (msg.peerDeviceId != null) { + final paired = ref.read(transferProvider).pairedDevices; + final match = paired.where((p) => p.deviceId == msg.peerDeviceId); + if (match.isNotEmpty) { + final info = match.first; + device = TransferDevice( + id: info.deviceId, + alias: info.alias, + deviceType: DeviceType.mobile, + ip: info.ip, + port: info.port, + pairingMethod: info.method, + preferredTransport: TransportType.localsendHttp, + lastSeen: DateTime.now(), + isOnline: true, + isVerified: true, + ); + } + } + + if (device == null) { + String? resolvedIp; + final state = ref.read(transferProvider); + for (final d in state.myDevices) { + if (d.id == msg.peerDeviceId && d.ip != null) { + resolvedIp = d.ip; + break; + } + } + if (resolvedIp == null) { + for (final d in state.discoveredDevices) { + if (d.id == msg.peerDeviceId && d.ip != null) { + resolvedIp = d.ip; + break; + } + } + } + device = TransferDevice( + id: msg.peerDeviceId ?? msg.id, + alias: msg.deviceAlias ?? '未知设备', + deviceType: DeviceType.mobile, + ip: resolvedIp, + port: 53317, + pairingMethod: PairingMethod.lan, + preferredTransport: TransportType.localsendHttp, + lastSeen: DateTime.now(), + isOnline: true, + isVerified: false, + ); + } + Navigator.of(context).push( + CupertinoPageRoute( + builder: (_) => TransferChatPage(peerDevice: device!), + ), + ); + } + + void startFullScan() { + final discoveryState = ref.read(deviceDiscoveryProvider); + ref.read(transferProvider.notifier).connectSignaling(); + ref + .read(deviceDiscoveryProvider.notifier) + .startScan(discoveryState.availableMethods); + } + + void startSingleMethodScan(PairingMethod method) { + ref.read(deviceDiscoveryProvider.notifier).startScan({method}); + } + + void navigateToPairing() { + Navigator.of( + context, + ).push(CupertinoPageRoute(builder: (_) => const DevicePairingPage())); + } + + void showDeviceContextMenu(TransferDevice device, bool paired); +} diff --git a/lib/features/file_transfer/presentation/pages/file_transfer_my_devices_tab.dart b/lib/features/file_transfer/presentation/pages/file_transfer_my_devices_tab.dart new file mode 100644 index 00000000..384c0190 --- /dev/null +++ b/lib/features/file_transfer/presentation/pages/file_transfer_my_devices_tab.dart @@ -0,0 +1,196 @@ +// ============================================================ +// 闲言APP — 文件传输我的设备Tab Mixin +// 创建时间: 2026-05-15 +// 更新时间: 2026-05-15 +// 作用: 从file_transfer_page.dart拆分 — 我的设备Tab的UI构建 +// 上次更新: 初始拆分 — 包含我的设备列表/空状态/刷新 +// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_spacing.dart'; +import 'package:xianyan/core/theme/app_typography.dart'; +import 'package:xianyan/core/theme/app_radius.dart'; +import 'package:xianyan/features/file_transfer/providers/providers.dart'; +import 'package:xianyan/features/file_transfer/presentation/pages/file_transfer_device_actions.dart'; + +mixin FileTransferMyDevicesTab + on ConsumerState, FileTransferDeviceActions { + Widget buildMyDevicesTab(AppThemeExtension ext, TransferState state) { + final myDevices = state.myDevices; + final isSearching = state.isDiscoveringMyDevices; + + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.device_phone_portrait, + size: 20, + color: ext.textPrimary, + ), + const SizedBox(width: AppSpacing.xs), + Text( + '我的设备', + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + ), + ), + ], + ), + const Spacer(), + if (isSearching) + const Padding( + padding: EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + ), + child: CupertinoActivityIndicator(), + ) + else + CupertinoButton( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + color: ext.accent.withValues(alpha: 0.1), + borderRadius: AppRadius.pillBorder, + minimumSize: Size.zero, + onPressed: () { + ref + .read(transferProvider.notifier) + .discoverMyDevices(); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.arrow_clockwise, + size: 14, + color: ext.accent, + ), + const SizedBox(width: 4), + Text( + '刷新', + style: AppTypography.caption1.copyWith( + color: ext.accent, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + Row( + children: [ + if (isSearching) ...[ + Icon(CupertinoIcons.search, size: 14, color: ext.accent), + const SizedBox(width: 4), + ], + Flexible( + child: Text( + isSearching ? '正在搜索同账号在线设备...' : '同一账号下的在线设备,可跨网络互传文件', + style: AppTypography.footnote.copyWith( + color: isSearching ? ext.accent : ext.textHint, + ), + ), + ), + ], + ), + ], + ), + ), + ), + if (isSearching && myDevices.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CupertinoActivityIndicator(radius: 16), + const SizedBox(height: AppSpacing.md), + Text( + '正在搜索你的其他设备...', + style: AppTypography.body.copyWith( + color: ext.textSecondary, + ), + ), + ], + ), + ), + ) + else if (myDevices.isEmpty) + SliverFillRemaining(child: buildEmptyMyDevices(ext)) + else + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final device = myDevices[index]; + return buildMyDeviceCard(ext, device); + }, childCount: myDevices.length), + ), + const SliverPadding(padding: EdgeInsets.only(bottom: 80)), + ], + ); + } + + Widget buildEmptyMyDevices(AppThemeExtension ext) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.device_phone_portrait, + size: 64, + color: ext.iconDisabled, + ), + const SizedBox(height: AppSpacing.md), + Text( + '暂无其他在线设备', + style: AppTypography.body.copyWith(color: ext.textSecondary), + ), + const SizedBox(height: AppSpacing.sm), + Text( + '登录同一账号的其他设备将自动显示在此', + style: AppTypography.footnote.copyWith(color: ext.textHint), + ), + const SizedBox(height: AppSpacing.lg), + CupertinoButton( + color: ext.accent, + borderRadius: AppRadius.lgBorder, + onPressed: () { + ref.read(transferProvider.notifier).discoverMyDevices(); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + CupertinoIcons.search, + size: 16, + color: CupertinoColors.white, + ), + const SizedBox(width: AppSpacing.xs), + Text( + '搜索我的设备', + style: AppTypography.subhead.copyWith( + color: CupertinoColors.white, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/file_transfer/presentation/pages/file_transfer_page.dart b/lib/features/file_transfer/presentation/pages/file_transfer_page.dart index 843bc17a..3f51fb24 100644 --- a/lib/features/file_transfer/presentation/pages/file_transfer_page.dart +++ b/lib/features/file_transfer/presentation/pages/file_transfer_page.dart @@ -1,15 +1,13 @@ // ============================================================ // 闲言APP — 文件传输助手主页面 // 创建时间: 2026-05-09 -// 更新时间: 2026-05-14 +// 更新时间: 2026-05-15 // 作用: 文件传输助手入口 — 设备发现/我的设备/配对/传输管理/聊天入口 -// 上次更新: 修复循环导入 — file_transfer_device_actions不再反向导入本文件 +// 上次更新: 拆分为多个Mixin文件 — discovery/my_devices/records/debug_panel // ============================================================ import 'package:flutter/cupertino.dart'; 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:xianyan/core/theme/app_theme.dart'; @@ -19,13 +17,12 @@ import 'package:xianyan/core/theme/app_radius.dart'; import 'package:xianyan/features/file_transfer/models/models.dart'; import 'package:xianyan/features/file_transfer/providers/providers.dart'; import 'package:xianyan/features/file_transfer/presentation/pages/transfer_chat_page.dart'; -import 'package:xianyan/features/file_transfer/presentation/pages/device_pairing_page.dart'; -import 'package:xianyan/features/file_transfer/presentation/widgets/transfer_task_card.dart'; -import 'package:xianyan/features/file_transfer/presentation/widgets/device_card.dart'; -import 'package:xianyan/features/file_transfer/presentation/widgets/recent_messages_section.dart'; import 'package:xianyan/features/file_transfer/presentation/pages/transfer_settings_page.dart'; -import 'package:xianyan/shared/widgets/app_toast.dart'; import 'package:xianyan/features/file_transfer/presentation/pages/file_transfer_device_actions.dart'; +import 'package:xianyan/features/file_transfer/presentation/pages/file_transfer_discovery_tab.dart'; +import 'package:xianyan/features/file_transfer/presentation/pages/file_transfer_my_devices_tab.dart'; +import 'package:xianyan/features/file_transfer/presentation/pages/file_transfer_records_tab.dart'; +import 'package:xianyan/features/file_transfer/presentation/pages/file_transfer_debug_panel.dart'; class FileTransferPage extends ConsumerStatefulWidget { const FileTransferPage({super.key}); @@ -37,7 +34,11 @@ class FileTransferPage extends ConsumerStatefulWidget { class _FileTransferPageState extends ConsumerState with SingleTickerProviderStateMixin, - FileTransferDeviceActions { + FileTransferDeviceActions, + FileTransferDiscoveryTab, + FileTransferMyDevicesTab, + FileTransferRecordsTab, + FileTransferDebugPanel { late final TabController _tabController; @override @@ -82,7 +83,7 @@ class _FileTransferPageState extends ConsumerState trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - _buildDebugButton(ext), + buildDebugButton(ext), const SizedBox(width: AppSpacing.sm), GestureDetector( onTap: () => _navigateToSettings(), @@ -105,9 +106,9 @@ class _FileTransferPageState extends ConsumerState child: TabBarView( controller: _tabController, children: [ - _buildDevicesTab(ext, transferState), - _buildMyDevicesTab(ext, transferState), - _buildTransferRecordsTab(ext, transferState), + buildDevicesTab(ext, transferState), + buildMyDevicesTab(ext, transferState), + buildTransferRecordsTab(ext, transferState), ], ), ), @@ -152,748 +153,6 @@ class _FileTransferPageState extends ConsumerState ); } - // ============================================================ - // 发现设备Tab - // ============================================================ - - Widget _buildDevicesTab(AppThemeExtension ext, TransferState state) { - final discoveryState = ref.watch(deviceDiscoveryProvider); - - return CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: RecentMessagesSection( - messages: state.messages, - onMessageTap: (msg) => _onMessageTap(msg), - ), - ), - if (state.lanAccessUrl != null) - SliverToBoxAdapter( - child: _buildLanAccessBanner(ext, state.lanAccessUrl!), - ), - SliverToBoxAdapter(child: _buildDiscoveryControls(ext, discoveryState)), - if (discoveryState.discoveredDevices.isEmpty) - SliverFillRemaining(child: _buildEmptyDevices(ext, discoveryState)) - else - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final device = discoveryState.discoveredDevices[index]; - final paired = state.isPairedWith(device.id); - final pairing = state.isPairing(device.id); - final customAlias = state.peerStatuses[device.id]?.customAlias; - return DeviceCard( - device: device, - onTap: () => _onDeviceTap(device), - onPair: paired ? null : () => _onPairDevice(device), - onLongPress: () => showDeviceContextMenu(device, paired), - isPaired: paired, - isPairing: pairing, - customAlias: customAlias, - ); - }, childCount: discoveryState.discoveredDevices.length), - ), - const SliverPadding(padding: EdgeInsets.only(bottom: 80)), - ], - ); - } - - Widget _buildLanAccessBanner(AppThemeExtension ext, String lanUrl) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - child: GestureDetector( - onTap: () { - Clipboard.setData(ClipboardData(text: lanUrl)); - AppToast.showSuccess('已复制局域网地址'); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - decoration: BoxDecoration( - color: ext.accent.withValues(alpha: 0.08), - borderRadius: AppRadius.lgBorder, - border: Border.all(color: ext.accent.withValues(alpha: 0.2)), - ), - child: Row( - children: [ - const Text('🌐', style: TextStyle(fontSize: 18)), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '局域网访问', - style: AppTypography.caption1.copyWith( - color: ext.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - Text( - lanUrl, - style: AppTypography.footnote.copyWith( - color: ext.accent, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - Icon( - CupertinoIcons.doc_on_clipboard, - size: 16, - color: ext.accent.withValues(alpha: 0.6), - ), - ], - ), - ), - ), - ); - } - - Widget _buildDiscoveryControls( - AppThemeExtension ext, - DeviceDiscoveryState discoveryState, - ) { - return Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - discoveryState.isScanning ? '正在扫描附近设备...' : '附近设备', - style: AppTypography.subhead.copyWith( - color: ext.textSecondary, - ), - ), - ), - if (discoveryState.isScanning) - SizedBox( - width: 16, - height: 16, - child: CupertinoActivityIndicator(color: ext.accent), - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - _buildMethodChips(ext, discoveryState), - const SizedBox(height: AppSpacing.sm), - Row( - children: [ - Expanded( - child: CupertinoButton( - color: discoveryState.isScanning - ? ext.bgSecondary - : ext.accent, - borderRadius: AppRadius.lgBorder, - padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), - onPressed: () { - if (discoveryState.isScanning) { - ref.read(deviceDiscoveryProvider.notifier).stopScan(); - } else { - _startFullScan(); - } - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (!discoveryState.isScanning) ...[ - const Icon( - CupertinoIcons.search, - size: 16, - color: CupertinoColors.white, - ), - const SizedBox(width: AppSpacing.xs), - ], - Text( - discoveryState.isScanning ? '停止扫描' : '开始扫描', - style: AppTypography.subhead.copyWith( - color: discoveryState.isScanning - ? ext.textSecondary - : CupertinoColors.white, - ), - ), - ], - ), - ), - ), - const SizedBox(width: AppSpacing.sm), - CupertinoButton( - color: ext.bgSecondary, - borderRadius: AppRadius.lgBorder, - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.sm, - horizontal: AppSpacing.md, - ), - onPressed: () => _navigateToPairing(), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(CupertinoIcons.plus, size: 16, color: ext.accent), - const SizedBox(width: AppSpacing.xs), - Text( - '手动', - style: AppTypography.subhead.copyWith(color: ext.accent), - ), - ], - ), - ), - ], - ), - ], - ), - ); - } - - Widget _buildMethodChips(AppThemeExtension ext, DeviceDiscoveryState state) { - final methods = PairingMethod.values.where( - (m) => state.isMethodAvailable(m), - ); - - return Wrap( - spacing: AppSpacing.xs, - runSpacing: AppSpacing.xs, - children: methods.map((method) { - final isActive = state.isMethodActive(method); - return GestureDetector( - onTap: () { - if (state.isScanning) { - AppToast.showWarning('请先停止当前扫描,再切换配对方式'); - return; - } - _startSingleMethodScan(method); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xs, - ), - decoration: BoxDecoration( - color: isActive ? ext.accent.withValues(alpha: 0.15) : ext.bgCard, - borderRadius: AppRadius.pillBorder, - border: Border.all(color: isActive ? ext.accent : ext.bgElevated), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(method.emoji, style: const TextStyle(fontSize: 12)), - const SizedBox(width: 2), - Text( - method.label, - style: AppTypography.caption2.copyWith( - color: isActive ? ext.accent : ext.textSecondary, - ), - ), - ], - ), - ), - ); - }).toList(), - ); - } - - Widget _buildEmptyDevices(AppThemeExtension ext, DeviceDiscoveryState state) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - state.isScanning - ? CupertinoIcons.antenna_radiowaves_left_right - : CupertinoIcons.device_phone_portrait, - size: 64, - color: ext.iconDisabled, - ) - .animate(onPlay: (c) => c.repeat()) - .shimmer( - duration: 2.seconds, - color: ext.accent.withValues(alpha: 0.3), - ), - const SizedBox(height: AppSpacing.md), - Text( - state.isScanning ? '正在搜索附近设备...' : '暂无发现设备', - style: AppTypography.body.copyWith(color: ext.textSecondary), - ), - const SizedBox(height: AppSpacing.sm), - Text( - state.isScanning ? '请确保对方设备已开启传输服务' : '点击"开始扫描"搜索附近设备', - style: AppTypography.footnote.copyWith(color: ext.textHint), - ), - if (!state.isScanning) ...[ - const SizedBox(height: AppSpacing.lg), - CupertinoButton( - color: ext.accent, - borderRadius: AppRadius.lgBorder, - onPressed: () => _navigateToPairing(), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - CupertinoIcons.plus, - size: 16, - color: CupertinoColors.white, - ), - const SizedBox(width: AppSpacing.xs), - Text( - '手动配对', - style: AppTypography.subhead.copyWith( - color: CupertinoColors.white, - ), - ), - ], - ), - ), - ], - ], - ), - ); - } - - // ============================================================ - // 我的设备Tab - // ============================================================ - - Widget _buildMyDevicesTab(AppThemeExtension ext, TransferState state) { - final myDevices = state.myDevices; - final isSearching = state.isDiscoveringMyDevices; - - return CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - CupertinoIcons.device_phone_portrait, - size: 20, - color: ext.textPrimary, - ), - const SizedBox(width: AppSpacing.xs), - Text( - '我的设备', - style: AppTypography.headline.copyWith( - color: ext.textPrimary, - ), - ), - ], - ), - const Spacer(), - if (isSearching) - const Padding( - padding: EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - ), - child: CupertinoActivityIndicator(), - ) - else - CupertinoButton( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xs, - ), - color: ext.accent.withValues(alpha: 0.1), - borderRadius: AppRadius.pillBorder, - minimumSize: Size.zero, - onPressed: () { - ref - .read(transferProvider.notifier) - .discoverMyDevices(); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - CupertinoIcons.arrow_clockwise, - size: 14, - color: ext.accent, - ), - const SizedBox(width: 4), - Text( - '刷新', - style: AppTypography.caption1.copyWith( - color: ext.accent, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: AppSpacing.xs), - Row( - children: [ - if (isSearching) ...[ - Icon(CupertinoIcons.search, size: 14, color: ext.accent), - const SizedBox(width: 4), - ], - Flexible( - child: Text( - isSearching ? '正在搜索同账号在线设备...' : '同一账号下的在线设备,可跨网络互传文件', - style: AppTypography.footnote.copyWith( - color: isSearching ? ext.accent : ext.textHint, - ), - ), - ), - ], - ), - ], - ), - ), - ), - if (isSearching && myDevices.isEmpty) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CupertinoActivityIndicator(radius: 16), - const SizedBox(height: AppSpacing.md), - Text( - '正在搜索你的其他设备...', - style: AppTypography.body.copyWith( - color: ext.textSecondary, - ), - ), - ], - ), - ), - ) - else if (myDevices.isEmpty) - SliverFillRemaining(child: _buildEmptyMyDevices(ext)) - else - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final device = myDevices[index]; - return buildMyDeviceCard(ext, device); - }, childCount: myDevices.length), - ), - const SliverPadding(padding: EdgeInsets.only(bottom: 80)), - ], - ); - } - - Widget _buildEmptyMyDevices(AppThemeExtension ext) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - CupertinoIcons.device_phone_portrait, - size: 64, - color: ext.iconDisabled, - ), - const SizedBox(height: AppSpacing.md), - Text( - '暂无其他在线设备', - style: AppTypography.body.copyWith(color: ext.textSecondary), - ), - const SizedBox(height: AppSpacing.sm), - Text( - '登录同一账号的其他设备将自动显示在此', - style: AppTypography.footnote.copyWith(color: ext.textHint), - ), - const SizedBox(height: AppSpacing.lg), - CupertinoButton( - color: ext.accent, - borderRadius: AppRadius.lgBorder, - onPressed: () { - ref.read(transferProvider.notifier).discoverMyDevices(); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - CupertinoIcons.search, - size: 16, - color: CupertinoColors.white, - ), - const SizedBox(width: AppSpacing.xs), - Text( - '搜索我的设备', - style: AppTypography.subhead.copyWith( - color: CupertinoColors.white, - ), - ), - ], - ), - ), - ], - ), - ); - } - - // ============================================================ - // 传输记录Tab(合并原传输+记录) - // ============================================================ - - Widget _buildTransferRecordsTab(AppThemeExtension ext, TransferState state) { - final activeTasks = state.activeTasks; - final completed = state.completedTasks; - final failed = state.failedTasks; - final hasAny = - activeTasks.isNotEmpty || completed.isNotEmpty || failed.isNotEmpty; - - if (!hasAny) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(CupertinoIcons.doc_on_doc, size: 64, color: ext.iconDisabled), - const SizedBox(height: AppSpacing.md), - Text( - '暂无传输记录', - style: AppTypography.body.copyWith(color: ext.textSecondary), - ), - const SizedBox(height: AppSpacing.sm), - Text( - '配对设备后即可开始传输文件', - style: AppTypography.footnote.copyWith(color: ext.textHint), - ), - ], - ), - ); - } - - return CustomScrollView( - slivers: [ - if (state.isTransferring) - SliverToBoxAdapter(child: _buildOverallProgress(ext, state)), - - if (activeTasks.isNotEmpty) ...[ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.md, - AppSpacing.md, - AppSpacing.sm, - ), - child: Row( - children: [ - Icon(CupertinoIcons.bolt, size: 16, color: ext.accent), - const SizedBox(width: AppSpacing.xs), - Text( - '传输中 (${activeTasks.length})', - style: AppTypography.subhead.copyWith( - color: ext.accent, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final task = activeTasks[index]; - return TransferTaskCard( - task: task, - onCancel: () => - ref.read(transferProvider.notifier).cancelTask(task.id), - onPause: () => - ref.read(transferProvider.notifier).pauseTask(task.id), - onResume: () => - ref.read(transferProvider.notifier).resumeTask(task.id), - onRetry: () => - ref.read(transferProvider.notifier).retryTask(task.id), - ); - }, childCount: activeTasks.length), - ), - ], - - if (completed.isNotEmpty) ...[ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.lg, - AppSpacing.md, - AppSpacing.sm, - ), - child: Row( - children: [ - Icon( - CupertinoIcons.checkmark_circle, - size: 16, - color: ext.successColor, - ), - const SizedBox(width: AppSpacing.xs), - Text( - '已完成 (${completed.length})', - style: AppTypography.subhead.copyWith( - color: ext.textSecondary, - ), - ), - ], - ), - ), - ), - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => TransferTaskCard(task: completed[index]), - childCount: completed.length, - ), - ), - ], - - if (failed.isNotEmpty) ...[ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.md, - AppSpacing.lg, - AppSpacing.md, - AppSpacing.sm, - ), - child: Row( - children: [ - Icon( - CupertinoIcons.xmark_circle, - size: 16, - color: ext.errorColor, - ), - const SizedBox(width: AppSpacing.xs), - Text( - '失败 (${failed.length})', - style: AppTypography.subhead.copyWith( - color: ext.textSecondary, - ), - ), - ], - ), - ), - ), - SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => TransferTaskCard(task: failed[index]), - childCount: failed.length, - ), - ), - ], - - const SliverPadding(padding: EdgeInsets.only(bottom: 80)), - ], - ); - } - - Widget _buildOverallProgress(AppThemeExtension ext, TransferState state) { - return Container( - margin: const EdgeInsets.all(AppSpacing.md), - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: ext.bgCard, - borderRadius: AppRadius.lgBorder, - boxShadow: [ - BoxShadow( - color: ext.accent.withValues(alpha: 0.1), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(CupertinoIcons.bolt, size: 16, color: ext.accent), - const SizedBox(width: AppSpacing.xs), - Text( - '传输中', - style: AppTypography.subhead.copyWith( - color: ext.accent, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - const Spacer(), - Text( - state.overallSpeedText, - style: AppTypography.caption1.copyWith( - color: ext.textSecondary, - ), - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - ClipRRect( - borderRadius: AppRadius.smBorder, - child: LinearProgressIndicator( - value: state.overallProgress, - backgroundColor: ext.bgSecondary, - valueColor: AlwaysStoppedAnimation(ext.accent), - minHeight: 6, - ), - ), - const SizedBox(height: AppSpacing.xs), - Text( - '${state.activeCount} 个任务 · ${(state.overallProgress * 100).toStringAsFixed(0)}%', - style: AppTypography.caption2.copyWith(color: ext.textHint), - ), - ], - ), - ); - } - - // ============================================================ - // 调试按钮 - // ============================================================ - - Widget _buildDebugButton(AppThemeExtension ext) { - return GestureDetector( - onTap: () => _showDebugPanel(ext), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xs, - ), - decoration: BoxDecoration( - color: CupertinoColors.systemOrange.withValues(alpha: 0.15), - borderRadius: AppRadius.smBorder, - ), - child: Text( - '🐛', - style: TextStyle(fontSize: 16, color: ext.iconSecondary), - ), - ), - ); - } - - // ============================================================ - // 交互方法 - // ============================================================ - - void _startFullScan() { - final discoveryState = ref.read(deviceDiscoveryProvider); - ref - .read(deviceDiscoveryProvider.notifier) - .startScan(discoveryState.availableMethods); - } - - void _startSingleMethodScan(PairingMethod method) { - ref.read(deviceDiscoveryProvider.notifier).startScan({method}); - } - void _onDeviceTap(TransferDevice device) { Navigator.of(context).push( CupertinoPageRoute( @@ -906,193 +165,9 @@ class _FileTransferPageState extends ConsumerState ref.read(transferProvider.notifier).pairWithDevice(device); } - void _navigateToPairing() { - Navigator.of( - context, - ).push(CupertinoPageRoute(builder: (_) => const DevicePairingPage())); - } - - void _onMessageTap(TransferMessage msg) { - TransferDevice? device; - if (msg.peerDeviceId != null) { - final paired = ref.read(transferProvider).pairedDevices; - final match = paired.where((p) => p.deviceId == msg.peerDeviceId); - if (match.isNotEmpty) { - final info = match.first; - device = TransferDevice( - id: info.deviceId, - alias: info.alias, - deviceType: DeviceType.mobile, - ip: info.ip, - port: info.port, - pairingMethod: info.method, - preferredTransport: TransportType.localsendHttp, - lastSeen: DateTime.now(), - isOnline: true, - isVerified: true, - ); - } - } - - if (device == null) { - String? resolvedIp; - final state = ref.read(transferProvider); - for (final d in state.myDevices) { - if (d.id == msg.peerDeviceId && d.ip != null) { - resolvedIp = d.ip; - break; - } - } - if (resolvedIp == null) { - for (final d in state.discoveredDevices) { - if (d.id == msg.peerDeviceId && d.ip != null) { - resolvedIp = d.ip; - break; - } - } - } - device = TransferDevice( - id: msg.peerDeviceId ?? msg.id, - alias: msg.deviceAlias ?? '未知设备', - deviceType: DeviceType.mobile, - ip: resolvedIp, - port: 53317, - pairingMethod: PairingMethod.lan, - preferredTransport: TransportType.localsendHttp, - lastSeen: DateTime.now(), - isOnline: true, - isVerified: false, - ); - } - Navigator.of(context).push( - CupertinoPageRoute( - builder: (_) => TransferChatPage(peerDevice: device!), - ), - ); - } - void _navigateToSettings() { Navigator.of(context).push( CupertinoPageRoute(builder: (_) => const TransferSettingsPage()), ); } - - // ============================================================ - // 调试面板 - // ============================================================ - - void _showDebugPanel(AppThemeExtension ext) { - showCupertinoModalPopup( - context: context, - builder: (ctx) => CupertinoActionSheet( - title: Text( - '🐛 调试面板', - style: AppTypography.headline.copyWith(color: ext.textPrimary), - ), - actions: [ - CupertinoActionSheetAction( - onPressed: () { - Navigator.pop(ctx); - _simulateIncomingTransfer(); - }, - child: const Text('📥 模拟接收文件'), - ), - CupertinoActionSheetAction( - onPressed: () { - Navigator.pop(ctx); - _simulateDeviceDiscovery(); - }, - child: const Text('🔍 模拟设备发现'), - ), - CupertinoActionSheetAction( - onPressed: () { - Navigator.pop(ctx); - _simulateWebRtcConnection(); - }, - child: const Text('🌐 模拟WebRTC连接'), - ), - CupertinoActionSheetAction( - onPressed: () { - Navigator.pop(ctx); - _printTransferState(); - }, - child: const Text('📊 打印传输状态'), - ), - ], - cancelButton: CupertinoActionSheetAction( - isDefaultAction: true, - onPressed: () => Navigator.pop(ctx), - child: const Text('取消'), - ), - ), - ); - } - - void _simulateIncomingTransfer() { - final task = TransferTask( - id: 'sim-${DateTime.now().millisecondsSinceEpoch}', - sessionId: 'sim-session', - peer: TransferDevice( - id: 'sim-device', - alias: '模拟设备', - deviceType: DeviceType.desktop, - port: 53317, - pairingMethod: PairingMethod.lan, - preferredTransport: TransportType.localsendHttp, - lastSeen: DateTime.now(), - isOnline: true, - isVerified: true, - ), - transport: TransportType.localsendHttp, - direction: TransferDirection.receive, - status: TransferTaskStatus.completed, - fileName: '模拟文件_${DateTime.now().millisecondsSinceEpoch}.png', - fileSize: 1024 * 512, - transferredBytes: 1024 * 512, - speed: 12.5 * 1024 * 1024, - mimeType: 'image/png', - startTime: DateTime.now().subtract(const Duration(seconds: 5)), - endTime: DateTime.now(), - ); - - ref.read(transferProvider.notifier).addSimulatedTask(task); - } - - void _simulateDeviceDiscovery() { - final devices = List.generate( - 3, - (i) => TransferDevice( - id: 'sim-device-$i', - alias: '模拟设备 $i', - deviceType: i == 0 - ? DeviceType.mobile - : i == 1 - ? DeviceType.desktop - : DeviceType.web, - ip: '192.168.1.${100 + i}', - port: 53317, - pairingMethod: PairingMethod.lan, - preferredTransport: TransportType.localsendHttp, - lastSeen: DateTime.now(), - isOnline: true, - isVerified: i == 0, - ), - ); - - ref.read(deviceDiscoveryProvider.notifier).addSimulatedDevices(devices); - } - - void _simulateWebRtcConnection() { - ref.read(transferProvider.notifier).connectSignaling(); - } - - void _printTransferState() { - final state = ref.read(transferProvider); - debugPrint('=== 传输状态 ==='); - debugPrint('活跃任务: ${state.activeCount}'); - debugPrint('发送字节: ${state.totalSentBytes}'); - debugPrint('接收字节: ${state.totalReceivedBytes}'); - debugPrint('消息数: ${state.messages.length}'); - debugPrint('配对设备: ${state.pairedDevices.length}'); - } } diff --git a/lib/features/file_transfer/presentation/pages/file_transfer_records_tab.dart b/lib/features/file_transfer/presentation/pages/file_transfer_records_tab.dart new file mode 100644 index 00000000..2f14fdd6 --- /dev/null +++ b/lib/features/file_transfer/presentation/pages/file_transfer_records_tab.dart @@ -0,0 +1,234 @@ +// ============================================================ +// 闲言APP — 文件传输记录Tab Mixin +// 创建时间: 2026-05-15 +// 更新时间: 2026-05-15 +// 作用: 从file_transfer_page.dart拆分 — 传输记录Tab的UI构建 +// 上次更新: 初始拆分 — 包含传输记录列表/整体进度/空状态 +// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_spacing.dart'; +import 'package:xianyan/core/theme/app_typography.dart'; +import 'package:xianyan/core/theme/app_radius.dart'; +import 'package:xianyan/features/file_transfer/providers/providers.dart'; +import 'package:xianyan/features/file_transfer/presentation/widgets/transfer_task_card.dart'; + +mixin FileTransferRecordsTab + on ConsumerState { + Widget buildTransferRecordsTab(AppThemeExtension ext, TransferState state) { + final activeTasks = state.activeTasks; + final completed = state.completedTasks; + final failed = state.failedTasks; + final hasAny = + activeTasks.isNotEmpty || completed.isNotEmpty || failed.isNotEmpty; + + if (!hasAny) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.doc_on_doc, size: 64, color: ext.iconDisabled), + const SizedBox(height: AppSpacing.md), + Text( + '暂无传输记录', + style: AppTypography.body.copyWith(color: ext.textSecondary), + ), + const SizedBox(height: AppSpacing.sm), + Text( + '配对设备后即可开始传输文件', + style: AppTypography.footnote.copyWith(color: ext.textHint), + ), + ], + ), + ); + } + + return CustomScrollView( + slivers: [ + if (state.isTransferring) + SliverToBoxAdapter(child: buildOverallProgress(ext, state)), + + if (activeTasks.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.md, + AppSpacing.md, + AppSpacing.sm, + ), + child: Row( + children: [ + Icon(CupertinoIcons.bolt, size: 16, color: ext.accent), + const SizedBox(width: AppSpacing.xs), + Text( + '传输中 (${activeTasks.length})', + style: AppTypography.subhead.copyWith( + color: ext.accent, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final task = activeTasks[index]; + return TransferTaskCard( + task: task, + onCancel: () => + ref.read(transferProvider.notifier).cancelTask(task.id), + onPause: () => + ref.read(transferProvider.notifier).pauseTask(task.id), + onResume: () => + ref.read(transferProvider.notifier).resumeTask(task.id), + onRetry: () => + ref.read(transferProvider.notifier).retryTask(task.id), + ); + }, childCount: activeTasks.length), + ), + ], + + if (completed.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.lg, + AppSpacing.md, + AppSpacing.sm, + ), + child: Row( + children: [ + Icon( + CupertinoIcons.checkmark_circle, + size: 16, + color: ext.successColor, + ), + const SizedBox(width: AppSpacing.xs), + Text( + '已完成 (${completed.length})', + style: AppTypography.subhead.copyWith( + color: ext.textSecondary, + ), + ), + ], + ), + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => TransferTaskCard(task: completed[index]), + childCount: completed.length, + ), + ), + ], + + if (failed.isNotEmpty) ...[ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.lg, + AppSpacing.md, + AppSpacing.sm, + ), + child: Row( + children: [ + Icon( + CupertinoIcons.xmark_circle, + size: 16, + color: ext.errorColor, + ), + const SizedBox(width: AppSpacing.xs), + Text( + '失败 (${failed.length})', + style: AppTypography.subhead.copyWith( + color: ext.textSecondary, + ), + ), + ], + ), + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => TransferTaskCard(task: failed[index]), + childCount: failed.length, + ), + ), + ], + + const SliverPadding(padding: EdgeInsets.only(bottom: 80)), + ], + ); + } + + Widget buildOverallProgress(AppThemeExtension ext, TransferState state) { + return Container( + margin: const EdgeInsets.all(AppSpacing.md), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: ext.bgCard, + borderRadius: AppRadius.lgBorder, + boxShadow: [ + BoxShadow( + color: ext.accent.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(CupertinoIcons.bolt, size: 16, color: ext.accent), + const SizedBox(width: AppSpacing.xs), + Text( + '传输中', + style: AppTypography.subhead.copyWith( + color: ext.accent, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const Spacer(), + Text( + state.overallSpeedText, + style: AppTypography.caption1.copyWith( + color: ext.textSecondary, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + ClipRRect( + borderRadius: AppRadius.smBorder, + child: LinearProgressIndicator( + value: state.overallProgress, + backgroundColor: ext.bgSecondary, + valueColor: AlwaysStoppedAnimation(ext.accent), + minHeight: 6, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text( + '${state.activeCount} 个任务 · ${(state.overallProgress * 100).toStringAsFixed(0)}%', + style: AppTypography.caption2.copyWith(color: ext.textHint), + ), + ], + ), + ); + } +} diff --git a/lib/features/file_transfer/presentation/pages/transfer_chat_page.dart b/lib/features/file_transfer/presentation/pages/transfer_chat_page.dart index a1f79de0..ad0707b1 100644 --- a/lib/features/file_transfer/presentation/pages/transfer_chat_page.dart +++ b/lib/features/file_transfer/presentation/pages/transfer_chat_page.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 传输聊天页面 // 创建时间: 2026-05-09 -// 更新时间: 2026-05-14 +// 更新时间: 2026-05-15 // 作用: 与对端设备的聊天界面 — 消息列表/输入发送/文件传输/网络状态/离线队列/屏幕共享 -// 上次更新: v6.4.0 新增📺共享按钮+发起屏幕共享 +// 上次更新: v13.1.0 监听pendingScreenShareOffer弹出确认对话框+屏幕共享请求卡片 // ============================================================ import 'dart:async'; @@ -28,8 +28,11 @@ import 'package:xianyan/features/file_transfer/services/voice_message_service.da import 'package:xianyan/features/file_transfer/presentation/pages/transfer_chat_bubbles.dart'; import 'package:xianyan/features/file_transfer/presentation/pages/transfer_chat_input.dart'; import 'package:xianyan/features/file_transfer/presentation/widgets/voice_recording_overlay.dart'; -import 'package:xianyan/core/router/app_router.dart'; -import 'package:go_router/go_router.dart'; +import 'package:xianyan/features/auth/providers/auth_provider.dart'; +import 'package:xianyan/features/collaboration/canvas/pages/canvas_page.dart'; +import 'package:xianyan/features/collaboration/screen_share/providers/screen_share_provider.dart'; +import 'package:xianyan/features/collaboration/screen_share/models/input_action.dart'; +import 'package:xianyan/features/collaboration/screen_share/pages/screen_share_page.dart'; class TransferChatPage extends ConsumerStatefulWidget { const TransferChatPage({super.key, required this.peerDevice}); @@ -52,6 +55,7 @@ class _TransferChatPageState extends ConsumerState { String _connectionType = 'unknown'; bool _isRecording = false; bool _isRecordingStarting = false; + bool _isShowingScreenShareDialog = false; String _peerStatusText = ''; final _voiceService = VoiceMessageService.instance; @@ -224,21 +228,72 @@ class _TransferChatPageState extends ConsumerState { return ext.iconDisabled; } + Set _collectPeerIds(TransferState state) { + final ids = {widget.peerDevice.id}; + final fp = widget.peerDevice.fingerprint; + if (fp != null && fp.isNotEmpty) ids.add(fp); + for (final p in state.pairedDevices) { + if (p.deviceId == widget.peerDevice.id || + (fp != null && fp.isNotEmpty && p.fingerprint == fp)) { + ids.add(p.deviceId); + if (p.fingerprint != null && p.fingerprint!.isNotEmpty) { + ids.add(p.fingerprint!); + } + } + } + for (final d in state.discoveredDevices) { + if (d.id == widget.peerDevice.id || + (fp != null && fp.isNotEmpty && d.fingerprint == fp)) { + ids.add(d.id); + if (d.fingerprint != null && d.fingerprint!.isNotEmpty) { + ids.add(d.fingerprint!); + } + } + } + for (final d in state.myDevices) { + if (d.id == widget.peerDevice.id || + (fp != null && fp.isNotEmpty && d.fingerprint == fp)) { + ids.add(d.id); + if (d.fingerprint != null && d.fingerprint!.isNotEmpty) { + ids.add(d.fingerprint!); + } + } + } + return ids; + } + @override Widget build(BuildContext context) { final ext = AppTheme.ext(context); final state = ref.watch(transferProvider); + final peerIds = _collectPeerIds(state); + final sessionIds = peerIds.map(sessionKeyForPeer).toSet(); + if (state.currentSessionId != null) { + sessionIds.add(state.currentSessionId!); + } final messages = state.messages .where( (m) => - m.sessionId == state.currentSessionId || - m.peerDeviceId == widget.peerDevice.id || + sessionIds.contains(m.sessionId) || + (m.peerDeviceId != null && + peerIds.contains(m.peerDeviceId)) || m.type == TransferMessageType.system, ) .toList() ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + final pendingOffer = state.pendingScreenShareOffer; + if (pendingOffer != null && + !_isShowingScreenShareDialog && + peerIds.contains(pendingOffer.fromDeviceId)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !_isShowingScreenShareDialog) { + _showScreenShareOfferDialog(pendingOffer); + } + }); + } + return CupertinoPageScaffold( backgroundColor: ext.bgPrimary, navigationBar: CupertinoNavigationBar( @@ -520,7 +575,14 @@ class _TransferChatPageState extends ConsumerState { } Widget _buildMessageList(List messages) { - if (messages.isEmpty) { + final pendingOffer = ref.watch(transferProvider).pendingScreenShareOffer; + final showOfferCard = + pendingOffer != null && + _collectPeerIds( + ref.read(transferProvider), + ).contains(pendingOffer.fromDeviceId); + + if (messages.isEmpty && !showOfferCard) { return ChatEmptyState( deviceEmoji: widget.peerDevice.displayEmoji, deviceAlias: widget.peerDevice.alias, @@ -529,6 +591,9 @@ class _TransferChatPageState extends ConsumerState { ); } + final ext = AppTheme.ext(context); + final extraCount = showOfferCard ? 1 : 0; + return ListView.builder( controller: _scrollController, reverse: true, @@ -536,9 +601,13 @@ class _TransferChatPageState extends ConsumerState { horizontal: AppSpacing.md, vertical: AppSpacing.sm, ), - itemCount: messages.length, + itemCount: messages.length + extraCount, itemBuilder: (context, index) { - final msg = messages[index]; + if (index == 0 && showOfferCard) { + return _buildScreenShareOfferCard(ext, pendingOffer); + } + final msgIndex = showOfferCard ? index - 1 : index; + final msg = messages[msgIndex]; final taskId = msg.transferTaskId; return ChatMessageBubble( msg: msg, @@ -562,6 +631,108 @@ class _TransferChatPageState extends ConsumerState { ); } + Widget _buildScreenShareOfferCard( + AppThemeExtension ext, + ScreenShareOffer offer, + ) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: ext.infoColor.withValues(alpha: 0.08), + borderRadius: AppRadius.lgBorder, + border: Border.all(color: ext.infoColor.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('📺', style: TextStyle(fontSize: 24)), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '屏幕共享请求', + style: AppTypography.footnote.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + '${widget.peerDevice.alias} 请求共享屏幕给你观看', + style: AppTypography.caption1.copyWith( + color: ext.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + onTap: () { + ref.read(transferProvider.notifier).rejectScreenShareOffer(); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: ext.errorColor.withValues(alpha: 0.1), + borderRadius: AppRadius.pillBorder, + border: Border.all( + color: ext.errorColor.withValues(alpha: 0.3), + ), + ), + child: Text( + '拒绝', + style: AppTypography.caption1.copyWith( + color: ext.errorColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + GestureDetector( + onTap: () => _acceptScreenShareOffer(offer), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: ext.successColor.withValues(alpha: 0.1), + borderRadius: AppRadius.pillBorder, + border: Border.all( + color: ext.successColor.withValues(alpha: 0.3), + ), + ), + child: Text( + '接受', + style: AppTypography.caption1.copyWith( + color: ext.successColor, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } + void _sendMessage() { final text = _inputController.text.trim(); if (text.isEmpty) return; @@ -773,8 +944,15 @@ class _TransferChatPageState extends ConsumerState { void _openCanvas() { final canvasId = 'canvas-${widget.peerDevice.id}-${DateTime.now().millisecondsSinceEpoch}'; - context.go( - '${AppRoutes.canvas}/$canvasId?peerDeviceId=${widget.peerDevice.id}', + final userId = ref.read(authProvider).user?.id.toString() ?? ''; + Navigator.of(context).push( + CupertinoPageRoute( + builder: (_) => CanvasPage( + canvasId: canvasId, + peerDeviceId: widget.peerDevice.id, + userId: userId, + ), + ), ); } @@ -796,6 +974,70 @@ class _TransferChatPageState extends ConsumerState { AppToast.showInfo('📺 已发送屏幕共享请求,等待对方确认...'); } + void _showScreenShareOfferDialog(ScreenShareOffer offer) { + final ext = AppTheme.ext(context); + _isShowingScreenShareDialog = true; + + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: Text('📺 屏幕共享请求', style: TextStyle(color: ext.textPrimary)), + content: Text( + '${widget.peerDevice.alias} 请求共享屏幕给你观看\n\n' + '你可以对预设热区进行受限操作\n' + '随时可以结束观看', + style: TextStyle(color: ext.textSecondary), + ), + actions: [ + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () { + Navigator.pop(ctx); + _isShowingScreenShareDialog = false; + ref.read(transferProvider.notifier).rejectScreenShareOffer(); + }, + child: const Text('拒绝'), + ), + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () { + Navigator.pop(ctx); + _isShowingScreenShareDialog = false; + _acceptScreenShareOffer(offer); + }, + child: const Text('允许'), + ), + ], + ), + ); + } + + void _acceptScreenShareOffer(ScreenShareOffer offer) { + ref.read(transferProvider.notifier).acceptScreenShareOffer(); + + final hotZones = offer.hotZonesData + .whereType>() + .map(HotZone.fromJson) + .toList(); + + ref + .read(screenShareProvider.notifier) + .startViewing( + sessionId: offer.sessionId, + peerId: offer.fromDeviceId, + hotZones: hotZones, + ); + + Navigator.of(context).push( + CupertinoPageRoute( + builder: (_) => ScreenSharePage( + sessionId: offer.sessionId, + peerDeviceId: offer.fromDeviceId, + ), + ), + ); + } + void _showPeerInfo(AppThemeExtension ext) { showCupertinoModalPopup( context: context, diff --git a/lib/features/file_transfer/providers/device_discovery_provider.dart b/lib/features/file_transfer/providers/device_discovery_provider.dart index 2851f94e..e7c30775 100644 --- a/lib/features/file_transfer/providers/device_discovery_provider.dart +++ b/lib/features/file_transfer/providers/device_discovery_provider.dart @@ -170,29 +170,39 @@ class DeviceDiscoveryNotifier extends StateNotifier { state = state.copyWith(isScanning: true, activeMethods: methods); _allDevices.clear(); + final futures = >[]; + for (final method in methods) { if (_isDisposed) return; switch (method) { case PairingMethod.lan: - await _lanSub?.cancel(); - _lanSub = _lanService.onDevicesChanged.listen(_mergeDevices); - await _lanService.startScan(); + futures.add(() async { + await _lanSub?.cancel(); + _lanSub = _lanService.onDevicesChanged.listen(_mergeDevices); + await _lanService.startScan(); + }()); case PairingMethod.ble: - await _bleSub?.cancel(); - _bleSub = _bleService.onDevicesChanged.listen(_mergeDevices); - await _bleService.startScan(); + futures.add(() async { + await _bleSub?.cancel(); + _bleSub = _bleService.onDevicesChanged.listen(_mergeDevices); + await _bleService.startScan(); + }()); case PairingMethod.nfc: - await _nfcSub?.cancel(); - _nfcSub = _nfcService.onDeviceFound.listen((device) { - if (device != null) _addDevice(device); - }); - await _nfcService.startScan(); + futures.add(() async { + await _nfcSub?.cancel(); + _nfcSub = _nfcService.onDeviceFound.listen((device) { + if (device != null) _addDevice(device); + }); + await _nfcService.startScan(); + }()); case PairingMethod.qrCode: break; case PairingMethod.usb: - await _usbSub?.cancel(); - _usbSub = _usbService.onDevicesChanged.listen(_mergeDevices); - await _usbService.startMonitoring(); + futures.add(() async { + await _usbSub?.cancel(); + _usbSub = _usbService.onDevicesChanged.listen(_mergeDevices); + await _usbService.startMonitoring(); + }()); case PairingMethod.hotspot: break; case PairingMethod.account: @@ -204,6 +214,8 @@ class DeviceDiscoveryNotifier extends StateNotifier { } } + await Future.wait(futures); + Log.i( 'Discovery: Scan started for ${methods.map((m) => m.label).join(', ')}', ); diff --git a/lib/features/file_transfer/providers/providers.dart b/lib/features/file_transfer/providers/providers.dart index 91ce28e5..112b16d6 100644 --- a/lib/features/file_transfer/providers/providers.dart +++ b/lib/features/file_transfer/providers/providers.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 文件传输助手Provider统一导出 // 创建时间: 2026-05-09 -// 更新时间: 2026-05-14 +// 更新时间: 2026-05-15 // 作用: 统一导出所有文件传输相关Provider+State+Handler -// 上次更新: v6.5.0 新增file_handler+offline_handler导出 +// 上次更新: v12.2.0 新增shared_signaling_provider导出 // ============================================================ export 'transfer_provider.dart'; @@ -18,3 +18,4 @@ export 'transfer_offline_handler.dart'; export 'device_discovery_provider.dart'; export 'transfer_settings_provider.dart'; export 'cloud_cache_provider.dart'; +export 'shared_signaling_provider.dart'; diff --git a/lib/features/file_transfer/providers/shared_signaling_provider.dart b/lib/features/file_transfer/providers/shared_signaling_provider.dart new file mode 100644 index 00000000..f3ca320b --- /dev/null +++ b/lib/features/file_transfer/providers/shared_signaling_provider.dart @@ -0,0 +1,17 @@ +// ============================================================ +// 闲言APP — 共享信令服务Provider +// 创建时间: 2026-05-15 +// 更新时间: 2026-05-15 +// 作用: 从TransferNotifier获取已连接的SignalingService实例,供协作画布/屏幕共享等模块复用 +// 上次更新: 初始创建,解决canvas/screen_share各自创建未连接SignalingService实例的问题 +// ============================================================ + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:xianyan/features/file_transfer/services/signaling_service.dart'; +import 'package:xianyan/features/file_transfer/providers/transfer_provider.dart'; + +final sharedSignalingProvider = Provider((ref) { + final notifier = ref.watch(transferProvider.notifier); + return notifier.pairingService.signalingService; +}); diff --git a/lib/features/file_transfer/providers/transfer_message_handler.dart b/lib/features/file_transfer/providers/transfer_message_handler.dart index ab024944..e6bf9794 100644 --- a/lib/features/file_transfer/providers/transfer_message_handler.dart +++ b/lib/features/file_transfer/providers/transfer_message_handler.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 传输消息处理器 // 创建时间: 2026-05-12 -// 更新时间: 2026-05-13 +// 更新时间: 2026-05-15 // 作用: 消息发送/送达回执/系统消息/文件消息 -// 上次更新: v6.1.0 消息信封封装+可选AES-256-CBC加密 +// 上次更新: v12.19.0 sendTextMessage优先使用设备preferredTransport通道 // ============================================================ import 'dart:io'; @@ -200,11 +200,34 @@ class TransferMessageHandler { final peerIp = _resolvePeerIp(peer); Log.i( - 'Transfer: sendTextMessage to peer=${peer.id}, alias=${peer.alias}, resolvedIp=$peerIp', + 'Transfer: sendTextMessage to peer=${peer.id}, alias=${peer.alias}, ' + 'resolvedIp=$peerIp, preferredTransport=${peer.preferredTransport.id}', ); bool sent = false; + // ── 根据设备preferredTransport优先选择通道 ── + if (peer.preferredTransport == TransportType.wsRelay && + signalingService.isConnected) { + addSystemMessage('📡 通过WsRelay中转发送...'); + try { + final wsResult = await wsRelayService.sendTextMessage( + peer.id, + wireText, + ); + if (wsResult) { + sent = true; + addSystemMessage('✅ WsRelay中转发送成功'); + Log.i('Transfer: Text sent via WsRelay to ${peer.id}'); + } else { + addSystemMessage('⚠️ WsRelay发送返回失败,尝试其他通道...'); + } + } catch (e) { + Log.w('Transfer: WsRelay send failed: $e'); + addSystemMessage('⚠️ WsRelay发送失败,尝试其他通道...'); + } + } + // ── 通道1: LocalSend 局域网直连(最可靠,不依赖服务器)── if (!sent && peerIp != null && peerIp.isNotEmpty) { addSystemMessage('📡 通过局域网直连发送到 $peerIp:${peer.port}...'); @@ -282,8 +305,10 @@ class TransferMessageHandler { } } - // ── 通道5: WsRelay中转 ── - if (!sent && signalingService.isConnected) { + // ── 通道5: WsRelay中转(非wsRelay偏好设备兜底)── + if (!sent && + peer.preferredTransport != TransportType.wsRelay && + signalingService.isConnected) { addSystemMessage('📡 尝试WsRelay中转发送...'); try { final wsResult = await wsRelayService.sendTextMessage( diff --git a/lib/features/file_transfer/providers/transfer_notifier.dart b/lib/features/file_transfer/providers/transfer_notifier.dart index 05e7aa84..f9f02a21 100644 --- a/lib/features/file_transfer/providers/transfer_notifier.dart +++ b/lib/features/file_transfer/providers/transfer_notifier.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 文件传输助手状态管理 // 创建时间: 2026-05-09 -// 更新时间: 2026-05-14 -// 作用: 传输任务状态+消息+进度+Drift持久化+送达回执+handler委托+离线队列+USB发现 -// 上次更新: v12.1.0 集成UsbDiscoveryService,USB设备自动发现+设备列表管理 +// 更新时间: 2026-05-15 +// 作用: 传输任务状态+消息+进度+Drift持久化+送达回执+handler委托+离线队列+USB发现+屏幕共享 +// 上次更新: v13.1.0 传入onScreenShareOffer/Answer/Stop/RemoteInput回调 // ============================================================ import 'dart:async'; @@ -26,6 +26,7 @@ import 'package:xianyan/features/file_transfer/providers/transfer_cloud_handler. import 'package:xianyan/features/file_transfer/providers/transfer_file_handler.dart'; import 'package:xianyan/features/file_transfer/providers/transfer_offline_handler.dart'; import 'package:xianyan/features/file_transfer/services/pairing_service.dart'; +import 'package:xianyan/features/file_transfer/services/signaling_service.dart'; import 'package:xianyan/features/file_transfer/services/cache_manager_service.dart'; import 'package:xianyan/features/file_transfer/services/delivery_receipt_service.dart'; import 'package:xianyan/features/file_transfer/services/cloud_cache_service.dart'; @@ -92,6 +93,13 @@ class TransferNotifier extends StateNotifier { _transportRouter = TransportRouter(wsRelayService: _wsRelayService); + // 设置本机设备指纹标识 + if (mounted) { + state = state.copyWith( + localFingerprint: _transportRouter.localSendService.fingerprint, + ); + } + _deliveryReceiptService = DeliveryReceiptService( _pairingService.signalingService, ); @@ -198,6 +206,18 @@ class TransferNotifier extends StateNotifier { markAsDelivered: (peerDeviceId, messageId) { _deliveryReceiptService.markAsDelivered(peerDeviceId, messageId); }, + onScreenShareOffer: (from, sessionId, hotZonesData) { + _handleScreenShareOffer(from, sessionId, hotZonesData); + }, + onScreenShareAnswer: (from, sessionId) { + _handleScreenShareAnswer(from, sessionId); + }, + onScreenShareStop: (from, sessionId) { + _handleScreenShareStop(from, sessionId); + }, + onRemoteInput: (from, zoneId, actionType, semanticAction) { + _handleRemoteInput(from, zoneId, actionType, semanticAction); + }, ); await _loadPairedDevices(); @@ -440,8 +460,7 @@ class TransferNotifier extends StateNotifier { Future sendFilesUsb({ required UsbDevice usbDevice, required List filePaths, - }) => - _fileHandler.sendFilesUsb(usbDevice: usbDevice, filePaths: filePaths); + }) => _fileHandler.sendFilesUsb(usbDevice: usbDevice, filePaths: filePaths); Future pairWithDevice(TransferDevice device) => _pairingHandler.pairWithDevice(device); @@ -570,6 +589,9 @@ class TransferNotifier extends StateNotifier { Future discoverMyDevices() => _signalingHandler.discoverMyDevices( signaling: _pairingService.signalingService, authState: _ref.read(authProvider), + transportRouter: _transportRouter, + deviceId: _deviceId, + settingsState: _ref.read(transferSettingsProvider), ); Future sendFileToMyDevice({ @@ -835,6 +857,80 @@ class TransferNotifier extends StateNotifier { } } + // ============================================================ + // 屏幕共享信令回调 + // ============================================================ + + void _handleScreenShareOffer( + String from, + String sessionId, + List hotZonesData, + ) { + if (!mounted) return; + state = state.copyWith( + pendingScreenShareOffer: ScreenShareOffer( + fromDeviceId: from, + sessionId: sessionId, + hotZonesData: hotZonesData, + ), + ); + Log.i( + 'Transfer: ScreenShareOffer received from=$from sessionId=$sessionId', + ); + } + + void _handleScreenShareAnswer(String from, String sessionId) { + if (!mounted) return; + _messageHandler.addSystemMessage('📺 对方已接受屏幕共享'); + Log.i('Transfer: ScreenShareAnswer from=$from sessionId=$sessionId'); + } + + void _handleScreenShareStop(String from, String sessionId) { + if (!mounted) return; + _messageHandler.addSystemMessage('📺 对方已停止屏幕共享'); + Log.i('Transfer: ScreenShareStop from=$from sessionId=$sessionId'); + } + + void _handleRemoteInput( + String from, + String zoneId, + String actionType, + String semanticAction, + ) { + Log.i( + 'Transfer: RemoteInput from=$from zone=$zoneId action=$actionType semantic=$semanticAction', + ); + } + + void clearPendingScreenShareOffer() { + if (!mounted) return; + state = state.copyWith(pendingScreenShareOffer: null); + } + + void acceptScreenShareOffer() { + final offer = state.pendingScreenShareOffer; + if (offer == null) return; + _pairingService.signalingService.sendCustomMessage( + SignalingMessage( + type: SignalingMessageType.screenShareAnswer, + from: _pairingService.signalingService.deviceId ?? '', + to: offer.fromDeviceId, + payload: {'sessionId': offer.sessionId}, + ), + ); + _messageHandler.addSystemMessage('📺 已接受屏幕共享请求'); + state = state.copyWith(pendingScreenShareOffer: null); + Log.i('Transfer: ScreenShareOffer accepted, sessionId=${offer.sessionId}'); + } + + void rejectScreenShareOffer() { + final offer = state.pendingScreenShareOffer; + if (offer == null) return; + _messageHandler.addSystemMessage('📺 已拒绝屏幕共享请求'); + state = state.copyWith(pendingScreenShareOffer: null); + Log.i('Transfer: ScreenShareOffer rejected, sessionId=${offer.sessionId}'); + } + @override void dispose() { _localSendMessageSub?.cancel(); diff --git a/lib/features/file_transfer/providers/transfer_signaling_handler.dart b/lib/features/file_transfer/providers/transfer_signaling_handler.dart index 9f51c6bd..c0993414 100644 --- a/lib/features/file_transfer/providers/transfer_signaling_handler.dart +++ b/lib/features/file_transfer/providers/transfer_signaling_handler.dart @@ -381,13 +381,38 @@ class TransferSignalingHandler { Future discoverMyDevices({ required SignalingService signaling, required AuthState authState, + TransportRouter? transportRouter, + String? deviceId, + TransferSettings? settingsState, }) async { if (!signaling.isConnected) { - Log.w('Transfer: discoverMyDevices aborted - signaling not connected'); - addSystemMessage('⚠️ 信令服务未连接,无法发现我的设备'); - AppToast.showWarning('信令服务未连接,请稍后重试'); - return; + Log.w('Transfer: discoverMyDevices - signaling not connected, attempting auto-connect'); + if (transportRouter == null || deviceId == null) { + addSystemMessage('⚠️ 信令服务未连接,无法发现我的设备'); + AppToast.showWarning('信令服务未连接,请稍后重试'); + return; + } + try { + final userId = authState.isLoggedIn && authState.user != null + ? authState.user!.id.toString() + : null; + final fingerprint = transportRouter.localSendService.fingerprint; + await connectSignaling( + signaling: signaling, + transportRouter: transportRouter, + deviceId: deviceId, + userId: userId, + fingerprint: fingerprint, + serverUrl: settingsState?.signalingServerUrl, + ).timeout(const Duration(seconds: 8)); + } catch (e) { + Log.e('Transfer: discoverMyDevices auto-connect failed: $e'); + addSystemMessage('⚠️ 信令服务器连接失败,无法发现跨网设备'); + AppToast.showWarning('信令服务器连接失败,请检查网络'); + return; + } } + var userId = signaling.userId; Log.i('Transfer: discoverMyDevices - signaling.userId=$userId'); if (userId == null || userId.isEmpty) { diff --git a/lib/features/file_transfer/providers/transfer_state.dart b/lib/features/file_transfer/providers/transfer_state.dart index 9c876a7b..8e60f74d 100644 --- a/lib/features/file_transfer/providers/transfer_state.dart +++ b/lib/features/file_transfer/providers/transfer_state.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 文件传输助手状态模型 // 创建时间: 2026-05-12 -// 更新时间: 2026-05-14 -// 作用: 传输任务状态数据模型 — 任务/消息/设备/进度/统计/离线队列 -// 上次更新: v11.9.0 新增pendingOfflineItems/totalPendingCount字段 +// 更新时间: 2026-05-15 +// 作用: 传输任务状态数据模型 — 任务/消息/设备/进度/统计/离线队列/屏幕共享请求 +// 上次更新: v13.1.0 新增ScreenShareOffer类和pendingScreenShareOffer字段 // ============================================================ import '../models/models.dart'; @@ -79,6 +79,18 @@ class PeerStatus { } } +class ScreenShareOffer { + const ScreenShareOffer({ + required this.fromDeviceId, + required this.sessionId, + required this.hotZonesData, + }); + + final String fromDeviceId; + final String sessionId; + final List hotZonesData; +} + const _unset = Object(); class TransferState { @@ -105,6 +117,8 @@ class TransferState { this.lanAccessUrl, this.pendingOfflineItems = const [], this.totalPendingCount = 0, + this.localFingerprint, + this.pendingScreenShareOffer, }); final List activeTasks; @@ -129,6 +143,8 @@ class TransferState { final String? lanAccessUrl; final List pendingOfflineItems; final int totalPendingCount; + final String? localFingerprint; + final ScreenShareOffer? pendingScreenShareOffer; bool isPairedWith(String deviceId) => pairedDevices.any((p) => p.deviceId == deviceId); @@ -180,6 +196,8 @@ class TransferState { Object? lanAccessUrl = _unset, List? pendingOfflineItems, int? totalPendingCount, + Object? localFingerprint = _unset, + Object? pendingScreenShareOffer = _unset, }) { return TransferState( activeTasks: activeTasks ?? this.activeTasks, @@ -216,6 +234,12 @@ class TransferState { : lanAccessUrl as String?, pendingOfflineItems: pendingOfflineItems ?? this.pendingOfflineItems, totalPendingCount: totalPendingCount ?? this.totalPendingCount, + localFingerprint: identical(localFingerprint, _unset) + ? this.localFingerprint + : localFingerprint as String?, + pendingScreenShareOffer: identical(pendingScreenShareOffer, _unset) + ? this.pendingScreenShareOffer + : pendingScreenShareOffer as ScreenShareOffer?, ); } } diff --git a/lib/features/file_transfer/services/discovery/usb_discovery_service.dart b/lib/features/file_transfer/services/discovery/usb_discovery_service.dart index 56329d42..6d0a1b51 100644 --- a/lib/features/file_transfer/services/discovery/usb_discovery_service.dart +++ b/lib/features/file_transfer/services/discovery/usb_discovery_service.dart @@ -92,11 +92,21 @@ class UsbDiscoveryService { } }, onError: (Object error) { + if (error is MissingPluginException) { + Log.w('USB Discovery: Native event channel not implemented, skipping'); + _usbEventSub?.cancel(); + _usbEventSub = null; + return; + } Log.w('USB Discovery: Native event error: $error'); }, ); + } on MissingPluginException { + Log.w('USB Discovery: Native event channel not available, skipping'); + _usbEventSub = null; } on PlatformException catch (e) { Log.w('USB Discovery: Failed to listen native events: ${e.message}'); + _usbEventSub = null; } } @@ -127,23 +137,29 @@ class UsbDiscoveryService { return; } - final supported = await _usbService.isUsbHostSupported(); - if (!supported) { - Log.w('USB Discovery: USB Host mode not supported'); - return; - } + try { + final supported = await _usbService.isUsbHostSupported(); + if (!supported) { + Log.w('USB Discovery: USB Host mode not supported'); + return; + } - final devices = await _usbService.detectAllUsbDevices(); - for (final device in devices) { - if (!_connectedDevices.any((d) => d.deviceId == device.deviceId)) { - _connectedDevices.add(device); - if (!_deviceFoundController.isClosed) { - _deviceFoundController.add(device); + final devices = await _usbService.detectAllUsbDevices(); + for (final device in devices) { + if (!_connectedDevices.any((d) => d.deviceId == device.deviceId)) { + _connectedDevices.add(device); + if (!_deviceFoundController.isClosed) { + _deviceFoundController.add(device); + } } } - } - _emitTransferDevices(); + _emitTransferDevices(); + } on MissingPluginException { + Log.w('USB Discovery: Native USB plugin not available, skipping scan'); + } catch (e) { + Log.w('USB Discovery: Error scanning existing devices: $e'); + } } void _handleDeviceAttached(UsbDevice device) { diff --git a/lib/features/file_transfer/services/signaling_service.dart b/lib/features/file_transfer/services/signaling_service.dart index 436c14e3..a481ede8 100644 --- a/lib/features/file_transfer/services/signaling_service.dart +++ b/lib/features/file_transfer/services/signaling_service.dart @@ -1,10 +1,10 @@ // ============================================================ // 闲言APP — WebSocket信令+消息中转服务 // 创建时间: 2026-05-09 -// 更新时间: 2026-05-13 +// 更新时间: 2026-05-15 // 作用: WebRTC信令中转+文本消息互发+设备发现+协议协商+WebSocket中转 // 参考 SnapDrop WebSocket 中转 + LocalSend Signaling 协议 -// 上次更新: v11.7.3 新增keyExchange信令类型+sendKeyExchange方法 +// 上次更新: v12.21.0 添加heartbeatAck/discoverResponse枚举+修复信令处理 // ============================================================ import 'dart:async'; @@ -22,6 +22,7 @@ import '../models/transfer_device.dart'; enum SignalingMessageType { register('register'), discover('discover'), + discoverResponse('discover_response'), offer('offer'), answer('answer'), iceCandidate('ice-candidate'), @@ -31,6 +32,7 @@ enum SignalingMessageType { fileComplete('file-complete'), progress('progress'), heartbeat('heartbeat'), + heartbeatAck('heartbeat_ack'), leave('leave'), clipboardSync('clipboard-sync'), pairRequest('pair-request'), @@ -184,6 +186,12 @@ class SignalingService { String? get deviceId => _deviceId; String? get userId => _userId; + DateTime? _lastHeartbeatAck; + DateTime? get lastHeartbeatAck => _lastHeartbeatAck; + + String? _serverDisplayName; + String? get serverDisplayName => _serverDisplayName; + final StreamController _messageController = StreamController.broadcast(); Stream get onMessage => _messageController.stream; @@ -592,6 +600,8 @@ class SignalingService { switch (message.type) { case SignalingMessageType.discover: + break; + case SignalingMessageType.discoverResponse: _handleDiscoverResponse(json); case SignalingMessageType.myDevicesResponse: _handleMyDevicesResponse(json); @@ -630,6 +640,10 @@ class SignalingService { _handleDeviceOnline(message, isOnline: false); case SignalingMessageType.heartbeat: break; + case SignalingMessageType.heartbeatAck: + _lastHeartbeatAck = DateTime.now(); + Log.d('Signaling: heartbeat_ack received'); + break; case SignalingMessageType.ping: _send( SignalingMessage( @@ -640,6 +654,8 @@ class SignalingService { ); break; case SignalingMessageType.pong: + _lastHeartbeatAck = DateTime.now(); + Log.d('Signaling: pong received'); break; case SignalingMessageType.registered: final serverId = json['id'] as String?; @@ -648,7 +664,13 @@ class SignalingService { } break; case SignalingMessageType.displayName: - Log.d('Signaling: Server set display name'); + final name = json['name'] as String? ?? json['displayName'] as String?; + if (name != null && name.isNotEmpty) { + _serverDisplayName = name; + Log.i('Signaling: Server set display name: $name'); + } else { + Log.d('Signaling: Server set display name'); + } break; case SignalingMessageType.peers: final peerList = json['peers'] as List? ?? []; diff --git a/lib/features/file_transfer/services/transport/localsend_service.dart b/lib/features/file_transfer/services/transport/localsend_service.dart index bd9e9c82..092b4db5 100644 --- a/lib/features/file_transfer/services/transport/localsend_service.dart +++ b/lib/features/file_transfer/services/transport/localsend_service.dart @@ -133,7 +133,7 @@ class LocalSendService { final Dio _dio = Dio( BaseOptions( - connectTimeout: const Duration(seconds: 10), + connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(minutes: 30), sendTimeout: const Duration(minutes: 30), ), diff --git a/lib/features/file_transfer/services/transport/usb_transport_service.dart b/lib/features/file_transfer/services/transport/usb_transport_service.dart index 96ae1c3b..37f9afbe 100644 --- a/lib/features/file_transfer/services/transport/usb_transport_service.dart +++ b/lib/features/file_transfer/services/transport/usb_transport_service.dart @@ -132,6 +132,12 @@ class UsbTransportService { _isHostSupported = result ?? false; Log.i('USB: Host supported = $_isHostSupported'); return _isHostSupported; + } on MissingPluginException { + Log.w( + 'USB: Native plugin not implemented for isUsbHostSupported, fallback to false', + ); + _isHostSupported = false; + return false; } on PlatformException catch (e) { Log.w('USB: Failed to check host support: ${e.message}'); return false; @@ -150,6 +156,9 @@ class UsbTransportService { 'USB: Detected device: ${device.deviceName} (${device.usbVersion.label})', ); return device; + } on MissingPluginException { + Log.w('USB: Native plugin not implemented for detectUsbDevice'); + return null; } on PlatformException catch (e) { Log.w('USB: Failed to detect device: ${e.message}'); return null; @@ -166,6 +175,9 @@ class UsbTransportService { return result .map((e) => UsbDevice.fromMap(e as Map)) .toList(); + } on MissingPluginException { + Log.w('USB: Native plugin not implemented for detectAllUsbDevices'); + return []; } on PlatformException catch (e) { Log.w('USB: Failed to detect all devices: ${e.message}'); return []; @@ -189,6 +201,9 @@ class UsbTransportService { Log.i('USB: Connected to ${device.deviceName}'); } return result ?? false; + } on MissingPluginException { + Log.w('USB: Native plugin not implemented for connectDevice'); + return false; } on PlatformException catch (e) { Log.w('USB: Failed to connect device: ${e.message}'); return false; @@ -237,6 +252,9 @@ class UsbTransportService { speed: _calculateSpeed(fileSize, DateTime.now()), startTime: DateTime.now(), ); + } on MissingPluginException { + Log.w('USB: Native plugin not implemented, send failed'); + return _createFailedTask(taskId, file, 'USB原生插件未实现'); } on PlatformException catch (e) { Log.w('USB: Send failed: ${e.message}'); return _createFailedTask(taskId, file, e.message ?? 'USB发送失败'); @@ -312,6 +330,9 @@ class UsbTransportService { await sink.close(); rethrow; } + } on MissingPluginException { + Log.w('USB: Native plugin not implemented, receive failed'); + return _createFailedReceiveTask(taskId, fileName, fileSize, 'USB原生插件未实现'); } on PlatformException catch (e) { Log.w('USB: Receive failed: ${e.message}'); return _createFailedReceiveTask( @@ -329,6 +350,8 @@ class UsbTransportService { await _channel.invokeMethod('disconnectDevice', { 'deviceId': _connectedDevice!.deviceId, }); + } on MissingPluginException { + Log.w('USB: Native plugin not implemented for disconnect'); } on PlatformException catch (e) { Log.w('USB: Disconnect error: ${e.message}'); } diff --git a/lib/features/home/presentation/cache_management_page.dart b/lib/features/home/presentation/cache_management_page.dart index 0a0cc1aa..a0fd3967 100644 --- a/lib/features/home/presentation/cache_management_page.dart +++ b/lib/features/home/presentation/cache_management_page.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 缓存管理页面 /// 创建时间: 2026-04-28 -/// 更新时间: 2026-05-04 +/// 更新时间: 2026-05-15 /// 作用: 存储空间分析、分类统计、缓存清理 -/// 上次更新: 添加离线标识+清理进度+下拉刷新+emoji替换CupertinoIcon+传输缓存管理 +/// 上次更新: 新增稍后读缓存统计条目+清理稍后读缓存/清理全部稍后读数据操作 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -289,6 +289,12 @@ class _CacheManagementPageState extends ConsumerState { '${stats?.receivedFileCount ?? 0} 个 · ${stats?.receivedFileSizeFormatted ?? '0 B'}', ext, ), + _buildCategoryRow( + CupertinoIcons.book, + '📖 稍后读', + stats?.readlaterSizeFormatted ?? '0 B', + ext, + ), ], ), ), @@ -484,6 +490,66 @@ class _CacheManagementPageState extends ConsumerState { ), ), ), + const SizedBox(height: AppSpacing.sm), + SizedBox( + width: double.infinity, + child: CupertinoButton( + color: ext.accent.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(AppRadius.md), + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + onPressed: state.isCleaning + ? null + : () => _cleanReadlaterCache(), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.book, + size: 16, + color: ext.accent.withValues(alpha: 0.7), + ), + const SizedBox(width: 6), + Text( + '📖 清理稍后读缓存', + style: AppTypography.subhead.copyWith( + color: ext.accent.withValues(alpha: 0.8), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + SizedBox( + width: double.infinity, + child: CupertinoButton( + color: CupertinoColors.systemRed.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(AppRadius.md), + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + onPressed: state.isCleaning + ? null + : () => _clearReadlaterData(), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.book_fill, + size: 16, + color: CupertinoColors.systemRed.withValues(alpha: 0.8), + ), + const SizedBox(width: 6), + Text( + '📖 清理全部稍后读数据', + style: AppTypography.subhead.copyWith( + color: CupertinoColors.systemRed.withValues(alpha: 0.9), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), ], ), ), @@ -793,6 +859,79 @@ class _CacheManagementPageState extends ConsumerState { ); } + void _cleanReadlaterCache() { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.book, + size: 18, + color: CupertinoColors.activeBlue, + ), + SizedBox(width: 6), + Text('清理稍后读缓存'), + ], + ), + content: const Text('将清理稍后读的缩略图、附件和同步临时文件,消息记录不会被删除。'), + actions: [ + CupertinoDialogAction( + child: const Text('取消'), + onPressed: () => Navigator.pop(ctx), + ), + CupertinoDialogAction( + child: const Text('清理'), + onPressed: () async { + Navigator.pop(ctx); + await CacheService.cleanReadlaterCache(); + ref.read(cacheProvider.notifier).loadStats(); + if (mounted) _showToast('稍后读缓存已清理'); + }, + ), + ], + ), + ); + } + + void _clearReadlaterData() { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.book_fill, + size: 18, + color: CupertinoColors.systemRed, + ), + SizedBox(width: 6), + Text('清理全部稍后读数据'), + ], + ), + content: const Text('将删除所有稍后读消息、附件和缩略图,此操作不可撤销!'), + actions: [ + CupertinoDialogAction( + child: const Text('取消'), + onPressed: () => Navigator.pop(ctx), + ), + CupertinoDialogAction( + isDestructiveAction: true, + child: const Text('全部清除'), + onPressed: () async { + Navigator.pop(ctx); + await CacheService.clearReadlaterData(); + ref.read(cacheProvider.notifier).loadStats(); + if (mounted) _showToast('全部稍后读数据已清除'); + }, + ), + ], + ), + ); + } + void _clearAll() { showCupertinoDialog( context: context, diff --git a/lib/features/home/providers/home_interaction_mixin.dart b/lib/features/home/providers/home_interaction_mixin.dart index 8ccdef1b..356c284a 100644 --- a/lib/features/home/providers/home_interaction_mixin.dart +++ b/lib/features/home/providers/home_interaction_mixin.dart @@ -10,6 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/storage/database/app_database.dart'; import '../../../core/utils/logger.dart'; +import '../../inspiration/services/chat_message_service.dart'; import '../services/feed_service.dart'; import 'home_sentence_model.dart'; import 'home_state.dart'; @@ -157,6 +158,25 @@ mixin HomeInteractionMixin on StateNotifier { } catch (e) { Log.w('服务端稍后读同步失败(本地已生效): $e'); } + + if (!oldValue) { + try { + await ChatMessageService.sendReadLaterSentence( + conversationId: 'readlater', + text: sentence.text, + author: sentence.author, + source: sentence.feedName, + feedType: sentence.feedType ?? sentence.type, + feedName: sentence.feedName, + likeCount: sentence.likeCount, + views: sentence.views, + sentenceId: sentence.id.toString(), + ); + Log.i('稍后读句子已写入会话: ${sentence.id}'); + } catch (e) { + Log.w('稍后读句子写入会话失败: $e'); + } + } } catch (e) { Log.e('toggleReadLater异常', e); } finally { diff --git a/lib/features/home/services/cache_service.dart b/lib/features/home/services/cache_service.dart index e3754c25..0321c813 100644 --- a/lib/features/home/services/cache_service.dart +++ b/lib/features/home/services/cache_service.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 缓存服务 /// 创建时间: 2026-04-28 -/// 更新时间: 2026-04-29 +/// 更新时间: 2026-05-15 /// 作用: 统一缓存读写、统计、清理、策略管理 -/// 上次更新: 适配FeedItem新字段(likeCount→likes, isReadLater→isBookmarked) +/// 上次更新: 新增稍后读缓存管理方法(getReadlaterCacheSize/cleanReadlaterCache/clearReadlaterData) /// ============================================================ import 'dart:io'; @@ -142,6 +142,8 @@ class CacheService { final chatTrashCount = await _getChatTrashCount(); final chatTrashSize = await _getChatTrashDirSize(appDocDir.path); + final readlaterSize = await getReadlaterCacheSize(); + return CacheStats( feedCacheCount: feedCount, pendingActionCount: pendingActions, @@ -154,6 +156,7 @@ class CacheService { chatAttachmentSizeBytes: chatAttachSize, chatTrashCount: chatTrashCount, chatTrashSizeBytes: chatTrashSize, + readlaterSizeBytes: readlaterSize, ); } catch (e) { Log.e('CacheService: 统计获取失败', e); @@ -254,6 +257,109 @@ class CacheService { } } + // ---- 稍后读缓存管理 ---- + + /// 获取稍后读缓存大小 + static Future getReadlaterCacheSize() async { + try { + int totalSize = 0; + + final msgRows = await _db.executor.runSelect( + "SELECT COUNT(*) AS cnt FROM chat_msg_records WHERE conversation_id = 'readlater' AND is_deleted = 0", + [], + ); + final msgCount = Sqflite.firstIntValue(msgRows) ?? 0; + totalSize += msgCount * 512; + + final appDocDir = await getApplicationDocumentsDirectory(); + final readlaterDir = Directory( + '${appDocDir.path}/chat_attachments/readlater', + ); + if (await readlaterDir.exists()) { + await for (final entity in readlaterDir.list(recursive: true)) { + if (entity is File) { + totalSize += await entity.length(); + } + } + } + + final syncDir = Directory('${appDocDir.path}/readlater_sync'); + if (await syncDir.exists()) { + await for (final entity in syncDir.list(recursive: true)) { + if (entity is File) { + totalSize += await entity.length(); + } + } + } + + return totalSize; + } catch (e) { + Log.e('CacheService: 稍后读缓存大小获取失败', e); + return 0; + } + } + + /// 清理稍后读缓存(缩略图/附件,保留消息记录) + static Future cleanReadlaterCache() async { + try { + final appDocDir = await getApplicationDocumentsDirectory(); + + final readlaterDir = Directory( + '${appDocDir.path}/chat_attachments/readlater', + ); + if (await readlaterDir.exists()) { + await for (final entity in readlaterDir.list(recursive: true)) { + if (entity is File) { + final name = entity.path.split(Platform.pathSeparator).last; + if (name.startsWith('thumb_')) { + await entity.delete(); + } + } + } + } + + final syncDir = Directory('${appDocDir.path}/readlater_sync'); + if (await syncDir.exists()) { + await syncDir.delete(recursive: true); + } + + Log.i('CacheService: 稍后读缓存已清理(保留消息记录)'); + } catch (e) { + Log.e('CacheService: 稍后读缓存清理失败', e); + } + } + + /// 清理全部稍后读数据(消息+附件+缩略图) + static Future clearReadlaterData() async { + try { + await _db.executor.runCustom( + "DELETE FROM chat_attachments WHERE message_id IN (SELECT id FROM chat_msg_records WHERE conversation_id = 'readlater')", + [], + ); + await _db.executor.runCustom( + "DELETE FROM chat_msg_records WHERE conversation_id = 'readlater'", + [], + ); + + final appDocDir = await getApplicationDocumentsDirectory(); + final readlaterDir = Directory( + '${appDocDir.path}/chat_attachments/readlater', + ); + if (await readlaterDir.exists()) { + await readlaterDir.delete(recursive: true); + } + + final syncDir = Directory('${appDocDir.path}/readlater_sync'); + if (await syncDir.exists()) { + await syncDir.delete(recursive: true); + } + + Log.i('CacheService: 全部稍后读数据已清除'); + } catch (e) { + Log.e('CacheService: 稍后读数据清除失败', e); + } + } + // ---- 预加载 ---- static Future preloadChannels(List channels) async { @@ -414,6 +520,7 @@ class CacheStats { final int pairedDeviceCount; final int receivedFileCount; final int receivedFileSizeBytes; + final int readlaterSizeBytes; const CacheStats({ required this.feedCacheCount, @@ -431,6 +538,7 @@ class CacheStats { this.pairedDeviceCount = 0, this.receivedFileCount = 0, this.receivedFileSizeBytes = 0, + this.readlaterSizeBytes = 0, }); factory CacheStats.empty() => const CacheStats( @@ -448,6 +556,7 @@ class CacheStats { _formatBytes(chatAttachmentSizeBytes); String get chatTrashSizeFormatted => _formatBytes(chatTrashSizeBytes); String get receivedFileSizeFormatted => _formatBytes(receivedFileSizeBytes); + String get readlaterSizeFormatted => _formatBytes(readlaterSizeBytes); static String _formatBytes(int bytes) { if (bytes < 1024) return '$bytes B'; diff --git a/lib/features/home/services/feed_service.dart b/lib/features/home/services/feed_service.dart index 6d6ac11e..67b8e236 100644 --- a/lib/features/home/services/feed_service.dart +++ b/lib/features/home/services/feed_service.dart @@ -435,10 +435,12 @@ class FeedService { 'limit': limit, }; if (seenIds != null && seenIds.isNotEmpty) { - params['seen_ids'] = seenIds.join(','); + final trimmedIds = seenIds.length > 30 ? seenIds.sublist(0, 30) : seenIds; + params['seen_ids'] = trimmedIds.join(','); } if (seenHashes != null && seenHashes.isNotEmpty) { - params['seen_hashes'] = seenHashes.join(','); + final trimmedHashes = seenHashes.length > 30 ? seenHashes.sublist(0, 30) : seenHashes; + params['seen_hashes'] = trimmedHashes.join(','); } final response = await _api.get>( diff --git a/lib/features/home/services/searchall_service.dart b/lib/features/home/services/searchall_service.dart index 76299ad1..5f4eee68 100644 --- a/lib/features/home/services/searchall_service.dart +++ b/lib/features/home/services/searchall_service.dart @@ -8,6 +8,7 @@ import '../../../core/network/api_client.dart'; import '../../../core/utils/logger.dart'; +import '../../../core/utils/extensions.dart'; import '../models/feed_model.dart'; /// 全量搜索API服务 @@ -387,20 +388,20 @@ class SearchAllService { final list = resultData['list'] as List? ?? []; return list.map((e) { final json = e as Map; - final titleHl = json['title_highlight'] as String? ?? ''; - final contentHl = json['content_highlight'] as String? ?? ''; - final authorHl = json['author_highlight'] as String? ?? ''; + final titleHl = (json['title_highlight'] as String? ?? '').stripHtml.decodeHtmlEntities; + final contentHl = (json['content_highlight'] as String? ?? '').stripHtml.decodeHtmlEntities; + final authorHl = (json['author_highlight'] as String? ?? '').stripHtml.decodeHtmlEntities; return SearchHighlightItem( item: FeedItem.fromJson(json), titleHighlight: titleHl.isNotEmpty ? titleHl - : _autoHighlight(json['title'] as String? ?? '', keyword, tag), + : _autoHighlight((json['title'] as String? ?? '').cleanHtml, keyword, tag), contentHighlight: contentHl.isNotEmpty ? contentHl - : _autoHighlight(json['content'] as String? ?? '', keyword, tag), + : _autoHighlight((json['content'] as String? ?? '').cleanHtml, keyword, tag), authorHighlight: authorHl.isNotEmpty ? authorHl - : _autoHighlight(json['author'] as String? ?? '', keyword, tag), + : _autoHighlight((json['author'] as String? ?? '').cleanHtml, keyword, tag), ); }).toList(); } catch (e) { diff --git a/lib/features/inspiration/models/chat_message.dart b/lib/features/inspiration/models/chat_message.dart index 4b9c7025..afa2c235 100644 --- a/lib/features/inspiration/models/chat_message.dart +++ b/lib/features/inspiration/models/chat_message.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 会话消息模型 // 创建时间: 2026-04-30 -// 更新时间: 2026-05-08 +// 更新时间: 2026-05-15 // 作用: 会话流消息数据模型,支持AI推送+用户消息+导入导出+Drift互转 -// 上次更新: 新增audio/video/richText枚举+replyToId/richContent/ipText/ipDetailJson字段 +// 上次更新: E9新增tags便捷访问方法(getTags/addTag/removeTag) // ============================================================ import 'dart:convert'; @@ -21,7 +21,10 @@ enum ChatMessageType { file('file', '文件消息'), audio('audio', '语音消息'), video('video', '视频消息'), - richText('rich_text', '富文本消息'); + richText('rich_text', '富文本消息'), + link('link', '链接消息'), + document('document', '文档消息'), + readlaterSentence('readlater_sentence', '稍后读句子'); const ChatMessageType(this.id, this.label); final String id; @@ -193,9 +196,46 @@ class ChatMessage { bool get isRichText => type == ChatMessageType.richText || (richContent != null && richContent!.isNotEmpty); + bool get isLink => type == ChatMessageType.link; + bool get isDocument => type == ChatMessageType.document; + bool get isReadlaterSentence => type == ChatMessageType.readlaterSentence; bool get hasReplyTo => replyToId != null && replyToId!.isNotEmpty; bool get hasIpInfo => ipText != null && ipText!.isNotEmpty; + // ---- 标签便捷方法 (ext['tags']) ---- + + /// 获取消息标签列表 + List get getTags { + final tags = ext?['tags']; + if (tags is List) { + return tags.map((e) => e.toString()).toList(); + } + return []; + } + + /// 判断消息是否包含指定标签 + bool hasTag(String tag) => getTags.contains(tag); + + /// 添加标签,返回新的ChatMessage实例 + ChatMessage addTag(String tag) { + final currentTags = getTags; + if (currentTags.contains(tag)) return this; + final newTags = [...currentTags, tag]; + final newExt = Map.from(ext ?? {}); + newExt['tags'] = newTags; + return copyWith(ext: newExt); + } + + /// 移除标签,返回新的ChatMessage实例 + ChatMessage removeTag(String tag) { + final currentTags = getTags; + if (!currentTags.contains(tag)) return this; + final newTags = currentTags.where((t) => t != tag).toList(); + final newExt = Map.from(ext ?? {}); + newExt['tags'] = newTags; + return copyWith(ext: newExt); + } + String get displayTime { final now = DateTime.now(); final diff = now.difference(timestamp); diff --git a/lib/features/inspiration/models/chat_session.dart b/lib/features/inspiration/models/chat_session.dart index 8985e234..ea8f5467 100644 --- a/lib/features/inspiration/models/chat_session.dart +++ b/lib/features/inspiration/models/chat_session.dart @@ -10,7 +10,8 @@ enum ChatSessionType { chat('chat', '会话流'), discover('discover', '发现'), footprint('footprint', '足迹'), - custom('custom', '自定义'); + custom('custom', '自定义'), + readlater('readlater', '稍后读'); const ChatSessionType(this.id, this.label); final String id; diff --git a/lib/features/inspiration/presentation/chat_flow_page.dart b/lib/features/inspiration/presentation/chat_flow_page.dart deleted file mode 100644 index 397cef3a..00000000 --- a/lib/features/inspiration/presentation/chat_flow_page.dart +++ /dev/null @@ -1,1184 +0,0 @@ -// ============================================================ -// 闲言APP — 会话流页面 -// 创建时间: 2026-04-30 -// 更新时间: 2026-05-09 -// 作用: 会话流Tab内容,对话式UI+用户输入+智能推送+附件发送 -// 上次更新: 动态主题+动态样式+隐藏会话+背景图+键盘不自动弹出 -// ============================================================ - -import 'dart:io'; -import 'dart:ui'; - -import 'package:flutter/cupertino.dart'; -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:go_router/go_router.dart'; -import 'package:audioplayers/audioplayers.dart'; - -import 'package:xianyan/core/theme/app_theme.dart'; -import 'package:xianyan/core/theme/app_spacing.dart'; -import 'package:xianyan/core/theme/app_typography.dart'; -import 'package:xianyan/core/router/app_router.dart'; -import 'package:xianyan/core/utils/logger.dart'; -import 'package:xianyan/features/inspiration/models/chat_message.dart'; -import 'package:xianyan/features/inspiration/presentation/widgets/chat_bubble.dart'; -import 'package:xianyan/features/inspiration/presentation/widgets/rich_text_editor_sheet.dart'; -import 'package:xianyan/features/inspiration/presentation/widgets/reply_preview_bar.dart'; -import 'package:xianyan/features/inspiration/presentation/widgets/attachment_grid_sheet.dart'; -import 'package:xianyan/features/inspiration/presentation/widgets/record_audio_sheet.dart'; -import 'package:xianyan/features/inspiration/providers/chat_provider.dart'; -import 'package:xianyan/features/inspiration/providers/chat_attachment_provider.dart'; -import 'package:xianyan/features/inspiration/providers/chat_conversation_provider.dart'; -import 'package:xianyan/features/inspiration/services/chat_conversation_service.dart'; -import 'package:xianyan/features/inspiration/services/chat_file_service.dart'; - -class ChatFlowPage extends ConsumerStatefulWidget { - const ChatFlowPage({super.key, this.conversationId = 'default'}); - - final String conversationId; - - @override - ConsumerState createState() => _ChatFlowPageState(); -} - -class _ChatFlowPageState extends ConsumerState - with WidgetsBindingObserver { - late final TextEditingController _inputController; - final _scrollController = ScrollController(); - final _focusNode = FocusNode(); - bool _userTappedInput = false; - - static const _categories = >[ - MapEntry('all', '📋 全部'), - MapEntry('hot', '🔥 热门'), - MapEntry('love', '💕 爱情'), - MapEntry('nature', '🌿 自然'), - MapEntry('motivate', '💪 励志'), - MapEntry('literature', '📖 文学'), - MapEntry('movie', '🎬 影视'), - ]; - - bool _showAllCategories = false; - String _inputText = ''; - ChatMessage? _replyToMessage; - String? _selectedQuickCategory; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - _inputController = TextEditingController( - text: ref.read(chatMessagesProvider(widget.conversationId)).draftText, - ); - _inputText = _inputController.text; - _inputController.addListener(_onInputChanged); - WidgetsBinding.instance.addPostFrameCallback((_) { - FocusScope.of(context).unfocus(); - _focusNode.unfocus(); - ref - .read(chatMessagesProvider(widget.conversationId).notifier) - .markAllAsRead(); - }); - } - - void _onInputChanged() { - final text = _inputController.text; - if (text != _inputText) { - _inputText = text; - ref - .read(chatMessagesProvider(widget.conversationId).notifier) - .saveDraft(text); - } - } - - @override - void dispose() { - WidgetsBinding.instance.removeObserver(this); - _inputController.removeListener(_onInputChanged); - _inputController.dispose(); - _scrollController.dispose(); - _focusNode.dispose(); - super.dispose(); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed && !_userTappedInput) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - FocusScope.of(context).unfocus(); - _focusNode.unfocus(); - } - }); - } - } - - void _sendMessage() { - final text = _inputController.text.trim(); - if (text.isEmpty) return; - ref - .read(chatMessagesProvider(widget.conversationId).notifier) - .sendMessage( - text, - category: _selectedQuickCategory, - replyToId: _replyToMessage?.id, - ); - _inputController.clear(); - _inputText = ''; - _replyToMessage = null; - HapticFeedback.lightImpact(); - _playSendFeedback(); - } - - void _playSendFeedback() { - try { - final player = AudioPlayer(); - player.play(AssetSource('sounds/send.mp3')); - } catch (_) { - SystemSound.play(SystemSoundType.click); - } - _showSendToast(); - } - - void _showSendToast() { - final overlay = Overlay.of(context); - OverlayEntry? entry; - entry = OverlayEntry( - builder: (ctx) { - return Positioned( - top: MediaQuery.of(ctx).padding.top + 60, - left: 0, - right: 0, - child: Center( - child: _SendToastWidget( - category: _selectedQuickCategory, - onClose: () { - if (entry!.mounted) entry.remove(); - }, - ), - ), - ); - }, - ); - overlay.insert(entry); - Future.delayed(const Duration(milliseconds: 1800), () { - if (entry!.mounted) entry.remove(); - }); - } - - void _setReplyTo(ChatMessage message) { - setState(() => _replyToMessage = message); - _focusNode.requestFocus(); - } - - void _clearReplyTo() { - setState(() => _replyToMessage = null); - } - - ChatMessage? _findMessage(String? id, List messages) { - if (id == null) return null; - try { - return messages.firstWhere((m) => m.id == id); - } catch (_) { - return null; - } - } - - void _openSettings() { - context.push('${AppRoutes.chatSettings}/${widget.conversationId}'); - } - - Future _createNewConversation() async { - final nameController = TextEditingController(); - final emoji = ValueNotifier('💬'); - final ext = AppTheme.ext(context); - - final result = await showCupertinoDialog>( - context: context, - builder: (ctx) { - return CupertinoAlertDialog( - title: const Text('✨ 新建对话'), - content: Padding( - padding: const EdgeInsets.only(top: AppSpacing.sm), - child: Column( - children: [ - Row( - children: [ - ValueListenableBuilder( - valueListenable: emoji, - builder: (_, val, __) => GestureDetector( - onTap: () async { - final emojis = [ - '💬', - '🎯', - '💡', - '📚', - '🎮', - '🎵', - '✈️', - '🏠', - ]; - final selected = - await showCupertinoModalPopup( - context: ctx, - builder: (_) => SizedBox( - height: 200, - child: CupertinoPicker( - itemExtent: 40, - onSelectedItemChanged: (i) => - emoji.value = emojis[i], - children: emojis - .map( - (e) => Center( - child: Text( - e, - style: const TextStyle( - fontSize: 24, - ), - ), - ), - ) - .toList(), - ), - ), - ); - if (selected != null) emoji.value = selected; - }, - child: Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: ext.bgSecondary, - borderRadius: BorderRadius.circular(10), - ), - child: Center( - child: ValueListenableBuilder( - valueListenable: emoji, - builder: (_, val, __) => Text( - val, - style: const TextStyle(fontSize: 24), - ), - ), - ), - ), - ), - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: CupertinoTextField( - controller: nameController, - placeholder: '对话名称', - autofocus: true, - ), - ), - ], - ), - ], - ), - ), - actions: [ - CupertinoDialogAction( - isDestructiveAction: true, - onPressed: () => Navigator.pop(ctx), - child: const Text('取消'), - ), - CupertinoDialogAction( - isDefaultAction: true, - onPressed: () { - final name = nameController.text.trim(); - if (name.isEmpty) return; - Navigator.pop(ctx, {'name': name, 'emoji': emoji.value}); - }, - child: const Text('创建'), - ), - ], - ); - }, - ); - - if (result != null) { - final conv = await ChatConversationService.create( - name: result['name']!, - emoji: result['emoji'] ?? '💬', - ); - if (mounted) { - context.push('${AppRoutes.chatFlow}/${conv.id}'); - } - } - } - - @override - Widget build(BuildContext context) { - final baseExt = AppTheme.ext(context); - final chatState = ref.watch(chatMessagesProvider(widget.conversationId)); - final messages = chatState.filteredMessages; - final attachmentState = ref.watch( - chatAttachmentProvider(widget.conversationId), - ); - final convState = ref.watch(chatConversationProvider); - final conv = convState.conversations - .where((c) => c.id == widget.conversationId) - .firstOrNull; - final bgImagePath = conv?.bgImagePath; - - final convSettings = conv != null - ? ChatConversationService.getSettings(conv) - : {}; - final ext = _buildDynamicTheme(baseExt, convSettings); - - return CupertinoPageScaffold( - backgroundColor: ext.bgPrimary, - navigationBar: CupertinoNavigationBar( - middle: Text( - '💬 会话流', - style: AppTypography.headline.copyWith(color: ext.textPrimary), - ), - backgroundColor: ext.bgPrimary.withValues(alpha: 0.85), - border: null, - previousPageTitle: '灵感', - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - GestureDetector( - onTap: _createNewConversation, - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: ext.bgSecondary, - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Icon( - CupertinoIcons.pencil_ellipsis_rectangle, - size: 16, - color: ext.textSecondary, - ), - ), - ), - ), - const SizedBox(width: 6), - GestureDetector( - onTap: _openSettings, - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: ext.bgSecondary, - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Icon( - CupertinoIcons.settings, - size: 16, - color: ext.textSecondary, - ), - ), - ), - ), - ], - ), - ), - child: Stack( - fit: StackFit.expand, - children: [ - if (bgImagePath != null && bgImagePath.isNotEmpty) - _buildBackgroundImage(bgImagePath), - GestureDetector( - onTap: () { - _userTappedInput = false; - FocusScope.of(context).unfocus(); - }, - behavior: HitTestBehavior.translucent, - child: SafeArea( - bottom: false, - child: Column( - children: [ - _buildCategoryBar(ext), - if (attachmentState.pendingAttachments.isNotEmpty) - _buildPendingAttachments(ext, attachmentState), - Expanded( - child: chatState.isLoading - ? const Center(child: CupertinoActivityIndicator()) - : messages.isEmpty - ? _buildEmpty(ext) - : _buildMessageList(ext, messages), - ), - _buildInputBar(ext), - ], - ), - ), - ), - ], - ), - ); - } - - Widget _buildBackgroundImage(String path) { - final ext = AppTheme.ext(context); - return Positioned.fill( - child: FutureBuilder( - future: ChatFileService.getAbsolutePath(path), - builder: (_, snap) { - if (snap.hasData) { - final file = File(snap.data!); - if (file.existsSync()) { - return Stack( - fit: StackFit.expand, - children: [ - Image.file( - file, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - ), - Container(color: ext.bgPrimary.withValues(alpha: 0.35)), - ], - ); - } - } - return const SizedBox.shrink(); - }, - ), - ); - } - - AppThemeExtension _buildDynamicTheme( - AppThemeExtension base, - Map settings, - ) { - Color? accentOverride; - if (settings.containsKey('accentColor')) { - try { - final hex = settings['accentColor'] as String; - accentOverride = _hexToColor(hex); - } catch (_) {} - } - - if (accentOverride == null) return base; - - final lighter = Color.lerp(accentOverride, Colors.white, 0.2)!; - return base.copyWith(accent: accentOverride, accentLight: lighter); - } - - Color _hexToColor(String hex) { - final buffer = StringBuffer(); - if (hex.length == 6) buffer.write('ff'); - buffer.write(hex.replaceFirst('#', '')); - return Color(int.parse(buffer.toString(), radix: 16)); - } - - Widget _buildCategoryBar(AppThemeExtension ext) { - final selected = ref - .watch(chatMessagesProvider(widget.conversationId)) - .selectedCategory; - final displayCategories = _showAllCategories - ? _categories - : _categories.take(3).toList(); - final hasMore = _categories.length > 3; - - return Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), - child: Row( - children: [ - Expanded( - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: displayCategories.length, - separatorBuilder: (_, __) => const SizedBox(width: 6), - itemBuilder: (context, index) { - final entry = displayCategories[index]; - final isSelected = - (entry.key == 'all' && selected == null) || - entry.key == selected; - return GestureDetector( - onTap: () { - ref - .read( - chatMessagesProvider(widget.conversationId).notifier, - ) - .selectCategory(entry.key == 'all' ? null : entry.key); - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 6, - ), - decoration: BoxDecoration( - color: isSelected - ? ext.accent.withValues(alpha: 0.15) - : ext.bgCard, - borderRadius: BorderRadius.circular(20), - border: isSelected - ? Border.all(color: ext.accent.withValues(alpha: 0.3)) - : null, - ), - child: Center( - child: Text( - entry.value, - style: AppTypography.subhead.copyWith( - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, - color: isSelected ? ext.accent : ext.textSecondary, - ), - ), - ), - ), - ); - }, - ), - ), - if (hasMore) - GestureDetector( - onTap: () => - setState(() => _showAllCategories = !_showAllCategories), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: ext.bgCard, - borderRadius: BorderRadius.circular(20), - ), - child: Center( - child: Text( - _showAllCategories ? '收起 ▲' : '展开 ▼', - style: AppTypography.caption1.copyWith( - color: ext.textSecondary, - ), - ), - ), - ), - ), - ], - ), - ); - } - - Widget _buildPendingAttachments( - AppThemeExtension ext, - ChatAttachmentState attachmentState, - ) { - return Container( - height: 72, - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xs, - ), - decoration: BoxDecoration( - color: ext.bgCard, - border: Border( - top: BorderSide(color: ext.textHint.withValues(alpha: 0.1)), - bottom: BorderSide(color: ext.textHint.withValues(alpha: 0.1)), - ), - ), - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: attachmentState.pendingAttachments.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (context, index) { - final attachment = attachmentState.pendingAttachments[index]; - return _buildPendingAttachmentItem(ext, attachment, index); - }, - ), - ); - } - - Widget _buildPendingAttachmentItem( - AppThemeExtension ext, - PendingAttachment attachment, - int index, - ) { - final isImage = attachment.fileType.startsWith('image/'); - return GestureDetector( - onLongPress: () => _removePendingAttachment(index), - child: Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: ext.bgSecondary, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: ext.textHint.withValues(alpha: 0.2)), - ), - child: isImage - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: _buildPendingImagePreview(ext, attachment), - ) - : _buildFilePlaceholder(ext, attachment), - ), - ); - } - - Widget _buildFilePlaceholder( - AppThemeExtension ext, - PendingAttachment attachment, - ) { - final isImage = attachment.fileType.startsWith('image/'); - final icon = isImage ? '🖼️' : '📄'; - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(icon, style: const TextStyle(fontSize: 18)), - const SizedBox(height: 2), - Text( - attachment.fileName.length > 6 - ? '${attachment.fileName.substring(0, 6)}...' - : attachment.fileName, - style: AppTypography.caption2.copyWith(color: ext.textHint), - ), - ], - ), - ); - } - - Widget _buildPendingImagePreview( - AppThemeExtension ext, - PendingAttachment attachment, - ) { - final rawPath = attachment.thumbnailPath ?? attachment.filePath; - if (rawPath == null) return _buildFilePlaceholder(ext, attachment); - return FutureBuilder( - future: ChatFileService.getAbsolutePath(rawPath), - builder: (_, snap) { - if (snap.hasData) { - final file = File(snap.data!); - if (file.existsSync()) { - return Image.file(file, fit: BoxFit.cover, width: 56, height: 56); - } - } - return _buildFilePlaceholder(ext, attachment); - }, - ); - } - - void _removePendingAttachment(int index) { - ref - .read(chatAttachmentProvider(widget.conversationId).notifier) - .removePendingAttachment(index); - } - - Widget _buildMessageList(AppThemeExtension ext, List messages) { - return NotificationListener( - onNotification: (notification) { - if (notification is ScrollEndNotification && - _scrollController.position.pixels >= - _scrollController.position.maxScrollExtent - 100) { - ref - .read(chatMessagesProvider(widget.conversationId).notifier) - .loadMore(); - } - return false; - }, - child: ListView.builder( - controller: _scrollController, - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - reverse: true, - itemCount: messages.length, - itemBuilder: (context, index) { - final msg = messages[index]; - return ChatBubble( - message: msg, - ext: ext, - onLike: () => Log.i('喜欢: ${msg.id}'), - onFavorite: () => Log.i('收藏: ${msg.id}'), - onMake: () => Log.i('制作: ${msg.id}'), - onShare: () => Log.i('分享: ${msg.id}'), - onDelete: () => ref - .read(chatMessagesProvider(widget.conversationId).notifier) - .deleteMessage(msg.id), - onMarkRead: () => ref - .read(chatMessagesProvider(widget.conversationId).notifier) - .markRead(msg.id), - onEdit: (newText) => ref - .read(chatMessagesProvider(widget.conversationId).notifier) - .editMessage(msg.id, newText), - onReply: () => _setReplyTo(msg), - replyMessage: _findMessage(msg.replyToId, messages), - ); - }, - ), - ); - } - - Widget _buildEmpty(AppThemeExtension ext) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('💬', style: TextStyle(fontSize: 48)) - .animate(onPlay: (c) => c.repeat(reverse: true)) - .scale( - begin: const Offset(1, 1), - end: const Offset(1.1, 1.1), - duration: 1500.ms, - ) - .then() - .scale( - begin: const Offset(1.1, 1.1), - end: const Offset(1, 1), - duration: 1500.ms, - ), - const SizedBox(height: AppSpacing.md), - Text( - '暂无消息', - style: AppTypography.body.copyWith(color: ext.textSecondary), - ).animate().fadeIn(duration: 600.ms), - const SizedBox(height: AppSpacing.xs), - Text( - '发送一条消息开始对话吧', - style: AppTypography.subhead.copyWith(color: ext.textHint), - ).animate().fadeIn(duration: 600.ms, delay: 200.ms), - ], - ), - ); - } - - Widget _buildInputBar(AppThemeExtension ext) { - final hasContent = _inputText.trim().isNotEmpty; - final attachmentNotifier = ref.read( - chatAttachmentProvider(widget.conversationId).notifier, - ); - - return Container( - padding: const EdgeInsets.fromLTRB( - AppSpacing.sm, - AppSpacing.xs, - AppSpacing.sm, - AppSpacing.xs, - ), - decoration: BoxDecoration( - color: ext.bgCard, - border: Border( - top: BorderSide(color: ext.textHint.withValues(alpha: 0.1)), - ), - ), - child: SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_replyToMessage != null) - Padding( - padding: const EdgeInsets.only(bottom: AppSpacing.xs), - child: ReplyPreviewBar( - message: _replyToMessage!, - ext: ext, - onCancel: _clearReplyTo, - ), - ), - Row( - children: [ - _buildAttachButton(ext, attachmentNotifier), - const SizedBox(width: 6), - Expanded( - child: CupertinoTextField( - controller: _inputController, - focusNode: _focusNode, - onTap: () => _userTappedInput = true, - placeholder: '说点什么...', - placeholderStyle: AppTypography.body.copyWith( - color: ext.textHint, - ), - style: AppTypography.body.copyWith(color: ext.textPrimary), - decoration: BoxDecoration( - color: ext.bgSecondary, - borderRadius: BorderRadius.circular(20), - ), - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - maxLines: 4, - minLines: 1, - textInputAction: TextInputAction.send, - onSubmitted: (_) => _sendMessage(), - ), - ), - const SizedBox(width: AppSpacing.xs), - _buildRichTextButton(ext), - const SizedBox(width: 4), - _buildSendButton(ext, hasContent), - ], - ), - const SizedBox(height: 4), - _buildQuickCategoryBar(ext), - ], - ), - ), - ); - } - - Widget _buildAttachButton( - AppThemeExtension ext, - ChatAttachmentNotifier notifier, - ) { - return GestureDetector( - onTap: () => _showAttachmentSheet(ext, notifier), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: ext.bgSecondary, - borderRadius: BorderRadius.circular(50), - ), - child: Center( - child: Icon(CupertinoIcons.plus, size: 18, color: ext.textSecondary), - ), - ), - ); - } - - Widget _buildRichTextButton(AppThemeExtension ext) { - return GestureDetector( - onTap: () { - RichTextEditorSheet.show( - context, - onSend: (plainText, deltaJson) { - ref - .read(chatMessagesProvider(widget.conversationId).notifier) - .sendRichTextMessage( - plainText, - richContent: deltaJson, - replyToId: _replyToMessage?.id, - ); - _replyToMessage = null; - }, - ); - }, - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: ext.bgSecondary, - borderRadius: BorderRadius.circular(50), - ), - child: Center( - child: Icon( - CupertinoIcons.pencil_ellipsis_rectangle, - size: 16, - color: ext.textSecondary, - ), - ), - ), - ); - } - - Widget _buildSendButton(AppThemeExtension ext, bool hasContent) { - return GestureDetector( - onTap: hasContent ? _sendAll : null, - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - width: 38, - height: 38, - decoration: BoxDecoration( - gradient: hasContent - ? LinearGradient(colors: [ext.accent, ext.accentLight]) - : null, - color: hasContent ? null : ext.textHint.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(50), - boxShadow: hasContent - ? [ - BoxShadow( - color: ext.accent.withValues(alpha: 0.3), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ] - : null, - ), - child: const Icon( - CupertinoIcons.arrow_up, - color: Colors.white, - size: 18, - ), - ), - ) - .animate(target: hasContent ? 1 : 0) - .scale( - begin: const Offset(1, 1), - end: const Offset(1.05, 1.05), - duration: 200.ms, - ); - } - - void _sendAll() { - final text = _inputController.text.trim(); - final attachmentState = ref.read( - chatAttachmentProvider(widget.conversationId), - ); - final chatNotifier = ref.read( - chatMessagesProvider(widget.conversationId).notifier, - ); - final attachmentNotifier = ref.read( - chatAttachmentProvider(widget.conversationId).notifier, - ); - - if (text.isNotEmpty) { - chatNotifier.sendMessage(text); - _inputController.clear(); - _inputText = ''; - } - - if (attachmentState.pendingAttachments.isNotEmpty) { - attachmentNotifier.sendPendingAttachments().then((_) { - chatNotifier.reloadMessages(); - }); - } - - HapticFeedback.lightImpact(); - } - - Widget _buildQuickCategoryBar(AppThemeExtension ext) { - final quickCategories = _categories.skip(1).take(4).toList(); - return SizedBox( - height: 32, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: quickCategories.length, - separatorBuilder: (_, __) => const SizedBox(width: 6), - itemBuilder: (context, index) { - final entry = quickCategories[index]; - final isSelected = _selectedQuickCategory == entry.key; - return GestureDetector( - onTap: () { - setState(() { - _selectedQuickCategory = isSelected ? null : entry.key; - }); - HapticFeedback.selectionClick(); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: isSelected - ? ext.accent.withValues(alpha: 0.15) - : ext.bgSecondary, - borderRadius: BorderRadius.circular(12), - border: isSelected - ? Border.all(color: ext.accent.withValues(alpha: 0.4)) - : null, - ), - child: Center( - child: Text( - entry.value, - style: AppTypography.caption1.copyWith( - color: isSelected ? ext.accent : ext.textSecondary, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, - ), - ), - ), - ), - ); - }, - ), - ); - } - - void _showAttachmentSheet( - AppThemeExtension ext, - ChatAttachmentNotifier notifier, - ) { - AttachmentGridSheet.show( - context, - items: [ - AttachmentGridItem( - emoji: '🖼️', - label: '相册', - gradientColors: [const Color(0xFF6C63FF), const Color(0xFF4ECDC4)], - onTap: () { - Navigator.pop(context); - notifier.pickImageFromGallery(); - }, - ), - AttachmentGridItem( - emoji: '📷', - label: '拍照', - gradientColors: [const Color(0xFFf093fb), const Color(0xFFf5576c)], - onTap: () { - Navigator.pop(context); - notifier.pickImageFromCamera(); - }, - ), - AttachmentGridItem( - emoji: '🎬', - label: '视频', - gradientColors: [const Color(0xFF43e97b), const Color(0xFF38f9d7)], - onTap: () { - Navigator.pop(context); - notifier.pickVideo(); - }, - ), - AttachmentGridItem( - emoji: '🎙️', - label: '录音', - gradientColors: [const Color(0xFFFF6B6B), const Color(0xFFFFB74D)], - onTap: () { - Navigator.pop(context); - RecordAudioSheet.show( - context, - onComplete: (path) { - ref - .read(chatMessagesProvider(widget.conversationId).notifier) - .sendAudio(path); - }, - ); - }, - ), - AttachmentGridItem( - emoji: '📄', - label: '文件', - gradientColors: [const Color(0xFF667eea), const Color(0xFF764ba2)], - onTap: () { - Navigator.pop(context); - notifier.pickFile(); - }, - ), - AttachmentGridItem( - emoji: '📍', - label: '位置', - gradientColors: [const Color(0xFFfa709a), const Color(0xFFfee140)], - onTap: () { - Navigator.pop(context); - }, - ), - AttachmentGridItem( - emoji: '🔗', - label: '链接', - gradientColors: [const Color(0xFFa18cd1), const Color(0xFFfbc2eb)], - onTap: () { - Navigator.pop(context); - }, - ), - AttachmentGridItem( - emoji: '✏️', - label: '富文本', - gradientColors: [const Color(0xFFffecd2), const Color(0xFFfcb69f)], - onTap: () { - Navigator.pop(context); - }, - ), - ], - ); - } -} - -class _SendToastWidget extends StatefulWidget { - const _SendToastWidget({this.category, required this.onClose}); - - final String? category; - final VoidCallback onClose; - - @override - State<_SendToastWidget> createState() => _SendToastWidgetState(); -} - -class _SendToastWidgetState extends State<_SendToastWidget> - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _opacity; - late Animation _slide; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 350), - ); - _opacity = Tween( - begin: 0, - end: 1, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); - _slide = Tween( - begin: const Offset(0, -0.5), - end: Offset.zero, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); - _controller.forward(); - Future.delayed(const Duration(milliseconds: 1400), () { - if (mounted) _controller.reverse().then((_) => widget.onClose()); - }); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final ext = AppTheme.ext(context); - final catEmoji = widget.category != null - ? _categoryEmoji(widget.category!) - : ''; - final label = widget.category != null - ? '$catEmoji 已发送至 ${widget.category!}' - : '✈️ 已发送'; - - return SlideTransition( - position: _slide, - child: FadeTransition( - opacity: _opacity, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: ext.bgCard.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: ext.accent.withValues(alpha: 0.2)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: AppTypography.subhead.copyWith( - color: ext.textPrimary, - fontSize: 13, - ), - ), - ], - ), - ), - ), - ), - ), - ); - } - - String _categoryEmoji(String category) { - return switch (category) { - 'hot' => '🔥', - 'love' => '💕', - 'nature' => '🌿', - 'motivate' => '💪', - 'literature' => '📖', - 'movie' => '🎬', - _ => '🏷️', - }; - } -} diff --git a/lib/features/inspiration/presentation/pages/chat/chat_flow_page.dart b/lib/features/inspiration/presentation/pages/chat/chat_flow_page.dart new file mode 100644 index 00000000..6b17c0da --- /dev/null +++ b/lib/features/inspiration/presentation/pages/chat/chat_flow_page.dart @@ -0,0 +1,374 @@ +// ============================================================ +// 闲言APP — 会话流页面 +// 创建时间: 2026-04-30 +// 更新时间: 2026-05-15 +// 作用: 会话流Tab内容,对话式UI+用户输入+智能推送+附件发送+稍后读会话+全文搜索 +// 上次更新: v5.4.0 拆分为多文件,主文件保留State管理和build方法 +// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:audioplayers/audioplayers.dart'; + +import 'package:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_typography.dart'; +import 'package:xianyan/core/router/app_router.dart'; +import 'package:xianyan/features/inspiration/models/chat_message.dart'; +import 'package:xianyan/features/inspiration/models/chat_session.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/chat/chat_flow_top_bar.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/chat/chat_flow_input_bar.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/chat/chat_flow_message_list.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/chat/chat_flow_send_toast.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/chat/chat_flow_readlater_mixin.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/chat/chat_flow_conversation_mixin.dart'; +import 'package:xianyan/features/inspiration/providers/chat_provider.dart'; +import 'package:xianyan/features/inspiration/providers/chat_attachment_provider.dart'; +import 'package:xianyan/features/inspiration/providers/chat_conversation_provider.dart'; +import 'package:xianyan/features/inspiration/services/chat_conversation_service.dart'; + +class ChatFlowPage extends ConsumerStatefulWidget { + const ChatFlowPage({ + super.key, + this.conversationId = 'default', + this.sessionType = ChatSessionType.chat, + }); + + final String conversationId; + final ChatSessionType sessionType; + + bool get isReadlater => sessionType == ChatSessionType.readlater; + + @override + ConsumerState createState() => _ChatFlowPageState(); +} + +class _ChatFlowPageState extends ConsumerState + with WidgetsBindingObserver { + late final TextEditingController _inputController; + final _scrollController = ScrollController(); + final _focusNode = FocusNode(); + final _searchController = TextEditingController(); + final _searchFocusNode = FocusNode(); + bool _userTappedInput = false; + bool _isSearchActive = false; + final bool _showAllCategories = false; + String _inputText = ''; + ChatMessage? _replyToMessage; + String? _selectedQuickCategory; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _inputController = TextEditingController( + text: ref.read(chatMessagesProvider(widget.conversationId)).draftText, + ); + _inputText = _inputController.text; + _inputController.addListener(_onInputChanged); + WidgetsBinding.instance.addPostFrameCallback((_) { + FocusScope.of(context).unfocus(); + _focusNode.unfocus(); + ref + .read(chatMessagesProvider(widget.conversationId).notifier) + .markAllAsRead(); + }); + } + + void _onInputChanged() { + final text = _inputController.text; + if (text != _inputText) { + _inputText = text; + ref + .read(chatMessagesProvider(widget.conversationId).notifier) + .saveDraft(text); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _inputController.removeListener(_onInputChanged); + _inputController.dispose(); + _scrollController.dispose(); + _focusNode.dispose(); + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed && !_userTappedInput) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + FocusScope.of(context).unfocus(); + _focusNode.unfocus(); + } + }); + } + } + + void _playSendFeedback() { + try { + final player = AudioPlayer(); + player.play(AssetSource('sounds/send.mp3')); + } catch (_) { + SystemSound.play(SystemSoundType.click); + } + _showSendToast(); + } + + void _showSendToast() { + final overlay = Overlay.of(context); + OverlayEntry? entry; + entry = OverlayEntry( + builder: (ctx) { + return Positioned( + top: MediaQuery.of(ctx).padding.top + 60, + left: 0, + right: 0, + child: Center( + child: SendToastWidget( + category: _selectedQuickCategory, + onClose: () { + if (entry!.mounted) entry.remove(); + }, + ), + ), + ); + }, + ); + overlay.insert(entry); + Future.delayed(const Duration(milliseconds: 1800), () { + if (entry!.mounted) entry.remove(); + }); + } + + void _setReplyTo(ChatMessage message) { + setState(() => _replyToMessage = message); + _focusNode.requestFocus(); + } + + void _clearReplyTo() { + setState(() => _replyToMessage = null); + } + + void _toggleSearch() { + setState(() { + _isSearchActive = !_isSearchActive; + if (!_isSearchActive) { + _searchController.clear(); + _searchFocusNode.unfocus(); + ref + .read(chatMessagesProvider(widget.conversationId).notifier) + .clearSearch(); + } else { + Future.delayed(const Duration(milliseconds: 100), () { + if (mounted) _searchFocusNode.requestFocus(); + }); + } + }); + } + + void _onSearchChanged(String query) { + ref + .read(chatMessagesProvider(widget.conversationId).notifier) + .setSearchQuery(query); + } + + void _openSettings() { + context.push('${AppRoutes.chatSettings}/${widget.conversationId}'); + } + + void _sendAll() { + final text = _inputController.text.trim(); + final attachmentState = ref.read( + chatAttachmentProvider(widget.conversationId), + ); + final chatNotifier = ref.read( + chatMessagesProvider(widget.conversationId).notifier, + ); + final attachmentNotifier = ref.read( + chatAttachmentProvider(widget.conversationId).notifier, + ); + + if (text.isNotEmpty) { + chatNotifier.sendMessage(text); + _inputController.clear(); + _inputText = ''; + } + + if (attachmentState.pendingAttachments.isNotEmpty) { + attachmentNotifier.sendPendingAttachments().then((_) { + chatNotifier.reloadMessages(); + }); + } + + HapticFeedback.lightImpact(); + _playSendFeedback(); + } + + @override + Widget build(BuildContext context) { + final baseExt = AppTheme.ext(context); + final convState = ref.watch(chatConversationProvider); + final conv = convState.conversations + .where((c) => c.id == widget.conversationId) + .firstOrNull; + final bgImagePath = conv?.bgImagePath; + + final convSettings = conv != null + ? ChatConversationService.getSettings(conv) + : {}; + final ext = buildDynamicTheme(baseExt, convSettings); + + return CupertinoPageScaffold( + backgroundColor: ext.bgPrimary, + navigationBar: CupertinoNavigationBar( + middle: Text( + widget.isReadlater ? '📖 稍后读' : '💬 会话流', + style: AppTypography.headline.copyWith(color: ext.textPrimary), + ), + backgroundColor: ext.bgPrimary.withValues(alpha: 0.85), + border: null, + previousPageTitle: widget.isReadlater ? '工作流' : '灵感', + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.isReadlater) + GestureDetector( + onTap: _toggleSearch, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: _isSearchActive + ? ext.accent.withValues(alpha: 0.15) + : ext.bgSecondary, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Icon( + CupertinoIcons.search, + size: 16, + color: _isSearchActive ? ext.accent : ext.textSecondary, + ), + ), + ), + ), + if (widget.isReadlater) const SizedBox(width: 6), + if (!widget.isReadlater) + GestureDetector( + onTap: () => ChatFlowConversationHelper.createNewConversation( + context, + ref, + ), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Icon( + CupertinoIcons.pencil_ellipsis_rectangle, + size: 16, + color: ext.textSecondary, + ), + ), + ), + ), + if (!widget.isReadlater) const SizedBox(width: 6), + GestureDetector( + onTap: widget.isReadlater + ? () => ChatFlowReadlaterHelper.showSettings( + context, + ref, + widget.conversationId, + ) + : _openSettings, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Icon( + widget.isReadlater + ? CupertinoIcons.ellipsis_circle + : CupertinoIcons.settings, + size: 16, + color: ext.textSecondary, + ), + ), + ), + ), + ], + ), + ), + child: Stack( + fit: StackFit.expand, + children: [ + if (bgImagePath != null && bgImagePath.isNotEmpty) + ChatFlowBackgroundImage(path: bgImagePath), + GestureDetector( + onTap: () { + _userTappedInput = false; + FocusScope.of(context).unfocus(); + }, + behavior: HitTestBehavior.translucent, + child: SafeArea( + bottom: false, + child: Column( + children: [ + if (widget.isReadlater && _isSearchActive) + ChatFlowSearchBar( + conversationId: widget.conversationId, + searchController: _searchController, + searchFocusNode: _searchFocusNode, + onChanged: _onSearchChanged, + onClose: _toggleSearch, + ) + else if (!widget.isReadlater) + ChatFlowCategoryBar( + conversationId: widget.conversationId, + showAll: _showAllCategories, + ), + ChatFlowPendingAttachments( + conversationId: widget.conversationId, + ), + Expanded( + child: ChatFlowMessageList( + conversationId: widget.conversationId, + isReadlater: widget.isReadlater, + scrollController: _scrollController, + onReplyTo: _setReplyTo, + ), + ), + ChatFlowInputBar( + conversationId: widget.conversationId, + isReadlater: widget.isReadlater, + inputController: _inputController, + focusNode: _focusNode, + replyToMessage: _replyToMessage, + onClearReply: _clearReplyTo, + onSendAll: _sendAll, + selectedQuickCategory: _selectedQuickCategory, + onCategorySelected: (cat) { + setState(() => _selectedQuickCategory = cat); + }, + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/inspiration/presentation/chat_settings_page.dart b/lib/features/inspiration/presentation/pages/chat/chat_settings_page.dart similarity index 100% rename from lib/features/inspiration/presentation/chat_settings_page.dart rename to lib/features/inspiration/presentation/pages/chat/chat_settings_page.dart diff --git a/lib/features/inspiration/presentation/hidden_sessions_page.dart b/lib/features/inspiration/presentation/pages/chat/hidden_sessions_page.dart similarity index 100% rename from lib/features/inspiration/presentation/hidden_sessions_page.dart rename to lib/features/inspiration/presentation/pages/chat/hidden_sessions_page.dart diff --git a/lib/features/inspiration/presentation/pages/document_preview_page.dart b/lib/features/inspiration/presentation/pages/document_preview_page.dart new file mode 100644 index 00000000..d047eab1 --- /dev/null +++ b/lib/features/inspiration/presentation/pages/document_preview_page.dart @@ -0,0 +1,475 @@ +// ============================================================ +// 闲言APP — 文档预览页面 +// 创建时间: 2026-05-15 +// 更新时间: 2026-05-15 +// 作用: 文档/文件预览页面,支持PDF渲染、文件信息展示、打开/分享/保存操作 +// 上次更新: 初始创建 — E2文档预览功能 +// ============================================================ + +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_spacing.dart'; +import 'package:xianyan/core/theme/app_typography.dart'; +import 'package:xianyan/core/theme/app_radius.dart'; +import 'package:xianyan/core/utils/logger.dart'; +import 'package:xianyan/shared/widgets/glass_container.dart'; +import 'package:xianyan/shared/widgets/share_sheet.dart'; +import 'package:xianyan/features/inspiration/models/chat_message.dart'; +import 'package:xianyan/features/inspiration/services/chat_file_service.dart'; + +class DocumentPreviewPage extends StatefulWidget { + const DocumentPreviewPage({ + super.key, + required this.filePath, + this.fileName, + this.fileSize, + this.fileType, + this.modifiedTime, + this.message, + }); + + final String filePath; + final String? fileName; + final int? fileSize; + final String? fileType; + final DateTime? modifiedTime; + final ChatMessage? message; + + @override + State createState() => _DocumentPreviewPageState(); +} + +class _DocumentPreviewPageState extends State { + bool _isLoading = true; + bool _fileExists = false; + String _absolutePath = ''; + FileStat? _fileStat; + + @override + void initState() { + super.initState(); + _initFileInfo(); + } + + Future _initFileInfo() async { + try { + String absPath = widget.filePath; + if (!widget.filePath.startsWith('http') && + !widget.filePath.startsWith('/')) { + absPath = await ChatFileService.getAbsolutePath(widget.filePath); + } + _absolutePath = absPath; + + if (!widget.filePath.startsWith('http')) { + final file = File(absPath); + _fileExists = await file.exists(); + if (_fileExists) { + _fileStat = await file.stat(); + } + } else { + _fileExists = true; + } + } catch (e) { + Log.e('文档预览: 获取文件信息失败', e); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + bool get _isImage { + final type = widget.fileType ?? ''; + return type.startsWith('image/'); + } + + String get _displayName { + return widget.fileName ?? + widget.filePath.split('/').last.split('\\').last; + } + + String get _displaySize { + final size = widget.fileSize ?? _fileStat?.size; + if (size == null) return '未知'; + if (size < 1024) return '$size B'; + if (size < 1024 * 1024) return '${(size / 1024).toStringAsFixed(1)} KB'; + return '${(size / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + String get _displayType { + final type = widget.fileType ?? ''; + if (type.contains('pdf')) return 'PDF'; + if (type.contains('word') || type.contains('doc')) return 'Word'; + if (type.contains('excel') || type.contains('sheet')) return 'Excel'; + if (type.contains('presentation') || type.contains('ppt')) return 'PPT'; + if (type.contains('zip') || type.contains('rar')) return '压缩包'; + if (type.contains('text') || type.contains('txt')) return '文本'; + if (type.startsWith('image/')) return '图片'; + if (type.startsWith('video/')) return '视频'; + if (type.startsWith('audio/')) return '音频'; + final ext = _displayName.split('.').last.toUpperCase(); + return ext.isNotEmpty ? ext : '文件'; + } + + String get _displayModifiedTime { + final time = widget.modifiedTime ?? _fileStat?.modified; + if (time == null) return '未知'; + return '${time.year}-${time.month.toString().padLeft(2, '0')}-${time.day.toString().padLeft(2, '0')} ' + '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; + } + + String get _fileEmoji { + final type = widget.fileType ?? ''; + if (type.contains('pdf')) return '📕'; + if (type.contains('word') || type.contains('doc')) return '📘'; + if (type.contains('excel') || type.contains('sheet')) return '📗'; + if (type.contains('presentation') || type.contains('ppt')) return '📙'; + if (type.contains('zip') || type.contains('rar')) return '📦'; + if (type.contains('text') || type.contains('txt')) return '📝'; + if (type.startsWith('image/')) return '🖼️'; + if (type.startsWith('video/')) return '🎬'; + if (type.startsWith('audio/')) return '🎵'; + return '📄'; + } + + @override + Widget build(BuildContext context) { + final ext = AppTheme.ext(context); + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text( + '文档预览', + style: AppTypography.title3.copyWith(color: ext.textPrimary), + ), + backgroundColor: ext.bgElevated.withValues(alpha: 0.85), + border: null, + ), + child: SafeArea( + child: _isLoading + ? Center(child: CupertinoActivityIndicator(color: ext.accent)) + : CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(AppSpacing.md), + sliver: SliverList( + delegate: SliverChildListDelegate([ + _buildFilePreview(ext), + const SizedBox(height: AppSpacing.md), + _buildFileInfoCard(ext), + const SizedBox(height: AppSpacing.md), + _buildActionButtons(ext), + const SizedBox(height: AppSpacing.xl), + ]), + ), + ), + ], + ), + ), + ); + } + + Widget _buildFilePreview(AppThemeExtension ext) { + if (!_fileExists) { + return GlassContainer( + depth: GlassDepth.elevated, + width: double.infinity, + height: 240, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_fileEmoji, style: const TextStyle(fontSize: 56)), + const SizedBox(height: AppSpacing.md), + Text( + '文件不存在或已移除', + style: AppTypography.subhead.copyWith(color: ext.textSecondary), + ), + const SizedBox(height: AppSpacing.xs), + Text( + _displayName, + style: AppTypography.caption1.copyWith(color: ext.textHint), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } + + if (_isImage && !widget.filePath.startsWith('http')) { + return GlassContainer( + depth: GlassDepth.elevated, + width: double.infinity, + padding: EdgeInsets.zero, + child: ClipRRect( + borderRadius: AppRadius.lgBorder, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 360), + child: Image.file( + File(_absolutePath), + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => _buildFilePlaceholder(ext), + ), + ), + ), + ); + } + + return _buildFilePlaceholder(ext); + } + + Widget _buildFilePlaceholder(AppThemeExtension ext) { + return GlassContainer( + depth: GlassDepth.elevated, + width: double.infinity, + height: 240, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_fileEmoji, style: const TextStyle(fontSize: 64)), + const SizedBox(height: AppSpacing.md), + Text( + _displayName, + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: AppSpacing.xs), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: ext.accent.withValues(alpha: 0.1), + borderRadius: AppRadius.pillBorder, + ), + child: Text( + _displayType, + style: AppTypography.caption1.copyWith( + color: ext.accent, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + + Widget _buildFileInfoCard(AppThemeExtension ext) { + return GlassContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '📋 文件信息', + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.md), + _buildInfoRow(ext, '📄 文件名', _displayName), + _buildInfoRow(ext, '📦 大小', _displaySize), + _buildInfoRow(ext, '🏷️ 类型', _displayType), + _buildInfoRow(ext, '🕐 修改时间', _displayModifiedTime), + if (widget.filePath.startsWith('http')) + _buildInfoRow(ext, '🔗 来源', '网络链接'), + ], + ), + ); + } + + Widget _buildInfoRow(AppThemeExtension ext, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + label, + style: AppTypography.subhead.copyWith(color: ext.textSecondary), + ), + ), + Expanded( + child: Text( + value, + style: AppTypography.subhead.copyWith(color: ext.textPrimary), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Widget _buildActionButtons(AppThemeExtension ext) { + return Column( + children: [ + SizedBox( + width: double.infinity, + child: CupertinoButton( + color: ext.accent, + borderRadius: AppRadius.lgBorder, + padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), + onPressed: _fileExists ? _openFile : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(CupertinoIcons.arrow_up_doc_fill, size: 20), + const SizedBox(width: AppSpacing.sm), + Text( + '打开文件', + style: AppTypography.callout.copyWith( + color: ext.textOnAccent, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: CupertinoButton( + color: ext.bgSecondary, + borderRadius: AppRadius.lgBorder, + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + onPressed: _fileExists ? _shareFile : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.share_up, size: 18, color: ext.accent), + const SizedBox(width: AppSpacing.xs), + Text( + '分享', + style: AppTypography.subhead.copyWith( + color: ext.accent, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + if (_isImage) ...[ + const SizedBox(width: AppSpacing.sm), + Expanded( + child: CupertinoButton( + color: ext.bgSecondary, + borderRadius: AppRadius.lgBorder, + padding: + const EdgeInsets.symmetric(vertical: AppSpacing.sm), + onPressed: _fileExists ? _saveToAlbum : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.photo_fill, size: 18, color: ext.accent), + const SizedBox(width: AppSpacing.xs), + Text( + '保存到相册', + style: AppTypography.subhead.copyWith( + color: ext.accent, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], + ], + ), + ], + ); + } + + Future _openFile() async { + try { + if (widget.filePath.startsWith('http')) { + final uri = Uri.parse(widget.filePath); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } else { + final uri = Uri.file(_absolutePath); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + Log.i('文档预览: 打开文件 $_displayName'); + } catch (e) { + Log.e('文档预览: 打开文件失败', e); + if (mounted) { + showCupertinoDialog( + context: context, + builder: (_) => CupertinoAlertDialog( + title: const Text('无法打开文件'), + content: Text('未找到可打开此类型文件的应用: $e'), + actions: [ + CupertinoDialogAction( + child: const Text('确定'), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ); + } + } + } + + void _shareFile() { + ShareSheet.show( + context: context, + data: ShareData( + text: widget.message?.text ?? _displayName, + title: '分享文档: $_displayName', + ), + ); + Log.i('文档预览: 分享文件 $_displayName'); + } + + Future _saveToAlbum() async { + try { + if (widget.filePath.startsWith('http')) { + final uri = Uri.parse(widget.filePath); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } else { + final uri = Uri.file(_absolutePath); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + Log.i('文档预览: 保存到相册 $_displayName'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('已保存到相册: $_displayName'), + duration: const Duration(seconds: 2), + ), + ); + } + } catch (e) { + Log.e('文档预览: 保存到相册失败', e); + } + } +} diff --git a/lib/features/inspiration/presentation/discover_page.dart b/lib/features/inspiration/presentation/pages/home/discover_page.dart similarity index 100% rename from lib/features/inspiration/presentation/discover_page.dart rename to lib/features/inspiration/presentation/pages/home/discover_page.dart diff --git a/lib/features/inspiration/presentation/footprint_page.dart b/lib/features/inspiration/presentation/pages/home/footprint_page.dart similarity index 91% rename from lib/features/inspiration/presentation/footprint_page.dart rename to lib/features/inspiration/presentation/pages/home/footprint_page.dart index a1937fd2..39581548 100644 --- a/lib/features/inspiration/presentation/footprint_page.dart +++ b/lib/features/inspiration/presentation/pages/home/footprint_page.dart @@ -10,15 +10,15 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../../core/theme/app_theme.dart'; -import '../../../core/theme/app_spacing.dart'; -import '../../../core/theme/app_typography.dart'; -import '../../auth/providers/auth_provider.dart'; -import '../../home/presentation/history_page.dart'; -import '../../home/presentation/providers/likes_page.dart'; -import '../../home/presentation/favorite_page.dart'; -import '../../home/presentation/providers/readlater_page.dart'; -import '../../note/presentation/note_list_page.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_spacing.dart'; +import '../../../../../core/theme/app_typography.dart'; +import '../../../../auth/providers/auth_provider.dart'; +import '../../../../home/presentation/history_page.dart'; +import '../../../../home/presentation/providers/likes_page.dart'; +import '../../../../home/presentation/favorite_page.dart'; +import '../../../../home/presentation/providers/readlater_page.dart'; +import '../../../../note/presentation/note_list_page.dart'; enum FootprintTab { history('📋 浏览'), diff --git a/lib/features/inspiration/presentation/inspiration_page.dart b/lib/features/inspiration/presentation/pages/home/inspiration_page.dart similarity index 94% rename from lib/features/inspiration/presentation/inspiration_page.dart rename to lib/features/inspiration/presentation/pages/home/inspiration_page.dart index 219fdda6..71494467 100644 --- a/lib/features/inspiration/presentation/inspiration_page.dart +++ b/lib/features/inspiration/presentation/pages/home/inspiration_page.dart @@ -1,9 +1,9 @@ // ============================================================ -// 闲言APP — 灵感页面(联系人列表样式) +// 闲言APP — 发现页面(联系人列表样式) // 创建时间: 2026-04-20 -// 更新时间: 2026-05-14 +// 更新时间: 2026-05-15 // 作用: 联系人列表布局 — 会话流/发现/足迹为独立对话条目 -// 上次更新: 顶部标题"足迹"改为"工作流" +// 上次更新: 会话流RSS订阅按钮 — 未登录提示登录,已登录提示未开放 // ============================================================ import 'dart:math' as math; @@ -21,12 +21,14 @@ import 'package:xianyan/core/theme/app_spacing.dart'; import 'package:xianyan/core/theme/app_typography.dart'; import 'package:xianyan/core/theme/app_radius.dart'; import 'package:xianyan/core/router/app_router.dart'; +import 'package:xianyan/features/auth/providers/auth_provider.dart'; import 'package:xianyan/features/inspiration/models/chat_session.dart'; import 'package:xianyan/features/inspiration/providers/chat_session_provider.dart'; import 'package:xianyan/features/inspiration/providers/tool_center_provider.dart'; -import 'package:xianyan/features/inspiration/presentation/widgets/session_row.dart'; -import 'package:xianyan/features/inspiration/presentation/widgets/session_search_bar.dart'; -import 'package:xianyan/features/inspiration/presentation/widgets/tool_panel.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/session/session_row.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/session/session_search_bar.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/tool/tool_panel.dart'; +import 'package:xianyan/shared/widgets/app_toast.dart'; import 'package:xianyan/shared/widgets/keyboard_safe_sheet.dart'; class InspirationPage extends ConsumerStatefulWidget { @@ -347,10 +349,22 @@ class _InspirationPageState extends ConsumerState { context.push(AppRoutes.footprint); break; case ChatSessionType.custom: + if (session.id == 'rss_feed') { + final isLoggedIn = ref.read(authProvider).isLoggedIn; + if (!isLoggedIn) { + AppToast.showWarning('请先登录账号'); + return; + } + AppToast.showInfo('会话流功能即将开放,敬请期待 ✨'); + return; + } if (session.route != null) { context.push(session.route!); } break; + case ChatSessionType.readlater: + context.push(AppRoutes.readlaterChat); + break; } } diff --git a/lib/features/inspiration/presentation/pages/readlater_stats_page.dart b/lib/features/inspiration/presentation/pages/readlater_stats_page.dart new file mode 100644 index 00000000..3fbc6008 --- /dev/null +++ b/lib/features/inspiration/presentation/pages/readlater_stats_page.dart @@ -0,0 +1,466 @@ +// ============================================================ +// 闲言APP — 稍后读统计页面 +// 创建时间: 2026-05-15 +// 更新时间: 2026-05-15 +// 作用: 稍后读阅读统计 — 总览/类型分布饼图/7天趋势折线图 +// 上次更新: 初始创建 — E7阅读统计功能 +// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:fl_chart/fl_chart.dart'; + +import 'package:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_spacing.dart'; +import 'package:xianyan/core/theme/app_typography.dart'; +import 'package:xianyan/shared/widgets/glass_container.dart'; +import 'package:xianyan/features/inspiration/models/chat_message.dart'; + +class ReadlaterStatsPage extends StatelessWidget { + const ReadlaterStatsPage({super.key, required this.messages}); + + final List messages; + + @override + Widget build(BuildContext context) { + final ext = AppTheme.ext(context); + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text( + '📊 阅读统计', + style: AppTypography.title3.copyWith(color: ext.textPrimary), + ), + backgroundColor: ext.bgElevated.withValues(alpha: 0.85), + border: null, + ), + child: SafeArea( + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(AppSpacing.md), + sliver: SliverList( + delegate: SliverChildListDelegate([ + _buildOverviewCards(ext), + const SizedBox(height: AppSpacing.md), + _buildTypeDistribution(ext), + const SizedBox(height: AppSpacing.md), + _buildTrendChart(ext), + const SizedBox(height: AppSpacing.xl), + ]), + ), + ), + ], + ), + ), + ); + } + + // ============================================================ + // 总览统计卡片 + // ============================================================ + + Widget _buildOverviewCards(AppThemeExtension ext) { + final total = messages.length; + final readCount = messages.where((m) => m.isRead).length; + final unreadCount = total - readCount; + + return Row( + children: [ + Expanded( + child: _StatCard( + emoji: '📬', + label: '总消息', + value: '$total', + color: ext.accent, + ext: ext, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _StatCard( + emoji: '✅', + label: '已读', + value: '$readCount', + color: ext.successColor, + ext: ext, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _StatCard( + emoji: '📩', + label: '未读', + value: '$unreadCount', + color: ext.warningColor, + ext: ext, + ), + ), + ], + ); + } + + // ============================================================ + // 类型分布饼图 + // ============================================================ + + Widget _buildTypeDistribution(AppThemeExtension ext) { + final typeStats = {}; + for (final m in messages) { + final key = _typeLabel(m.type); + typeStats[key] = (typeStats[key] ?? 0) + 1; + } + + final sorted = typeStats.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + + final total = messages.length; + + final sections = []; + final colors = [ + ext.accent, + ext.successColor, + ext.warningColor, + ext.infoColor, + ext.errorColor, + CupertinoColors.systemPurple, + ]; + + for (var i = 0; i < sorted.length; i++) { + final entry = sorted[i]; + final percent = total > 0 ? entry.value / total : 0.0; + sections.add( + PieChartSectionData( + value: entry.value.toDouble(), + title: percent > 0.05 + ? '${(percent * 100).toStringAsFixed(0)}%' + : '', + color: colors[i % colors.length], + radius: 48, + titleStyle: AppTypography.caption1.copyWith( + color: CupertinoColors.white, + fontWeight: FontWeight.w700, + ), + ), + ); + } + + return GlassContainer( + depth: GlassDepth.elevated, + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '📊 类型分布', + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.md), + if (sorted.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Text( + '暂无数据', + style: AppTypography.subhead.copyWith(color: ext.textHint), + ), + ), + ) + else + Row( + children: [ + SizedBox( + width: 160, + height: 160, + child: PieChart( + PieChartData( + sections: sections, + sectionsSpace: 2, + centerSpaceRadius: 36, + ), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: sorted.asMap().entries.map((e) { + final i = e.key; + final entry = e.value; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: colors[i % colors.length], + shape: BoxShape.circle, + ), + ), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Text( + entry.key, + style: AppTypography.caption1.copyWith( + color: ext.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '${entry.value}', + style: AppTypography.caption1.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ), + ], + ), + ); + } + + // ============================================================ + // 7天趋势折线图 + // ============================================================ + + Widget _buildTrendChart(AppThemeExtension ext) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final dailyCounts = {}; + + for (var i = 6; i >= 0; i--) { + final dayStart = today.subtract(Duration(days: i)); + final dayEnd = dayStart.add(const Duration(days: 1)); + final count = messages + .where( + (m) => + m.timestamp.isAfter(dayStart) && + m.timestamp.isBefore(dayEnd), + ) + .length; + dailyCounts[6 - i] = count; + } + + final maxCount = dailyCounts.values.fold(0, (a, b) => a > b ? a : b); + final chartMaxY = (maxCount + 2).clamp(2.0, 1000000.0).toDouble(); + + final spots = []; + for (var i = 0; i < 7; i++) { + spots.add(FlSpot(i.toDouble(), (dailyCounts[i] ?? 0).toDouble())); + } + + final dayLabels = []; + for (var i = 6; i >= 0; i--) { + final day = today.subtract(Duration(days: i)); + dayLabels.add('${day.month}/${day.day}'); + } + + return GlassContainer( + depth: GlassDepth.elevated, + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '📈 最近7天新增', + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + height: 180, + child: LineChart( + LineChartData( + gridData: FlGridData( + drawVerticalLine: false, + horizontalInterval: chartMaxY / 4, + getDrawingHorizontalLine: (value) => FlLine( + color: ext.textHint.withValues(alpha: 0.1), + strokeWidth: 0.5, + ), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles( + + ), + rightTitles: const AxisTitles( + + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + interval: chartMaxY > 4 ? (chartMaxY / 4) : 1, + getTitlesWidget: (value, _) => Text( + value.toInt().toString(), + style: AppTypography.caption2.copyWith( + color: ext.textHint, + ), + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 24, + interval: 1, + getTitlesWidget: (value, _) { + final idx = value.toInt(); + if (idx < 0 || idx >= dayLabels.length) { + return const SizedBox.shrink(); + } + return Text( + dayLabels[idx], + style: AppTypography.caption2.copyWith( + color: ext.textHint, + ), + ); + }, + ), + ), + ), + borderData: FlBorderData(show: false), + minX: 0, + maxX: 6, + minY: 0, + maxY: chartMaxY, + lineBarsData: [ + LineChartBarData( + spots: spots, + isCurved: true, + preventCurveOverShooting: true, + color: ext.accent, + barWidth: 2.5, + dotData: FlDotData( + getDotPainter: (_, __, ___, ____) => FlDotCirclePainter( + radius: 3, + color: ext.accent, + strokeWidth: 1.5, + strokeColor: ext.bgPrimary, + ), + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + ext.accent.withValues(alpha: 0.25), + ext.accent.withValues(alpha: 0.02), + ], + ), + ), + ), + ], + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (_) => ext.bgElevated, + tooltipRoundedRadius: 8, + getTooltipItems: (spots) => spots + .map( + (s) => LineTooltipItem( + '${s.y.toInt()} 条', + AppTypography.caption1.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ) + .toList(), + ), + ), + ), + ), + ), + ], + ), + ); + } + + // ============================================================ + // 类型标签映射 + // ============================================================ + + String _typeLabel(ChatMessageType type) { + return switch (type) { + ChatMessageType.sentence => '💬 句子', + ChatMessageType.link => '🔗 链接', + ChatMessageType.image => '🖼️ 图片', + ChatMessageType.video => '🎬 视频', + ChatMessageType.document => '📄 文档', + ChatMessageType.file => '📁 文件', + ChatMessageType.audio => '🎵 音频', + ChatMessageType.richText => '✏️ 富文本', + ChatMessageType.readlaterSentence => '📖 稍后读', + ChatMessageType.greeting => '👋 问候', + ChatMessageType.weather => '🌤️ 天气', + ChatMessageType.scenario => '🎭 情景', + ChatMessageType.system => '⚙️ 系统', + ChatMessageType.userMessage => '👤 用户', + }; + } +} + +// ============================================================ +// 统计卡片组件 +// ============================================================ + +class _StatCard extends StatelessWidget { + const _StatCard({ + required this.emoji, + required this.label, + required this.value, + required this.color, + required this.ext, + }); + + final String emoji; + final String label; + final String value; + final Color color; + final AppThemeExtension ext; + + @override + Widget build(BuildContext context) { + return GlassContainer( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.md, + ), + child: Column( + children: [ + Text(emoji, style: const TextStyle(fontSize: 28)), + const SizedBox(height: AppSpacing.xs), + Text( + value, + style: AppTypography.title2.copyWith( + color: color, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: AppTypography.caption1.copyWith(color: ext.textSecondary), + ), + ], + ), + ); + } +} diff --git a/lib/features/inspiration/presentation/calc_tool_page.dart b/lib/features/inspiration/presentation/pages/tool/calc_tool_page.dart similarity index 97% rename from lib/features/inspiration/presentation/calc_tool_page.dart rename to lib/features/inspiration/presentation/pages/tool/calc_tool_page.dart index 856f5073..382bad65 100644 --- a/lib/features/inspiration/presentation/calc_tool_page.dart +++ b/lib/features/inspiration/presentation/pages/tool/calc_tool_page.dart @@ -10,17 +10,17 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../../core/theme/app_theme.dart'; -import '../../../core/theme/app_spacing.dart'; -import '../../../core/theme/app_typography.dart'; -import '../../../core/theme/app_radius.dart'; -import '../../../core/utils/extensions.dart'; -import '../../../core/utils/logger.dart'; -import '../../../shared/widgets/app_toast.dart'; -import '../models/tool_item.dart'; -import '../services/tool_api_service.dart'; -import 'widgets/data_source_badge.dart'; -import 'widgets/tool_icon_helper.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_spacing.dart'; +import '../../../../../core/theme/app_typography.dart'; +import '../../../../../core/theme/app_radius.dart'; +import '../../../../../core/utils/extensions.dart'; +import '../../../../../core/utils/logger.dart'; +import '../../../../../shared/widgets/app_toast.dart'; +import '../../../models/tool_item.dart'; +import '../../../services/tool_api_service.dart'; +import '../../widgets/common/data_source_badge.dart'; +import '../../widgets/tool/tool_icon_helper.dart'; /// 单位选项 class UnitOption { diff --git a/lib/features/inspiration/presentation/china_colors_page.dart b/lib/features/inspiration/presentation/pages/tool/china_colors_page.dart similarity index 98% rename from lib/features/inspiration/presentation/china_colors_page.dart rename to lib/features/inspiration/presentation/pages/tool/china_colors_page.dart index d0d8e402..bdf1bd9e 100644 --- a/lib/features/inspiration/presentation/china_colors_page.dart +++ b/lib/features/inspiration/presentation/pages/tool/china_colors_page.dart @@ -19,15 +19,15 @@ import 'package:go_router/go_router.dart'; import 'package:gal/gal.dart'; import 'package:share_plus/share_plus.dart' as share_plus; -import '../../../core/theme/app_theme.dart'; -import '../../../core/theme/app_spacing.dart'; -import '../../../core/theme/app_typography.dart'; -import '../../../core/theme/app_radius.dart'; -import '../../../core/theme/app_shadow.dart'; -import '../../../core/utils/logger.dart'; -import '../../../shared/widgets/app_sticky_header.dart'; -import '../../../shared/widgets/app_toast.dart'; -import '../../../shared/widgets/skeleton.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_spacing.dart'; +import '../../../../../core/theme/app_typography.dart'; +import '../../../../../core/theme/app_radius.dart'; +import '../../../../../core/theme/app_shadow.dart'; +import '../../../../../core/utils/logger.dart'; +import '../../../../../shared/widgets/app_sticky_header.dart'; +import '../../../../../shared/widgets/app_toast.dart'; +import '../../../../../shared/widgets/skeleton.dart'; // ============================================================ // 数据模型 diff --git a/lib/features/inspiration/presentation/hanzi_tool_page.dart b/lib/features/inspiration/presentation/pages/tool/hanzi_tool_page.dart similarity index 96% rename from lib/features/inspiration/presentation/hanzi_tool_page.dart rename to lib/features/inspiration/presentation/pages/tool/hanzi_tool_page.dart index bc4ede5b..aa8daec1 100644 --- a/lib/features/inspiration/presentation/hanzi_tool_page.dart +++ b/lib/features/inspiration/presentation/pages/tool/hanzi_tool_page.dart @@ -13,24 +13,24 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:share_plus/share_plus.dart' as share_plus; -import '../../../core/storage/app_kv_store.dart'; -import '../../../core/storage/database/app_database.dart'; -import '../../../core/theme/app_theme.dart'; -import '../../../core/theme/app_spacing.dart'; -import '../../../core/theme/app_typography.dart'; -import '../../../core/theme/app_radius.dart'; -import '../../../core/utils/logger.dart'; -import '../../../core/utils/extensions.dart'; -import '../../../shared/widgets/app_sticky_header.dart'; -import '../../../shared/widgets/app_slidable.dart'; -import '../../../shared/widgets/skeleton.dart'; -import '../../../shared/widgets/app_toast.dart'; -import '../../../shared/widgets/app_markdown.dart'; -import '../models/hanzi_result.dart'; -import '../services/tool_api_service.dart'; -import '../models/tool_item.dart'; -import 'widgets/data_source_badge.dart'; -import 'widgets/tool_icon_helper.dart'; +import '../../../../../core/storage/app_kv_store.dart'; +import '../../../../../core/storage/database/app_database.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_spacing.dart'; +import '../../../../../core/theme/app_typography.dart'; +import '../../../../../core/theme/app_radius.dart'; +import '../../../../../core/utils/logger.dart'; +import '../../../../../core/utils/extensions.dart'; +import '../../../../../shared/widgets/app_sticky_header.dart'; +import '../../../../../shared/widgets/app_slidable.dart'; +import '../../../../../shared/widgets/skeleton.dart'; +import '../../../../../shared/widgets/app_toast.dart'; +import '../../../../../shared/widgets/app_markdown.dart'; +import '../../../models/hanzi_result.dart'; +import '../../../services/tool_api_service.dart'; +import '../../../models/tool_item.dart'; +import '../../widgets/common/data_source_badge.dart'; +import '../../widgets/tool/tool_icon_helper.dart'; /// 汉语工具类型配置 class HanziToolConfig { diff --git a/lib/features/inspiration/presentation/ocr_tool_page.dart b/lib/features/inspiration/presentation/pages/tool/ocr_tool_page.dart similarity index 96% rename from lib/features/inspiration/presentation/ocr_tool_page.dart rename to lib/features/inspiration/presentation/pages/tool/ocr_tool_page.dart index 985a37c4..44323c4f 100644 --- a/lib/features/inspiration/presentation/ocr_tool_page.dart +++ b/lib/features/inspiration/presentation/pages/tool/ocr_tool_page.dart @@ -15,14 +15,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:share_plus/share_plus.dart' as share_plus; -import '../../../core/theme/app_radius.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 '../../../shared/widgets/app_slidable.dart'; -import '../../../shared/widgets/app_toast.dart'; -import '../services/tool_api_service.dart'; +import '../../../../../core/theme/app_radius.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 '../../../../../shared/widgets/app_slidable.dart'; +import '../../../../../shared/widgets/app_toast.dart'; +import '../../../services/tool_api_service.dart'; class OcrToolPage extends ConsumerStatefulWidget { const OcrToolPage({super.key}); diff --git a/lib/features/inspiration/presentation/pinyin_tool_page.dart b/lib/features/inspiration/presentation/pages/tool/pinyin_tool_page.dart similarity index 96% rename from lib/features/inspiration/presentation/pinyin_tool_page.dart rename to lib/features/inspiration/presentation/pages/tool/pinyin_tool_page.dart index 42f346f5..b7df3f0b 100644 --- a/lib/features/inspiration/presentation/pinyin_tool_page.dart +++ b/lib/features/inspiration/presentation/pages/tool/pinyin_tool_page.dart @@ -13,12 +13,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:pinyin/pinyin.dart'; -import '../../../core/theme/app_radius.dart'; -import '../../../core/theme/app_spacing.dart'; -import '../../../core/theme/app_theme.dart'; -import '../../../core/theme/app_typography.dart'; -import '../models/tool_item.dart'; -import 'widgets/data_source_badge.dart'; +import '../../../../../core/theme/app_radius.dart'; +import '../../../../../core/theme/app_spacing.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_typography.dart'; +import '../../../models/tool_item.dart'; +import '../../widgets/common/data_source_badge.dart'; enum PinyinMode { defaultMode('默认', 'pīn yīn'), diff --git a/lib/features/inspiration/presentation/tool_detail_page.dart b/lib/features/inspiration/presentation/pages/tool_detail_page.dart similarity index 97% rename from lib/features/inspiration/presentation/tool_detail_page.dart rename to lib/features/inspiration/presentation/pages/tool_detail_page.dart index 9b44efed..2667f675 100644 --- a/lib/features/inspiration/presentation/tool_detail_page.dart +++ b/lib/features/inspiration/presentation/pages/tool_detail_page.dart @@ -10,20 +10,20 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../../core/theme/app_radius.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 '../../../shared/widgets/app_markdown.dart'; -import '../../../shared/widgets/app_popup_menu.dart'; -import '../../../shared/widgets/app_toast.dart'; -import '../models/tool_item.dart'; -import '../providers/tool_center_provider.dart'; -import 'hanzi_tool_page.dart'; -import 'calc_tool_page.dart'; -import 'widgets/data_source_badge.dart'; -import 'widgets/tool_icon_helper.dart'; +import '../../../../core/theme/app_radius.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 '../../../../shared/widgets/app_markdown.dart'; +import '../../../../shared/widgets/app_popup_menu.dart'; +import '../../../../shared/widgets/app_toast.dart'; +import '../../models/tool_item.dart'; +import '../../providers/tool_center_provider.dart'; +import 'tool/hanzi_tool_page.dart'; +import 'tool/calc_tool_page.dart'; +import '../widgets/common/data_source_badge.dart'; +import '../widgets/tool/tool_icon_helper.dart'; class ToolDetailPage extends ConsumerStatefulWidget { const ToolDetailPage({super.key, required this.toolId}); diff --git a/lib/features/inspiration/presentation/tool_list_page.dart b/lib/features/inspiration/presentation/pages/tool_list_page.dart similarity index 97% rename from lib/features/inspiration/presentation/tool_list_page.dart rename to lib/features/inspiration/presentation/pages/tool_list_page.dart index f4ff04e3..df2d6d6f 100644 --- a/lib/features/inspiration/presentation/tool_list_page.dart +++ b/lib/features/inspiration/presentation/pages/tool_list_page.dart @@ -14,20 +14,20 @@ import 'package:share_plus/share_plus.dart' as share_plus; import 'package:confetti/confetti.dart'; import 'package:flutter_animate/flutter_animate.dart'; -import '../../../core/theme/app_radius.dart'; -import '../../../core/theme/app_spacing.dart'; -import '../../../core/theme/app_theme.dart'; -import '../../../core/theme/app_typography.dart'; -import '../../../core/utils/extensions.dart'; -import '../../../core/utils/logger.dart'; -import '../../../shared/widgets/app_slidable.dart'; -import '../../../shared/widgets/app_sticky_header.dart'; -import '../../../shared/widgets/skeleton.dart'; -import '../../../shared/widgets/app_toast.dart'; -import '../models/tool_item.dart'; -import '../services/tool_api_service.dart'; -import 'widgets/data_source_badge.dart'; -import 'widgets/tool_icon_helper.dart'; +import '../../../../core/theme/app_radius.dart'; +import '../../../../core/theme/app_spacing.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../../../../core/theme/app_typography.dart'; +import '../../../../core/utils/extensions.dart'; +import '../../../../core/utils/logger.dart'; +import '../../../../shared/widgets/app_slidable.dart'; +import '../../../../shared/widgets/app_sticky_header.dart'; +import '../../../../shared/widgets/skeleton.dart'; +import '../../../../shared/widgets/app_toast.dart'; +import '../../models/tool_item.dart'; +import '../../services/tool_api_service.dart'; +import '../widgets/common/data_source_badge.dart'; +import '../widgets/tool/tool_icon_helper.dart'; class ToolListPage extends ConsumerStatefulWidget { const ToolListPage({ diff --git a/lib/features/inspiration/presentation/tool_search_page.dart b/lib/features/inspiration/presentation/pages/tool_search_page.dart similarity index 96% rename from lib/features/inspiration/presentation/tool_search_page.dart rename to lib/features/inspiration/presentation/pages/tool_search_page.dart index 3a70162c..725f6aed 100644 --- a/lib/features/inspiration/presentation/tool_search_page.dart +++ b/lib/features/inspiration/presentation/pages/tool_search_page.dart @@ -10,17 +10,17 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../../core/theme/app_radius.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/extensions.dart'; -import '../../../shared/widgets/app_slidable.dart'; -import '../models/tool_item.dart'; -import '../services/tool_api_service.dart'; -import 'widgets/data_source_badge.dart'; -import 'widgets/tool_icon_helper.dart'; +import '../../../../core/theme/app_radius.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/extensions.dart'; +import '../../../../shared/widgets/app_slidable.dart'; +import '../../models/tool_item.dart'; +import '../../services/tool_api_service.dart'; +import '../widgets/common/data_source_badge.dart'; +import '../widgets/tool/tool_icon_helper.dart'; class ToolSearchPage extends ConsumerStatefulWidget { const ToolSearchPage({ diff --git a/lib/features/inspiration/presentation/widgets/chat_animations.dart b/lib/features/inspiration/presentation/widgets/chat/chat_animations.dart similarity index 98% rename from lib/features/inspiration/presentation/widgets/chat_animations.dart rename to lib/features/inspiration/presentation/widgets/chat/chat_animations.dart index 069d85e5..bf9c17f1 100644 --- a/lib/features/inspiration/presentation/widgets/chat_animations.dart +++ b/lib/features/inspiration/presentation/widgets/chat/chat_animations.dart @@ -12,8 +12,8 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:lottie/lottie.dart'; import 'package:shimmer/shimmer.dart'; -import '../../../../core/theme/app_spacing.dart'; -import '../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_spacing.dart'; +import '../../../../../core/theme/app_theme.dart'; class SendAnimationOverlay extends StatefulWidget { const SendAnimationOverlay({super.key, required this.child}); diff --git a/lib/features/inspiration/presentation/widgets/chat/chat_flow_conversation_mixin.dart b/lib/features/inspiration/presentation/widgets/chat/chat_flow_conversation_mixin.dart new file mode 100644 index 00000000..7d253d49 --- /dev/null +++ b/lib/features/inspiration/presentation/widgets/chat/chat_flow_conversation_mixin.dart @@ -0,0 +1,142 @@ +// ============================================================ +// 闲言APP — 会话流新建对话辅助类 +// 创建时间: 2026-05-15 +// 更新时间: 2026-05-15 +// 作用: 新建对话弹窗逻辑,包含emoji选择和命名 +// 上次更新: v5.4.0 从chat_flow_page.dart拆分,改为静态辅助类 +// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_spacing.dart'; +import 'package:xianyan/core/router/app_router.dart'; +import 'package:xianyan/features/inspiration/services/chat_conversation_service.dart'; + +class ChatFlowConversationHelper { + ChatFlowConversationHelper._(); + + static Future createNewConversation( + BuildContext context, + WidgetRef ref, + ) async { + final nameController = TextEditingController(); + final emoji = ValueNotifier('💬'); + final ext = AppTheme.ext(context); + + final result = await showCupertinoDialog>( + context: context, + builder: (ctx) { + return CupertinoAlertDialog( + title: const Text('✨ 新建对话'), + content: Padding( + padding: const EdgeInsets.only(top: AppSpacing.sm), + child: Column( + children: [ + Row( + children: [ + ValueListenableBuilder( + valueListenable: emoji, + builder: (_, val, __) => GestureDetector( + onTap: () async { + final emojis = [ + '💬', + '🎯', + '💡', + '📚', + '🎮', + '🎵', + '✈️', + '🏠', + ]; + final selected = + await showCupertinoModalPopup( + context: ctx, + builder: (_) => SizedBox( + height: 200, + child: CupertinoPicker( + itemExtent: 40, + onSelectedItemChanged: (i) => + emoji.value = emojis[i], + children: emojis + .map( + (e) => Center( + child: Text( + e, + style: const TextStyle( + fontSize: 24, + ), + ), + ), + ) + .toList(), + ), + ), + ); + if (selected != null) emoji.value = selected; + }, + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: ValueListenableBuilder( + valueListenable: emoji, + builder: (_, val, __) => Text( + val, + style: const TextStyle(fontSize: 24), + ), + ), + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: CupertinoTextField( + controller: nameController, + placeholder: '对话名称', + autofocus: true, + ), + ), + ], + ), + ], + ), + ), + actions: [ + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () { + final name = nameController.text.trim(); + if (name.isEmpty) return; + Navigator.pop(ctx, {'name': name, 'emoji': emoji.value}); + }, + child: const Text('创建'), + ), + ], + ); + }, + ); + + if (result != null) { + final conv = await ChatConversationService.create( + name: result['name']!, + emoji: result['emoji'] ?? '💬', + ); + if (context.mounted) { + context.push('${AppRoutes.chatFlow}/${conv.id}'); + } + } + } +} diff --git a/lib/features/inspiration/presentation/widgets/chat/chat_flow_input_bar.dart b/lib/features/inspiration/presentation/widgets/chat/chat_flow_input_bar.dart new file mode 100644 index 00000000..5f29a0b2 --- /dev/null +++ b/lib/features/inspiration/presentation/widgets/chat/chat_flow_input_bar.dart @@ -0,0 +1,560 @@ +// ============================================================ +// 闲言APP — 会话流输入栏组件 +// 创建时间: 2026-05-15 +// 更新时间: 2026-05-15 +// 作用: 输入栏、附件按钮、发送按钮、富文本按钮、快捷分类栏 +// 上次更新: v5.4.0 从chat_flow_page.dart拆分 +// ============================================================ + +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +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:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_spacing.dart'; +import 'package:xianyan/core/theme/app_typography.dart'; +import 'package:xianyan/features/inspiration/models/chat_message.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/chat/chat_flow_top_bar.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/chat_input/rich_text_editor_sheet.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/chat_input/reply_preview_bar.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/chat_input/attachment_grid_sheet.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/chat_input/record_audio_sheet.dart'; +import 'package:xianyan/features/inspiration/providers/chat_provider.dart'; +import 'package:xianyan/features/inspiration/providers/chat_attachment_provider.dart'; +import 'package:xianyan/features/inspiration/services/chat_file_service.dart'; + +class ChatFlowInputBar extends ConsumerStatefulWidget { + const ChatFlowInputBar({ + super.key, + required this.conversationId, + required this.isReadlater, + required this.inputController, + required this.focusNode, + required this.replyToMessage, + required this.onClearReply, + required this.onSendAll, + required this.selectedQuickCategory, + required this.onCategorySelected, + }); + + final String conversationId; + final bool isReadlater; + final TextEditingController inputController; + final FocusNode focusNode; + final ChatMessage? replyToMessage; + final VoidCallback onClearReply; + final VoidCallback onSendAll; + final String? selectedQuickCategory; + final ValueChanged onCategorySelected; + + @override + ConsumerState createState() => _ChatFlowInputBarState(); +} + +class _ChatFlowInputBarState extends ConsumerState { + String _inputText = ''; + + @override + void initState() { + super.initState(); + _inputText = widget.inputController.text; + widget.inputController.addListener(_onInputChanged); + } + + @override + void dispose() { + widget.inputController.removeListener(_onInputChanged); + super.dispose(); + } + + void _onInputChanged() { + final text = widget.inputController.text; + if (text != _inputText) { + setState(() => _inputText = text); + } + } + + @override + Widget build(BuildContext context) { + final ext = AppTheme.ext(context); + final hasContent = _inputText.trim().isNotEmpty; + final attachmentNotifier = ref.read( + chatAttachmentProvider(widget.conversationId).notifier, + ); + + return Container( + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm, + AppSpacing.xs, + AppSpacing.sm, + AppSpacing.xs, + ), + decoration: BoxDecoration( + color: ext.bgCard, + border: Border( + top: BorderSide(color: ext.textHint.withValues(alpha: 0.1)), + ), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.replyToMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.xs), + child: ReplyPreviewBar( + message: widget.replyToMessage!, + ext: ext, + onCancel: widget.onClearReply, + ), + ), + Row( + children: [ + _AttachButton( + ext: ext, + onTap: () => _showAttachmentSheet(ext, attachmentNotifier), + ), + const SizedBox(width: 6), + Expanded( + child: CupertinoTextField( + controller: widget.inputController, + focusNode: widget.focusNode, + onTap: () {}, + placeholder: widget.isReadlater ? '添加链接/文字...' : '说点什么...', + placeholderStyle: AppTypography.body.copyWith( + color: ext.textHint, + ), + style: AppTypography.body.copyWith(color: ext.textPrimary), + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + maxLines: 4, + minLines: 1, + textInputAction: TextInputAction.send, + onSubmitted: (_) => widget.onSendAll(), + ), + ), + const SizedBox(width: AppSpacing.xs), + _RichTextButton( + ext: ext, + onTap: () { + RichTextEditorSheet.show( + context, + onSend: (plainText, deltaJson) { + ref + .read( + chatMessagesProvider( + widget.conversationId, + ).notifier, + ) + .sendRichTextMessage( + plainText, + richContent: deltaJson, + replyToId: widget.replyToMessage?.id, + ); + }, + ); + }, + ), + const SizedBox(width: 4), + _SendButton( + ext: ext, + hasContent: hasContent, + onTap: widget.onSendAll, + ), + ], + ), + const SizedBox(height: 4), + _QuickCategoryBar( + ext: ext, + selectedCategory: widget.selectedQuickCategory, + onCategorySelected: widget.onCategorySelected, + ), + ], + ), + ), + ); + } + + void _showAttachmentSheet( + AppThemeExtension ext, + ChatAttachmentNotifier notifier, + ) { + AttachmentGridSheet.show( + context, + items: [ + AttachmentGridItem( + emoji: '🖼️', + label: '相册', + gradientColors: [const Color(0xFF6C63FF), const Color(0xFF4ECDC4)], + onTap: () { + Navigator.pop(context); + notifier.pickImageFromGallery(); + }, + ), + AttachmentGridItem( + emoji: '📷', + label: '拍照', + gradientColors: [const Color(0xFFf093fb), const Color(0xFFf5576c)], + onTap: () { + Navigator.pop(context); + notifier.pickImageFromCamera(); + }, + ), + AttachmentGridItem( + emoji: '🎬', + label: '视频', + gradientColors: [const Color(0xFF43e97b), const Color(0xFF38f9d7)], + onTap: () { + Navigator.pop(context); + notifier.pickVideo(); + }, + ), + AttachmentGridItem( + emoji: '🎙️', + label: '录音', + gradientColors: [const Color(0xFFFF6B6B), const Color(0xFFFFB74D)], + onTap: () { + Navigator.pop(context); + RecordAudioSheet.show( + context, + onComplete: (path) { + ref + .read(chatMessagesProvider(widget.conversationId).notifier) + .sendAudio(path); + }, + ); + }, + ), + AttachmentGridItem( + emoji: '📄', + label: '文件', + gradientColors: [const Color(0xFF667eea), const Color(0xFF764ba2)], + onTap: () { + Navigator.pop(context); + notifier.pickFile(); + }, + ), + AttachmentGridItem( + emoji: '📍', + label: '位置', + gradientColors: [const Color(0xFFfa709a), const Color(0xFFfee140)], + onTap: () { + Navigator.pop(context); + }, + ), + AttachmentGridItem( + emoji: '🔗', + label: '链接', + gradientColors: [const Color(0xFFa18cd1), const Color(0xFFfbc2eb)], + onTap: () { + Navigator.pop(context); + }, + ), + AttachmentGridItem( + emoji: '✏️', + label: '富文本', + gradientColors: [const Color(0xFFffecd2), const Color(0xFFfcb69f)], + onTap: () { + Navigator.pop(context); + }, + ), + ], + ); + } +} + +class _AttachButton extends StatelessWidget { + const _AttachButton({required this.ext, required this.onTap}); + + final AppThemeExtension ext; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(50), + ), + child: Center( + child: Icon(CupertinoIcons.plus, size: 18, color: ext.textSecondary), + ), + ), + ); + } +} + +class _RichTextButton extends StatelessWidget { + const _RichTextButton({required this.ext, required this.onTap}); + + final AppThemeExtension ext; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(50), + ), + child: Center( + child: Icon( + CupertinoIcons.pencil_ellipsis_rectangle, + size: 16, + color: ext.textSecondary, + ), + ), + ), + ); + } +} + +class _SendButton extends StatelessWidget { + const _SendButton({ + required this.ext, + required this.hasContent, + required this.onTap, + }); + + final AppThemeExtension ext; + final bool hasContent; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: hasContent ? onTap : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + width: 38, + height: 38, + decoration: BoxDecoration( + gradient: hasContent + ? LinearGradient(colors: [ext.accent, ext.accentLight]) + : null, + color: hasContent ? null : ext.textHint.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(50), + boxShadow: hasContent + ? [ + BoxShadow( + color: ext.accent.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: const Icon( + CupertinoIcons.arrow_up, + color: Colors.white, + size: 18, + ), + ), + ) + .animate(target: hasContent ? 1 : 0) + .scale( + begin: const Offset(1, 1), + end: const Offset(1.05, 1.05), + duration: 200.ms, + ); + } +} + +class _QuickCategoryBar extends StatelessWidget { + const _QuickCategoryBar({ + required this.ext, + required this.selectedCategory, + required this.onCategorySelected, + }); + + final AppThemeExtension ext; + final String? selectedCategory; + final ValueChanged onCategorySelected; + + @override + Widget build(BuildContext context) { + final quickCategories = kChatCategories.skip(1).take(4).toList(); + return SizedBox( + height: 32, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: quickCategories.length, + separatorBuilder: (_, __) => const SizedBox(width: 6), + itemBuilder: (context, index) { + final entry = quickCategories[index]; + final isSelected = selectedCategory == entry.key; + return GestureDetector( + onTap: () { + onCategorySelected(isSelected ? null : entry.key); + HapticFeedback.selectionClick(); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: isSelected + ? ext.accent.withValues(alpha: 0.15) + : ext.bgSecondary, + borderRadius: BorderRadius.circular(12), + border: isSelected + ? Border.all(color: ext.accent.withValues(alpha: 0.4)) + : null, + ), + child: Center( + child: Text( + entry.value, + style: AppTypography.caption1.copyWith( + color: isSelected ? ext.accent : ext.textSecondary, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + ), + ); + }, + ), + ); + } +} + +class ChatFlowPendingAttachments extends ConsumerWidget { + const ChatFlowPendingAttachments({super.key, required this.conversationId}); + + final String conversationId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ext = AppTheme.ext(context); + final attachmentState = ref.watch(chatAttachmentProvider(conversationId)); + + if (attachmentState.pendingAttachments.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + height: 72, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: ext.bgCard, + border: Border( + top: BorderSide(color: ext.textHint.withValues(alpha: 0.1)), + bottom: BorderSide(color: ext.textHint.withValues(alpha: 0.1)), + ), + ), + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: attachmentState.pendingAttachments.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, index) { + final attachment = attachmentState.pendingAttachments[index]; + return _PendingAttachmentItem( + ext: ext, + attachment: attachment, + onLongPress: () { + ref + .read(chatAttachmentProvider(conversationId).notifier) + .removePendingAttachment(index); + }, + ); + }, + ), + ); + } +} + +class _PendingAttachmentItem extends StatelessWidget { + const _PendingAttachmentItem({ + required this.ext, + required this.attachment, + required this.onLongPress, + }); + + final AppThemeExtension ext; + final PendingAttachment attachment; + final VoidCallback onLongPress; + + @override + Widget build(BuildContext context) { + final isImage = attachment.fileType.startsWith('image/'); + return GestureDetector( + onLongPress: onLongPress, + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: ext.textHint.withValues(alpha: 0.2)), + ), + child: isImage + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _buildImagePreview(), + ) + : _buildFilePlaceholder(), + ), + ); + } + + Widget _buildFilePlaceholder() { + final isImage = attachment.fileType.startsWith('image/'); + final icon = isImage ? '🖼️' : '📄'; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(icon, style: const TextStyle(fontSize: 18)), + const SizedBox(height: 2), + Text( + attachment.fileName.length > 6 + ? '${attachment.fileName.substring(0, 6)}...' + : attachment.fileName, + style: AppTypography.caption2.copyWith(color: ext.textHint), + ), + ], + ), + ); + } + + Widget _buildImagePreview() { + final rawPath = attachment.thumbnailPath ?? attachment.filePath; + if (rawPath == null) return _buildFilePlaceholder(); + return FutureBuilder( + future: ChatFileService.getAbsolutePath(rawPath), + builder: (_, snap) { + if (snap.hasData) { + final file = File(snap.data!); + if (file.existsSync()) { + return Image.file(file, fit: BoxFit.cover, width: 56, height: 56); + } + } + return _buildFilePlaceholder(); + }, + ); + } +} diff --git a/lib/features/inspiration/presentation/widgets/chat/chat_flow_message_list.dart b/lib/features/inspiration/presentation/widgets/chat/chat_flow_message_list.dart new file mode 100644 index 00000000..f76a0d8f --- /dev/null +++ b/lib/features/inspiration/presentation/widgets/chat/chat_flow_message_list.dart @@ -0,0 +1,153 @@ +// ============================================================ +// 闲言APP — 会话流消息列表组件 +// 创建时间: 2026-05-15 +// 更新时间: 2026-05-15 +// 作用: 消息列表展示、空状态占位、搜索高亮 +// 上次更新: v5.4.0 从chat_flow_page.dart拆分 +// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_spacing.dart'; +import 'package:xianyan/core/theme/app_typography.dart'; +import 'package:xianyan/features/inspiration/models/chat_message.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/chat_bubble/chat_bubble.dart'; +import 'package:xianyan/features/inspiration/providers/chat_provider.dart'; + +class ChatFlowMessageList extends ConsumerStatefulWidget { + const ChatFlowMessageList({ + super.key, + required this.conversationId, + required this.isReadlater, + required this.scrollController, + required this.onReplyTo, + }); + + final String conversationId; + final bool isReadlater; + final ScrollController scrollController; + final ValueChanged onReplyTo; + + @override + ConsumerState createState() => + _ChatFlowMessageListState(); +} + +class _ChatFlowMessageListState extends ConsumerState { + @override + Widget build(BuildContext context) { + final chatState = ref.watch(chatMessagesProvider(widget.conversationId)); + final messages = chatState.filteredMessages; + + if (chatState.isLoading) { + return const Center(child: CupertinoActivityIndicator()); + } + + if (messages.isEmpty) { + return ChatFlowEmptyState(isReadlater: widget.isReadlater); + } + + return _buildMessageList(messages, chatState.searchQuery); + } + + Widget _buildMessageList(List messages, String searchQuery) { + return NotificationListener( + onNotification: (notification) { + if (notification is ScrollEndNotification && + widget.scrollController.position.pixels >= + widget.scrollController.position.maxScrollExtent - 100) { + ref + .read(chatMessagesProvider(widget.conversationId).notifier) + .loadMore(); + } + return false; + }, + child: ListView.builder( + controller: widget.scrollController, + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + reverse: true, + itemCount: messages.length, + itemBuilder: (context, index) { + final msg = messages[index]; + return ChatBubble( + message: msg, + ext: AppTheme.ext(context), + highlightQuery: searchQuery.isNotEmpty ? searchQuery : null, + onLike: () {}, + onFavorite: () {}, + onMake: () {}, + onShare: () {}, + onDelete: () => ref + .read(chatMessagesProvider(widget.conversationId).notifier) + .deleteMessage(msg.id), + onMarkRead: () => ref + .read(chatMessagesProvider(widget.conversationId).notifier) + .markRead(msg.id), + onEdit: (newText) => ref + .read(chatMessagesProvider(widget.conversationId).notifier) + .editMessage(msg.id, newText), + onReply: () => widget.onReplyTo(msg), + replyMessage: _findMessage(msg.replyToId, messages), + ); + }, + ), + ); + } + + ChatMessage? _findMessage(String? id, List messages) { + if (id == null) return null; + try { + return messages.firstWhere((m) => m.id == id); + } catch (_) { + return null; + } + } +} + +class ChatFlowEmptyState extends StatelessWidget { + const ChatFlowEmptyState({super.key, required this.isReadlater}); + + final bool isReadlater; + + @override + Widget build(BuildContext context) { + final ext = AppTheme.ext(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(isReadlater ? '📖' : '💬', style: const TextStyle(fontSize: 48)) + .animate(onPlay: (c) => c.repeat(reverse: true)) + .scale( + begin: const Offset(1, 1), + end: const Offset(1.1, 1.1), + duration: 1500.ms, + ) + .then() + .scale( + begin: const Offset(1.1, 1.1), + end: const Offset(1, 1), + duration: 1500.ms, + ), + const SizedBox(height: AppSpacing.md), + Text( + isReadlater ? '暂无稍后读内容' : '暂无消息', + style: AppTypography.body.copyWith(color: ext.textSecondary), + ).animate().fadeIn(duration: 600.ms), + const SizedBox(height: AppSpacing.xs), + Text( + isReadlater ? '收藏内容,稍后阅读 📖' : '发送一条消息开始对话吧', + style: AppTypography.subhead.copyWith(color: ext.textHint), + ).animate().fadeIn(duration: 600.ms, delay: 200.ms), + ], + ), + ); + } +} diff --git a/lib/features/inspiration/presentation/widgets/chat/chat_flow_readlater_mixin.dart b/lib/features/inspiration/presentation/widgets/chat/chat_flow_readlater_mixin.dart new file mode 100644 index 00000000..6dd0b8dc --- /dev/null +++ b/lib/features/inspiration/presentation/widgets/chat/chat_flow_readlater_mixin.dart @@ -0,0 +1,218 @@ +// ============================================================ +// 闲言APP — 会话流稍后读设置/导出辅助类 +// 创建时间: 2026-05-15 +// 更新时间: 2026-05-15 +// 作用: 稍后读会话的设置面板、多格式导出功能 +// 上次更新: v5.4.0 从chat_flow_page.dart拆分,改为静态辅助类 +// ============================================================ + +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import 'package:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_typography.dart'; +import 'package:xianyan/core/router/app_router.dart'; +import 'package:xianyan/features/inspiration/models/chat_message.dart'; +import 'package:xianyan/features/inspiration/providers/chat_provider.dart'; +import 'package:xianyan/shared/widgets/app_toast.dart'; + +class ChatFlowReadlaterHelper { + ChatFlowReadlaterHelper._(); + + static void showSettings( + BuildContext context, + WidgetRef ref, + String conversationId, + ) { + final ext = AppTheme.ext(context); + final chatState = ref.read(chatMessagesProvider(conversationId)); + final totalCount = chatState.messages.length; + final unreadCount = chatState.unreadCount; + final readCount = totalCount - unreadCount; + + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + context.push(AppRoutes.readLater); + }, + child: const Text('📋 稍后读列表'), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + ref + .read(chatMessagesProvider(conversationId).notifier) + .markAllAsRead(); + AppToast.showSuccess('已全部标记为已读'); + }, + child: const Text('✅ 标记全部已读'), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + showExportSheet(context, ref, conversationId); + }, + child: const Text('📤 导出内容'), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + showCupertinoDialog( + context: context, + builder: (dCtx) => CupertinoAlertDialog( + title: const Text('🗑️ 清空稍后读'), + content: Text('确定清空所有 $totalCount 条稍后读内容?此操作不可恢复。'), + actions: [ + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () => Navigator.pop(dCtx), + child: const Text('取消'), + ), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () { + Navigator.pop(dCtx); + ref + .read(chatMessagesProvider(conversationId).notifier) + .clearAllMessages(); + AppToast.showSuccess('已清空稍后读'); + }, + child: const Text('清空'), + ), + ], + ), + ); + }, + child: const Text('🗑️ 清空稍后读'), + ), + ], + message: Text( + '📊 共 $totalCount 条 · 已读 $readCount · 未读 $unreadCount', + style: AppTypography.footnote.copyWith(color: ext.textSecondary), + ), + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + ), + ); + } + + static void showExportSheet( + BuildContext context, + WidgetRef ref, + String conversationId, + ) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + exportAsJson(ref, conversationId); + }, + child: const Text('📋 导出为 JSON'), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + exportAsMarkdown(ref, conversationId); + }, + child: const Text('📝 导出为 Markdown'), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + exportAsZip(ref, conversationId); + }, + child: const Text('📦 导出为 ZIP'), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + ), + ); + } + + static void exportAsJson(WidgetRef ref, String conversationId) { + final chatState = ref.read(chatMessagesProvider(conversationId)); + final json = ChatMessage.exportToJson(chatState.messages); + Clipboard.setData(ClipboardData(text: json)); + AppToast.showSuccess('已导出 ${chatState.messages.length} 条到剪贴板 (JSON)'); + } + + static void exportAsMarkdown(WidgetRef ref, String conversationId) { + final chatState = ref.read(chatMessagesProvider(conversationId)); + final sb = StringBuffer(); + sb.writeln('# 📖 稍后读导出'); + sb.writeln('> 导出时间: ${DateTime.now().toString().substring(0, 19)}'); + sb.writeln('> 共 ${chatState.messages.length} 条'); + sb.writeln(); + for (final m in chatState.messages) { + sb.writeln('## ${m.displayTimestamp}'); + if (m.isReadlaterSentence) { + sb.writeln('> ${m.text}'); + if (m.author != null) sb.writeln('> —— ${m.author}'); + if (m.source != null) sb.writeln('> 📖 ${m.source}'); + } else if (m.isLink) { + sb.writeln( + '🔗 [${m.meta?['title'] ?? m.text}](${m.meta?['url'] ?? m.text})', + ); + } else { + sb.writeln(m.text); + } + sb.writeln(); + } + Clipboard.setData(ClipboardData(text: sb.toString())); + AppToast.showSuccess('已导出 ${chatState.messages.length} 条到剪贴板 (Markdown)'); + } + + static Future exportAsZip(WidgetRef ref, String conversationId) async { + try { + final chatState = ref.read(chatMessagesProvider(conversationId)); + final dir = await getTemporaryDirectory(); + final timestamp = DateTime.now().millisecondsSinceEpoch; + + final jsonFile = File('${dir.path}/readlater_$timestamp.json'); + jsonFile.writeAsStringSync(ChatMessage.exportToJson(chatState.messages)); + + final mdFile = File('${dir.path}/readlater_$timestamp.md'); + final sb = StringBuffer(); + sb.writeln('# 📖 稍后读导出'); + for (final m in chatState.messages) { + sb.writeln('- ${m.text}'); + } + mdFile.writeAsStringSync(sb.toString()); + + final archive = Archive(); + for (final file in [jsonFile, mdFile]) { + final bytes = file.readAsBytesSync(); + archive.addFile( + ArchiveFile(file.path.split('/').last, bytes.length, bytes), + ); + } + final zipBytes = ZipEncoder().encode(archive); + final zipFile = File('${dir.path}/readlater_$timestamp.zip'); + zipFile.writeAsBytesSync(zipBytes!); + + await Share.shareXFiles([XFile(zipFile.path)], subject: '闲言APP - 稍后读导出'); + AppToast.showSuccess('ZIP导出成功'); + } catch (e) { + AppToast.showError('导出失败: $e'); + } + } +} diff --git a/lib/features/inspiration/presentation/widgets/chat/chat_flow_send_toast.dart b/lib/features/inspiration/presentation/widgets/chat/chat_flow_send_toast.dart new file mode 100644 index 00000000..3afe4202 --- /dev/null +++ b/lib/features/inspiration/presentation/widgets/chat/chat_flow_send_toast.dart @@ -0,0 +1,121 @@ +// ============================================================ +// 闲言APP — 会话流发送Toast组件 +// 创建时间: 2026-05-15 +// 更新时间: 2026-05-15 +// 作用: 消息发送后的浮动提示Toast,支持分类显示 +// 上次更新: v5.4.0 从chat_flow_page.dart拆分 +// ============================================================ + +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import 'package:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_typography.dart'; + +class SendToastWidget extends StatefulWidget { + const SendToastWidget({super.key, this.category, required this.onClose}); + + final String? category; + final VoidCallback onClose; + + @override + State createState() => _SendToastWidgetState(); +} + +class _SendToastWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _opacity; + late Animation _slide; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + ); + _opacity = Tween( + begin: 0, + end: 1, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + _slide = Tween( + begin: const Offset(0, -0.5), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + _controller.forward(); + Future.delayed(const Duration(milliseconds: 1400), () { + if (mounted) _controller.reverse().then((_) => widget.onClose()); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ext = AppTheme.ext(context); + final catEmoji = widget.category != null + ? _categoryEmoji(widget.category!) + : ''; + final label = widget.category != null + ? '$catEmoji 已发送至 ${widget.category!}' + : '✈️ 已发送'; + + return SlideTransition( + position: _slide, + child: FadeTransition( + opacity: _opacity, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: ext.bgCard.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: ext.accent.withValues(alpha: 0.2)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: AppTypography.subhead.copyWith( + color: ext.textPrimary, + fontSize: 13, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + String _categoryEmoji(String category) { + return switch (category) { + 'hot' => '🔥', + 'love' => '💕', + 'nature' => '🌿', + 'motivate' => '💪', + 'literature' => '📖', + 'movie' => '🎬', + _ => '🏷️', + }; + } +} diff --git a/lib/features/inspiration/presentation/widgets/chat/chat_flow_top_bar.dart b/lib/features/inspiration/presentation/widgets/chat/chat_flow_top_bar.dart new file mode 100644 index 00000000..460f7865 --- /dev/null +++ b/lib/features/inspiration/presentation/widgets/chat/chat_flow_top_bar.dart @@ -0,0 +1,286 @@ +// ============================================================ +// 闲言APP — 会话流顶部区域组件 +// 创建时间: 2026-05-15 +// 更新时间: 2026-05-15 +// 作用: 搜索栏、分类栏、背景图、动态主题等顶部UI组件 +// 上次更新: v5.4.0 从chat_flow_page.dart拆分 +// ============================================================ + +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_spacing.dart'; +import 'package:xianyan/core/theme/app_typography.dart'; +import 'package:xianyan/features/inspiration/providers/chat_provider.dart'; +import 'package:xianyan/features/inspiration/services/chat_file_service.dart'; + +const kChatCategories = >[ + MapEntry('all', '📋 全部'), + MapEntry('hot', '🔥 热门'), + MapEntry('love', '💕 爱情'), + MapEntry('nature', '🌿 自然'), + MapEntry('motivate', '💪 励志'), + MapEntry('literature', '📖 文学'), + MapEntry('movie', '🎬 影视'), +]; + +class ChatFlowSearchBar extends ConsumerWidget { + const ChatFlowSearchBar({ + super.key, + required this.conversationId, + required this.searchController, + required this.searchFocusNode, + required this.onChanged, + required this.onClose, + }); + + final String conversationId; + final TextEditingController searchController; + final FocusNode searchFocusNode; + final ValueChanged onChanged; + final VoidCallback onClose; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ext = AppTheme.ext(context); + final chatState = ref.watch(chatMessagesProvider(conversationId)); + final resultCount = chatState.filteredMessages.length; + final totalCount = chatState.messages.length; + final isSearching = chatState.isSearching; + + return Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), + child: Row( + children: [ + Expanded( + child: CupertinoSearchTextField( + controller: searchController, + focusNode: searchFocusNode, + onChanged: onChanged, + onSubmitted: (_) {}, + placeholder: '🔍 搜索消息内容、作者、链接...', + style: AppTypography.body.copyWith(color: ext.textPrimary), + placeholderStyle: AppTypography.body.copyWith( + color: ext.textHint, + ), + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(10), + ), + itemColor: ext.textSecondary, + itemSize: 16, + onSuffixTap: () { + searchController.clear(); + ref + .read(chatMessagesProvider(conversationId).notifier) + .clearSearch(); + }, + ), + ), + if (isSearching) ...[ + const SizedBox(width: AppSpacing.sm), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: ext.accent.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$resultCount/$totalCount', + style: AppTypography.caption1.copyWith( + color: ext.accent, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + const SizedBox(width: AppSpacing.xs), + GestureDetector( + onTap: onClose, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Icon( + CupertinoIcons.xmark, + size: 14, + color: ext.textSecondary, + ), + ), + ), + ), + ], + ), + ); + } +} + +class ChatFlowCategoryBar extends ConsumerWidget { + const ChatFlowCategoryBar({ + super.key, + required this.conversationId, + required this.showAll, + }); + + final String conversationId; + final bool showAll; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ext = AppTheme.ext(context); + final selected = ref + .watch(chatMessagesProvider(conversationId)) + .selectedCategory; + final displayCategories = + showAll ? kChatCategories : kChatCategories.take(3).toList(); + final hasMore = kChatCategories.length > 3; + + return Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm), + child: Row( + children: [ + Expanded( + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: displayCategories.length, + separatorBuilder: (_, __) => const SizedBox(width: 6), + itemBuilder: (context, index) { + final entry = displayCategories[index]; + final isSelected = + (entry.key == 'all' && selected == null) || + entry.key == selected; + return GestureDetector( + onTap: () { + ref + .read(chatMessagesProvider(conversationId).notifier) + .selectCategory(entry.key == 'all' ? null : entry.key); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 6, + ), + decoration: BoxDecoration( + color: isSelected + ? ext.accent.withValues(alpha: 0.15) + : ext.bgCard, + borderRadius: BorderRadius.circular(20), + border: isSelected + ? Border.all(color: ext.accent.withValues(alpha: 0.3)) + : null, + ), + child: Center( + child: Text( + entry.value, + style: AppTypography.subhead.copyWith( + fontWeight: + isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? ext.accent : ext.textSecondary, + ), + ), + ), + ), + ); + }, + ), + ), + if (hasMore) + GestureDetector( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: ext.bgCard, + borderRadius: BorderRadius.circular(20), + ), + child: Center( + child: Text( + showAll ? '收起 ▲' : '展开 ▼', + style: AppTypography.caption1.copyWith( + color: ext.textSecondary, + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class ChatFlowBackgroundImage extends StatelessWidget { + const ChatFlowBackgroundImage({super.key, required this.path}); + + final String path; + + @override + Widget build(BuildContext context) { + final ext = AppTheme.ext(context); + return Positioned.fill( + child: FutureBuilder( + future: ChatFileService.getAbsolutePath(path), + builder: (_, snap) { + if (snap.hasData) { + final file = File(snap.data!); + if (file.existsSync()) { + return Stack( + fit: StackFit.expand, + children: [ + Image.file( + file, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + Container(color: ext.bgPrimary.withValues(alpha: 0.35)), + ], + ); + } + } + return const SizedBox.shrink(); + }, + ), + ); + } +} + +AppThemeExtension buildDynamicTheme( + AppThemeExtension base, + Map settings, +) { + Color? accentOverride; + if (settings.containsKey('accentColor')) { + try { + final hex = settings['accentColor'] as String; + accentOverride = _hexToColor(hex); + } catch (_) {} + } + + if (accentOverride == null) return base; + + final lighter = Color.lerp(accentOverride, Colors.white, 0.2)!; + return base.copyWith(accent: accentOverride, accentLight: lighter); +} + +Color _hexToColor(String hex) { + final buffer = StringBuffer(); + if (hex.length == 6) buffer.write('ff'); + buffer.write(hex.replaceFirst('#', '')); + return Color(int.parse(buffer.toString(), radix: 16)); +} diff --git a/lib/features/inspiration/presentation/widgets/chat_rich_bubble.dart b/lib/features/inspiration/presentation/widgets/chat/chat_rich_bubble.dart similarity index 96% rename from lib/features/inspiration/presentation/widgets/chat_rich_bubble.dart rename to lib/features/inspiration/presentation/widgets/chat/chat_rich_bubble.dart index 15ce4f09..8faf2524 100644 --- a/lib/features/inspiration/presentation/widgets/chat_rich_bubble.dart +++ b/lib/features/inspiration/presentation/widgets/chat/chat_rich_bubble.dart @@ -11,10 +11,10 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; -import '../../../../core/theme/app_spacing.dart'; -import '../../../../core/theme/app_theme.dart'; -import '../../../../core/theme/app_typography.dart'; -import '../../models/chat_message.dart'; +import '../../../../../core/theme/app_spacing.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_typography.dart'; +import '../../../models/chat_message.dart'; class ChatRichBubble extends StatelessWidget { const ChatRichBubble({ diff --git a/lib/features/inspiration/presentation/widgets/chat_audio_bubble.dart b/lib/features/inspiration/presentation/widgets/chat_bubble/chat_audio_bubble.dart similarity index 93% rename from lib/features/inspiration/presentation/widgets/chat_audio_bubble.dart rename to lib/features/inspiration/presentation/widgets/chat_bubble/chat_audio_bubble.dart index 6a9c6b96..368b74c6 100644 --- a/lib/features/inspiration/presentation/widgets/chat_audio_bubble.dart +++ b/lib/features/inspiration/presentation/widgets/chat_bubble/chat_audio_bubble.dart @@ -10,13 +10,13 @@ import 'dart:math'; import 'package:flutter/cupertino.dart'; -import '../../../../core/theme/app_radius.dart'; -import '../../../../core/theme/app_spacing.dart'; -import '../../../../core/theme/app_theme.dart'; -import '../../../../core/theme/app_typography.dart'; -import '../../models/chat_message.dart'; -import '../../services/chat_audio_service.dart'; -import '../../services/chat_file_service.dart'; +import '../../../../../core/theme/app_radius.dart'; +import '../../../../../core/theme/app_spacing.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_typography.dart'; +import '../../../models/chat_message.dart'; +import '../../../services/chat_audio_service.dart'; +import '../../../services/chat_file_service.dart'; class ChatAudioBubble extends StatefulWidget { const ChatAudioBubble({ diff --git a/lib/features/inspiration/presentation/widgets/chat_bubble.dart b/lib/features/inspiration/presentation/widgets/chat_bubble/chat_bubble.dart similarity index 89% rename from lib/features/inspiration/presentation/widgets/chat_bubble.dart rename to lib/features/inspiration/presentation/widgets/chat_bubble/chat_bubble.dart index 023c32ff..0a5f0ac5 100644 --- a/lib/features/inspiration/presentation/widgets/chat_bubble.dart +++ b/lib/features/inspiration/presentation/widgets/chat_bubble/chat_bubble.dart @@ -25,8 +25,11 @@ import 'chat_audio_bubble.dart'; import 'chat_file_bubble.dart'; import 'chat_image_bubble.dart'; import 'chat_video_bubble.dart'; -import 'reply_preview_bar.dart'; -import 'chat_rich_bubble.dart'; +import 'chat_link_bubble.dart'; +import 'chat_document_bubble.dart'; +import 'chat_sentence_card_bubble.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/chat_input/reply_preview_bar.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/chat/chat_rich_bubble.dart'; class ChatBubble extends StatefulWidget { const ChatBubble({ @@ -41,7 +44,9 @@ class ChatBubble extends StatefulWidget { this.onMarkRead, this.onEdit, this.onReply, + this.onTapSentence, this.replyMessage, + this.highlightQuery, }); final ChatMessage message; @@ -54,7 +59,9 @@ class ChatBubble extends StatefulWidget { final VoidCallback? onMarkRead; final ValueChanged? onEdit; final VoidCallback? onReply; + final VoidCallback? onTapSentence; final ChatMessage? replyMessage; + final String? highlightQuery; @override State createState() => _ChatBubbleState(); @@ -360,11 +367,23 @@ class _ChatBubbleState extends State { ChatAudioBubble(message: message, ext: ext, isMe: false) else if (message.isFile) ChatFileBubble(message: message, ext: ext) + else if (message.isLink) + ChatLinkBubble(message: message, ext: ext) + else if (message.isDocument) + ChatDocumentBubble(message: message, ext: ext) + else if (message.isReadlaterSentence) + ChatSentenceCardBubble( + message: message, + ext: ext, + onTapSentence: widget.onTapSentence, + onShare: widget.onShare, + onMarkRead: widget.onMarkRead, + ) else ...[ _buildAttachmentPreview(isUser: false), - Text( + _buildHighlightText( message.text, - style: TextStyle( + baseStyle: TextStyle( fontSize: 15, height: 1.65, color: isGradient ? Colors.white : ext.textPrimary, @@ -451,18 +470,68 @@ class _ChatBubbleState extends State { ChatFileBubble(message: message, ext: ext) else if (message.isRichText) ChatRichBubble(message: message, ext: ext, isMe: isUser) + else if (message.isLink) + ChatLinkBubble(message: message, ext: ext) + else if (message.isDocument) + ChatDocumentBubble(message: message, ext: ext) + else if (message.isReadlaterSentence) + ChatSentenceCardBubble( + message: message, + ext: ext, + onTapSentence: widget.onTapSentence, + onShare: widget.onShare, + onMarkRead: widget.onMarkRead, + ) else ...[ _buildAttachmentPreview(isUser: isUser), if (message.text.isNotEmpty) - Text( + _buildHighlightText( message.text, - style: TextStyle(fontSize: 15, color: textColor, height: 1.6), + baseStyle: TextStyle(fontSize: 15, color: textColor, height: 1.6), ), ], ], ); } + Widget _buildHighlightText(String text, {required TextStyle baseStyle}) { + final query = widget.highlightQuery; + if (query == null || query.isEmpty) { + return Text(text, style: baseStyle); + } + + final lowerText = text.toLowerCase(); + final lowerQuery = query.toLowerCase(); + final spans = []; + int start = 0; + + while (start < text.length) { + final index = lowerText.indexOf(lowerQuery, start); + if (index == -1) { + spans.add(TextSpan(text: text.substring(start), style: baseStyle)); + break; + } + if (index > start) { + spans.add( + TextSpan(text: text.substring(start, index), style: baseStyle), + ); + } + spans.add( + TextSpan( + text: text.substring(index, index + query.length), + style: baseStyle.copyWith( + backgroundColor: ext.accent.withValues(alpha: 0.25), + color: ext.accent, + fontWeight: FontWeight.w600, + ), + ), + ); + start = index + query.length; + } + + return RichText(text: TextSpan(children: spans)); + } + Widget _buildAttachmentPreview({required bool isUser}) { final attachments = message.attachments; if (attachments.isEmpty) return const SizedBox.shrink(); diff --git a/lib/features/inspiration/presentation/widgets/chat_bubble/chat_document_bubble.dart b/lib/features/inspiration/presentation/widgets/chat_bubble/chat_document_bubble.dart new file mode 100644 index 00000000..22fc3840 --- /dev/null +++ b/lib/features/inspiration/presentation/widgets/chat_bubble/chat_document_bubble.dart @@ -0,0 +1,295 @@ +/// ============================================================ +/// 闲言APP — 文档卡片气泡 +/// 创建时间: 2026-05-15 +/// 更新时间: 2026-05-15 +/// 作用: ChatFlowPage中显示文档消息的卡片气泡组件 +/// 上次更新: 初始创建 +/// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../../core/theme/app_radius.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 '../../../../../shared/widgets/share_sheet.dart'; +import '../../../models/chat_message.dart'; +import '../../../services/chat_file_service.dart'; + +// ============================================================ +// 文档类型配置 +// ============================================================ + +class _DocTypeConfig { + const _DocTypeConfig({ + required this.emoji, + required this.color, + required this.label, + }); + + final String emoji; + final Color color; + final String label; +} + +_DocTypeConfig _getDocTypeConfig(String fileType) { + if (fileType.contains('pdf')) { + return const _DocTypeConfig( + emoji: '📕', + color: Color(0xFFFF3B30), + label: 'PDF', + ); + } + if (fileType.contains('word') || fileType.contains('doc')) { + return const _DocTypeConfig( + emoji: '📘', + color: Color(0xFF007AFF), + label: 'Word', + ); + } + if (fileType.contains('excel') || fileType.contains('sheet')) { + return const _DocTypeConfig( + emoji: '📗', + color: Color(0xFF34C759), + label: 'Excel', + ); + } + if (fileType.contains('presentation') || fileType.contains('ppt')) { + return const _DocTypeConfig( + emoji: '📙', + color: Color(0xFFFF9500), + label: 'PPT', + ); + } + if (fileType.contains('zip') || fileType.contains('rar') || + fileType.contains('7z') || fileType.contains('tar')) { + return const _DocTypeConfig( + emoji: '📦', + color: Color(0xFFAF52DE), + label: '压缩包', + ); + } + if (fileType.contains('text') || fileType.contains('txt')) { + return const _DocTypeConfig( + emoji: '📝', + color: Color(0xFF8E8E93), + label: 'TXT', + ); + } + return const _DocTypeConfig( + emoji: '📄', + color: Color(0xFF8E8E93), + label: '文件', + ); +} + +// ============================================================ +// 文档卡片气泡 +// ============================================================ + +class ChatDocumentBubble extends StatelessWidget { + const ChatDocumentBubble({ + super.key, + required this.message, + required this.ext, + }); + + final ChatMessage message; + final AppThemeExtension ext; + + @override + Widget build(BuildContext context) { + final attachment = message.attachments.firstOrNull; + final fileName = attachment?.fileName ?? message.text; + final fileSize = attachment?.displaySize ?? ''; + final fileType = attachment?.fileType ?? ''; + final config = _getDocTypeConfig(fileType); + + return Container( + padding: const EdgeInsets.all(AppSpacing.sm + 4), + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: AppRadius.lgBorder, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 文件信息行 + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 左侧emoji图标 + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: config.color.withValues(alpha: 0.1), + borderRadius: AppRadius.mdBorder, + ), + child: Center( + child: Text( + config.emoji, + style: const TextStyle(fontSize: 26), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + // 右侧文件信息 + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + fileName, + style: AppTypography.subhead.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + fileSize, + style: AppTypography.caption2.copyWith( + color: ext.textHint, + ), + ), + const SizedBox(width: AppSpacing.xs), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: config.color.withValues(alpha: 0.1), + borderRadius: AppRadius.pillBorder, + ), + child: Text( + config.label, + style: AppTypography.caption2.copyWith( + color: config.color, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + // 底部操作按钮行 + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _ActionButton( + emoji: '📂', + label: '打开文件', + ext: ext, + onTap: () => _openFile( + attachment?.filePath ?? message.text, + ), + ), + const SizedBox(width: AppSpacing.sm), + _ActionButton( + emoji: '↗️', + label: '分享文件', + ext: ext, + onTap: () => _shareFile(context, fileName), + ), + ], + ), + ], + ), + ); + } + + // 打开文件 + Future _openFile(String path) async { + try { + String absPath = path; + if (!path.startsWith('http') && !path.startsWith('/')) { + absPath = await ChatFileService.getAbsolutePath(path); + } + final uri = Uri.file(absPath); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } + } catch (e) { + Log.e('打开文件失败', e); + } + } + + // 分享文件 + void _shareFile(BuildContext context, String fileName) { + ShareSheet.show( + context: context, + data: ShareData( + text: message.text.isNotEmpty ? message.text : fileName, + title: '分享文档', + ), + ); + } +} + +// ============================================================ +// 操作按钮 +// ============================================================ + +class _ActionButton extends StatelessWidget { + const _ActionButton({ + required this.emoji, + required this.label, + required this.ext, + required this.onTap, + }); + + final String emoji; + final String label; + final AppThemeExtension ext; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs + 2, + ), + decoration: BoxDecoration( + color: ext.bgCard, + borderRadius: AppRadius.smBorder, + border: Border.all( + color: ext.textHint.withValues(alpha: 0.1), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(emoji, style: const TextStyle(fontSize: 14)), + const SizedBox(width: 4), + Text( + label, + style: AppTypography.caption1.copyWith( + color: ext.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/inspiration/presentation/widgets/chat_file_bubble.dart b/lib/features/inspiration/presentation/widgets/chat_bubble/chat_file_bubble.dart similarity index 90% rename from lib/features/inspiration/presentation/widgets/chat_file_bubble.dart rename to lib/features/inspiration/presentation/widgets/chat_bubble/chat_file_bubble.dart index d1f83983..e29aaa4a 100644 --- a/lib/features/inspiration/presentation/widgets/chat_file_bubble.dart +++ b/lib/features/inspiration/presentation/widgets/chat_bubble/chat_file_bubble.dart @@ -10,13 +10,13 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../../../../core/theme/app_radius.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 '../../models/chat_message.dart'; -import '../../services/chat_file_service.dart'; +import '../../../../../core/theme/app_radius.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 '../../../models/chat_message.dart'; +import '../../../services/chat_file_service.dart'; class ChatFileBubble extends StatelessWidget { const ChatFileBubble({super.key, required this.message, required this.ext}); diff --git a/lib/features/inspiration/presentation/widgets/chat_image_bubble.dart b/lib/features/inspiration/presentation/widgets/chat_bubble/chat_image_bubble.dart similarity index 93% rename from lib/features/inspiration/presentation/widgets/chat_image_bubble.dart rename to lib/features/inspiration/presentation/widgets/chat_bubble/chat_image_bubble.dart index 81e90426..197ab6fc 100644 --- a/lib/features/inspiration/presentation/widgets/chat_image_bubble.dart +++ b/lib/features/inspiration/presentation/widgets/chat_bubble/chat_image_bubble.dart @@ -11,12 +11,12 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:photo_view/photo_view.dart'; -import '../../../../core/theme/app_radius.dart'; -import '../../../../core/theme/app_spacing.dart'; -import '../../../../core/theme/app_theme.dart'; -import '../../../../core/theme/app_typography.dart'; -import '../../models/chat_message.dart'; -import '../../services/chat_file_service.dart'; +import '../../../../../core/theme/app_radius.dart'; +import '../../../../../core/theme/app_spacing.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_typography.dart'; +import '../../../models/chat_message.dart'; +import '../../../services/chat_file_service.dart'; class ChatImageBubble extends StatefulWidget { const ChatImageBubble({super.key, required this.message, required this.ext}); diff --git a/lib/features/inspiration/presentation/widgets/chat_bubble/chat_link_bubble.dart b/lib/features/inspiration/presentation/widgets/chat_bubble/chat_link_bubble.dart new file mode 100644 index 00000000..30653b75 --- /dev/null +++ b/lib/features/inspiration/presentation/widgets/chat_bubble/chat_link_bubble.dart @@ -0,0 +1,453 @@ +/// ============================================================ +/// 闲言APP — 链接预览卡片气泡 +/// 创建时间: 2026-05-15 +/// 更新时间: 2026-05-15 +/// 作用: 在ChatFlowPage中显示链接消息,含OG预览+操作按钮+异步OG抓取 +/// 上次更新: v5.3.0 新增OG元数据异步抓取,渲染后自动补全预览信息 +/// ============================================================ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:cached_network_image/cached_network_image.dart'; + +import '../../../../../core/services/network/og_metadata_service.dart'; +import '../../../../../core/theme/app_radius.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 '../../../../../shared/widgets/glass_container.dart'; +import '../../../models/chat_message.dart'; + +class ChatLinkBubble extends StatefulWidget { + const ChatLinkBubble({ + super.key, + required this.message, + required this.ext, + this.onMetaUpdated, + }); + + final ChatMessage message; + final AppThemeExtension ext; + final void Function(String messageId, Map meta)? + onMetaUpdated; + + @override + State createState() => _ChatLinkBubbleState(); +} + +class _ChatLinkBubbleState extends State { + OgMetadata? _fetchedOg; + bool _isFetching = false; + bool _hasFetched = false; + + // 从meta读取链接数据(优先使用已抓取的OG数据) + String? get _url => widget.message.meta?['url'] as String?; + String? get _title => + _fetchedOg?.title ?? widget.message.meta?['title'] as String?; + String? get _description => + _fetchedOg?.description ?? widget.message.meta?['description'] as String?; + String? get _imageUrl => + _fetchedOg?.imageUrl ?? widget.message.meta?['imageUrl'] as String?; + String? get _sourceApp => widget.message.meta?['sourceApp'] as String?; + String? get _siteName => _fetchedOg?.siteName; + + @override + void initState() { + super.initState(); + _tryFetchOg(); + } + + @override + void didUpdateWidget(covariant ChatLinkBubble oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.message.id != widget.message.id) { + _fetchedOg = null; + _hasFetched = false; + _tryFetchOg(); + } + } + + /// 异步抓取OG元数据(仅在缺少OG数据时触发) + void _tryFetchOg() { + if (_hasFetched || _isFetching) return; + + final url = _url ?? widget.message.text; + if (url.isEmpty) return; + + final hasOgTitle = widget.message.meta?['title'] != null; + final hasOgImage = widget.message.meta?['imageUrl'] != null; + final hasOgDesc = widget.message.meta?['description'] != null; + + if (hasOgTitle && hasOgImage && hasOgDesc) { + _hasFetched = true; + return; + } + + _isFetching = true; + _hasFetched = true; + + OgMetadataService.fetch(url) + .then((og) { + if (!mounted) return; + _isFetching = false; + if (og != null && og.isNotEmpty) { + setState(() => _fetchedOg = og); + _notifyMetaUpdate(og); + Log.i('OG元数据抓取成功: $url'); + } + }) + .catchError((_) { + _isFetching = false; + }); + } + + /// 通知父组件更新消息meta + void _notifyMetaUpdate(OgMetadata og) { + if (widget.onMetaUpdated == null) return; + final newMeta = { + ...?widget.message.meta, + if (og.title != null) 'title': og.title, + if (og.description != null) 'description': og.description, + if (og.imageUrl != null) 'imageUrl': og.imageUrl, + if (og.siteName != null) 'siteName': og.siteName, + }; + widget.onMetaUpdated!(widget.message.id, newMeta); + } + + @override + Widget build(BuildContext context) { + final url = _url ?? widget.message.text; + final title = _title ?? ''; + final description = _description ?? ''; + final imageUrl = _imageUrl; + final domain = _extractDomain(url); + + return GlassContainer( + depth: GlassDepth.elevated, + padding: EdgeInsets.zero, + child: Container( + decoration: BoxDecoration( + border: Border(left: BorderSide(color: widget.ext.accent, width: 3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + if (imageUrl != null && imageUrl.isNotEmpty) + _buildOgImage(imageUrl) + else if (_isFetching) + _buildLoadingPlaceholder() + else + _buildDomainAvatar(domain), + if (title.isNotEmpty) _buildTitle(title), + if (description.isNotEmpty) _buildDescription(description), + _buildDomainText(domain), + _buildActionRow(url), + ], + ), + ), + ); + } + + /// 顶部🔗图标 + "链接"标签 + Widget _buildHeader() { + final siteLabel = _siteName ?? _sourceApp; + 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( + siteLabel != null && siteLabel.isNotEmpty + ? '链接 · $siteLabel' + : '链接', + style: AppTypography.caption1.copyWith( + color: widget.ext.accent, + fontWeight: FontWeight.w600, + ), + ), + if (_isFetching) ...[ + const SizedBox(width: AppSpacing.xs), + const SizedBox( + width: 12, + height: 12, + child: CupertinoActivityIndicator(radius: 6), + ), + ], + ], + ), + ); + } + + /// OG图片区域 + Widget _buildOgImage(String imageUrl) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm + 3), + child: ClipRRect( + borderRadius: AppRadius.mdBorder, + child: CachedNetworkImage( + imageUrl: imageUrl, + height: 120, + width: double.infinity, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + height: 120, + color: widget.ext.bgSecondary, + child: const Center(child: CupertinoActivityIndicator()), + ), + errorWidget: (_, __, ___) => Container( + height: 120, + color: widget.ext.bgSecondary, + child: Center( + child: Text( + '🖼️', + style: TextStyle(fontSize: 28, color: widget.ext.textHint), + ), + ), + ), + ), + ), + ); + } + + /// 加载中占位 + Widget _buildLoadingPlaceholder() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm + 3), + child: Container( + height: 80, + decoration: BoxDecoration( + color: widget.ext.bgSecondary, + borderRadius: AppRadius.mdBorder, + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CupertinoActivityIndicator(radius: 8), + const SizedBox(height: AppSpacing.xs), + Text( + '正在获取预览...', + style: AppTypography.caption2.copyWith( + color: widget.ext.textHint, + ), + ), + ], + ), + ), + ), + ); + } + + /// 域名首字母圆形头像(无OG图时) + Widget _buildDomainAvatar(String domain) { + final letter = domain.isNotEmpty ? domain[0].toUpperCase() : '?'; + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm + 3, + AppSpacing.xs, + AppSpacing.sm, + 0, + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: widget.ext.accent.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + letter, + style: AppTypography.headline.copyWith( + color: widget.ext.accent, + fontWeight: FontWeight.w700, + fontSize: 18, + ), + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + domain, + style: AppTypography.subhead.copyWith( + color: widget.ext.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + /// 标题 + Widget _buildTitle(String title) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm + 3, + AppSpacing.sm, + AppSpacing.sm, + 0, + ), + child: Text( + title, + style: AppTypography.headline.copyWith( + color: widget.ext.textPrimary, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + } + + /// 描述 + Widget _buildDescription(String description) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm + 3, + AppSpacing.xs, + AppSpacing.sm, + 0, + ), + child: Text( + description, + style: AppTypography.subhead.copyWith(color: widget.ext.textSecondary), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + } + + /// URL域名 + Widget _buildDomainText(String domain) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm + 3, + AppSpacing.xs, + AppSpacing.sm, + 0, + ), + child: Row( + children: [ + Icon(CupertinoIcons.link, size: 12, color: widget.ext.accent), + const SizedBox(width: 4), + Expanded( + child: Text( + domain, + style: AppTypography.footnote.copyWith(color: widget.ext.accent), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + /// 底部操作按钮行 + Widget _buildActionRow(String url) { + return Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.sm + 3, + AppSpacing.sm, + AppSpacing.sm, + AppSpacing.sm, + ), + child: Row( + children: [ + _buildActionButton( + emoji: '🌐', + label: '打开链接', + onTap: () => _launchUrl(url), + ), + const SizedBox(width: AppSpacing.sm), + _buildActionButton( + emoji: '📋', + label: '复制链接', + onTap: () => _copyUrl(url), + ), + ], + ), + ); + } + + /// 操作按钮 + 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: widget.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: widget.ext.accent, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + /// 提取域名 + String _extractDomain(String url) { + try { + final uri = Uri.parse(url); + return uri.host.isNotEmpty ? uri.host : url; + } catch (_) { + return url; + } + } + + /// 打开链接 + Future _launchUrl(String url) async { + try { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } catch (e) { + Log.e('打开链接失败', e); + } + } + + /// 复制链接 + Future _copyUrl(String url) async { + try { + await Clipboard.setData(ClipboardData(text: url)); + } catch (e) { + Log.e('复制链接失败', e); + } + } +} diff --git a/lib/features/inspiration/presentation/widgets/chat_bubble/chat_sentence_card_bubble.dart b/lib/features/inspiration/presentation/widgets/chat_bubble/chat_sentence_card_bubble.dart new file mode 100644 index 00000000..af6842df --- /dev/null +++ b/lib/features/inspiration/presentation/widgets/chat_bubble/chat_sentence_card_bubble.dart @@ -0,0 +1,353 @@ +/// ============================================================ +/// 闲言APP — 稍后读句子卡片气泡 +/// 创建时间: 2026-05-15 +/// 更新时间: 2026-05-15 +/// 作用: ChatFlowPage中显示从句子详情Sheet收藏的稍后读句子卡片 +/// 上次更新: E12 新增🎨制作按钮,截图句子卡片并分享 +/// ============================================================ + +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import 'package:xianyan/core/theme/app_theme.dart'; +import 'package:xianyan/core/theme/app_spacing.dart'; +import 'package:xianyan/core/theme/app_typography.dart'; +import 'package:xianyan/core/theme/app_radius.dart'; +import 'package:xianyan/core/utils/logger.dart'; +import 'package:xianyan/features/inspiration/models/chat_message.dart'; +import 'package:xianyan/shared/widgets/app_toast.dart'; +import 'package:xianyan/shared/widgets/share_sheet.dart'; + +class ChatSentenceCardBubble extends StatelessWidget { + ChatSentenceCardBubble({ + super.key, + required this.message, + required this.ext, + this.onTapSentence, + this.onShare, + this.onMarkRead, + }); + + final ChatMessage message; + final AppThemeExtension ext; + + /// 点击句子卡片的回调 + final VoidCallback? onTapSentence; + + /// 分享回调 + final VoidCallback? onShare; + + /// 标记已读回调 + final VoidCallback? onMarkRead; + + /// 截图用的Key + final GlobalKey _cardKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + final meta = message.meta ?? {}; + final feedType = meta['feedType'] as String? ?? 'hot'; + final feedName = meta['feedName'] as String? ?? ''; + final likeCount = meta['likeCount'] as int? ?? 0; + final commentCount = meta['commentCount'] as int? ?? 0; + final favoriteCount = meta['favoriteCount'] as int? ?? 0; + final colors = _gradientColors(feedType); + + return GestureDetector( + onTap: onTapSentence, + behavior: HitTestBehavior.opaque, + child: RepaintBoundary( + key: _cardKey, + child: Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: colors, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(AppRadius.xl), + boxShadow: [ + BoxShadow( + color: colors.first.withValues(alpha: 0.35), + blurRadius: 16, + offset: const Offset(0, 6), + ), + BoxShadow( + color: colors.last.withValues(alpha: 0.15), + blurRadius: 32, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(feedName), + _buildDivider(), + _buildSentenceContent(), + if (message.author != null) _buildAuthor(), + if (message.source != null) _buildSource(), + _buildDivider(), + _buildStats(likeCount, commentCount, favoriteCount), + const SizedBox(height: AppSpacing.sm), + _buildActions(context), + ], + ), + ), + ), + ).animate().shimmer( + duration: 1200.ms, + color: Colors.white.withValues(alpha: 0.08), + ); + } + + // 顶部标签行 + Widget _buildHeader(String feedName) { + return Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: AppRadius.mdBorder, + ), + child: Text( + '💬 灵感句子', + style: AppTypography.caption1.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + if (feedName.isNotEmpty) ...[ + const SizedBox(width: AppSpacing.xs), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: AppRadius.mdBorder, + ), + child: Text( + feedName, + style: AppTypography.caption2.copyWith( + color: Colors.white.withValues(alpha: 0.9), + ), + ), + ), + ], + ], + ); + } + + // 分隔线 + Widget _buildDivider() { + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: Divider( + height: 1, + thickness: 0.5, + color: Colors.white.withValues(alpha: 0.2), + ), + ); + } + + // 句子内容 + Widget _buildSentenceContent() { + return Text( + message.text, + style: AppTypography.body.copyWith( + color: Colors.white, + height: 1.7, + ), + maxLines: 5, + overflow: TextOverflow.ellipsis, + ); + } + + // 作者行 + Widget _buildAuthor() { + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.sm), + child: Text( + '—— ${message.author!}', + style: AppTypography.footnote.copyWith( + color: Colors.white.withValues(alpha: 0.75), + ), + ), + ); + } + + // 出处行 + Widget _buildSource() { + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.xs), + child: Row( + children: [ + const Text('📖', style: TextStyle(fontSize: 12)), + const SizedBox(width: AppSpacing.xs), + Flexible( + child: Text( + message.source!, + style: AppTypography.caption1.copyWith( + color: Colors.white.withValues(alpha: 0.6), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + // 统计行 + Widget _buildStats(int likeCount, int commentCount, int favoriteCount) { + return Row( + children: [ + _statItem('❤️', likeCount), + const SizedBox(width: AppSpacing.md), + _statItem('💬', commentCount), + const SizedBox(width: AppSpacing.md), + _statItem('⭐', favoriteCount), + ], + ); + } + + Widget _statItem(String emoji, int count) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(emoji, style: const TextStyle(fontSize: 12)), + const SizedBox(width: 2), + Text( + '$count', + style: AppTypography.caption2.copyWith( + color: Colors.white.withValues(alpha: 0.7), + ), + ), + ], + ); + } + + // 操作按钮行 + Widget _buildActions(BuildContext context) { + return Row( + children: [ + _actionButton('📖 已读', () { + onTapSentence?.call(); + }), + const SizedBox(width: AppSpacing.sm), + _actionButton('🎨 制作', () { + _captureAndShare(context); + }), + const SizedBox(width: AppSpacing.sm), + _actionButton('↗️ 分享', () { + ShareSheet.show( + context: context, + data: ShareData( + text: message.text, + author: message.author, + source: message.source, + title: '分享句子', + ), + ); + }), + ], + ); + } + + Widget _actionButton(String label, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: AppRadius.mdBorder, + ), + child: Text( + label, + style: AppTypography.caption1.copyWith( + color: Colors.white.withValues(alpha: 0.9), + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } + + // 按feedType分类着色 + List _gradientColors(String feedType) { + return switch (feedType) { + 'love' => const [Color(0xFFf093fb), Color(0xFFf5576c)], + 'nature' => const [Color(0xFF43e97b), Color(0xFF38f9d7)], + 'motivate' => const [Color(0xFFFF6B6B), Color(0xFFFFB74D)], + 'movie' => const [Color(0xFFfa709a), Color(0xFFfee140)], + 'literature' => const [Color(0xFF667eea), Color(0xFF764ba2)], + _ => const [Color(0xFF6C63FF), Color(0xFF4ECDC4)], + }; + } + + /// E12: 截图句子卡片并分享 + Future _captureAndShare(BuildContext context) async { + try { + final boundary = _cardKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) { + AppToast.showError('截图失败,请重试'); + return; + } + + AppToast.showLoading(message: '正在生成卡片...'); + + final image = await boundary.toImage(pixelRatio: 3.0); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) { + AppToast.closeLoading(); + AppToast.showError('图片生成失败'); + return; + } + + final tempDir = await getTemporaryDirectory(); + final fileName = + 'xianyan_sentence_${DateTime.now().millisecondsSinceEpoch}.png'; + final file = File('${tempDir.path}/$fileName'); + await file.writeAsBytes(byteData.buffer.asUint8List()); + + AppToast.closeLoading(); + + if (!context.mounted) return; + final box = context.findRenderObject() as RenderBox?; + final origin = box != null + ? box.localToGlobal(Offset.zero) & box.size + : null; + + await Share.shareXFiles( + [XFile(file.path, mimeType: 'image/png')], + text: '${message.text}${message.author != null ? "\n—— ${message.author!}" : ""}\n\n来自「闲言」', + sharePositionOrigin: origin, + ); + + Log.i('句子卡片截图分享成功: $fileName'); + } catch (e) { + AppToast.closeLoading(); + Log.e('句子卡片截图分享失败', e); + AppToast.showError('制作失败: $e'); + } + } +} diff --git a/lib/features/inspiration/presentation/widgets/chat_video_bubble.dart b/lib/features/inspiration/presentation/widgets/chat_bubble/chat_video_bubble.dart similarity index 66% rename from lib/features/inspiration/presentation/widgets/chat_video_bubble.dart rename to lib/features/inspiration/presentation/widgets/chat_bubble/chat_video_bubble.dart index d8733cb7..2598b80d 100644 --- a/lib/features/inspiration/presentation/widgets/chat_video_bubble.dart +++ b/lib/features/inspiration/presentation/widgets/chat_bubble/chat_video_bubble.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 视频消息气泡 /// 创建时间: 2026-05-08 -/// 更新时间: 2026-05-09 -/// 作用: 视频消息气泡组件,缩略图+播放按钮+时长+分辨率+全屏播放 -/// 上次更新: 实现视频播放功能,使用video_player全屏播放 +/// 更新时间: 2026-05-15 +/// 作用: 视频消息气泡组件,缩略图+播放按钮+时长+分辨率+全屏播放+压缩保存 +/// 上次更新: E4 新增长按菜单压缩保存功能,使用video_compress+gal /// ============================================================ import 'dart:io'; @@ -11,16 +11,18 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:gal/gal.dart'; import 'package:video_compress/video_compress.dart'; import 'package:video_player/video_player.dart'; -import '../../../../core/theme/app_radius.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 '../../models/chat_message.dart'; -import '../../services/chat_file_service.dart'; +import '../../../../../core/theme/app_radius.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 '../../../../../shared/widgets/app_toast.dart'; +import '../../../models/chat_message.dart'; +import '../../../services/chat_file_service.dart'; class ChatVideoBubble extends StatefulWidget { const ChatVideoBubble({super.key, required this.message, required this.ext}); @@ -36,6 +38,8 @@ class _ChatVideoBubbleState extends State { String? _thumbnailPath; String? _absoluteVideoPath; bool _loading = true; + bool _compressing = false; + double _compressProgress = 0.0; @override void initState() { @@ -107,6 +111,7 @@ class _ChatVideoBubbleState extends State { return GestureDetector( onTap: () => _playVideo(context, videoPath), + onLongPress: () => _showContextMenu(context, videoPath), child: ClipRRect( borderRadius: BorderRadius.circular(AppRadius.md), child: Stack( @@ -118,20 +123,40 @@ class _ChatVideoBubbleState extends State { color: ext.bgSecondary, child: _buildThumbnail(ext), ), - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: ext.bgPrimary.withValues(alpha: 0.5), - shape: BoxShape.circle, - border: Border.all(color: ext.bgPrimary.withValues(alpha: 0.2)), + if (_compressing) + Container( + width: displayWidth, + height: displayHeight.clamp(120.0, 280.0), + color: ext.bgPrimary.withValues(alpha: 0.6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CupertinoActivityIndicator(color: ext.textPrimary), + const SizedBox(height: AppSpacing.xs), + Text( + '压缩中 ${(_compressProgress * 100).toInt()}%', + style: AppTypography.caption1.copyWith( + color: ext.textPrimary, + ), + ), + ], + ), ), - child: const Icon( - CupertinoIcons.play_fill, - color: CupertinoColors.white, - size: 22, + if (!_compressing) + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: ext.bgPrimary.withValues(alpha: 0.5), + shape: BoxShape.circle, + border: Border.all(color: ext.bgPrimary.withValues(alpha: 0.2)), + ), + child: const Icon( + CupertinoIcons.play_fill, + color: CupertinoColors.white, + size: 22, + ), ), - ), Positioned( bottom: AppSpacing.xs, left: AppSpacing.xs, @@ -186,6 +211,95 @@ class _ChatVideoBubbleState extends State { return '$minutes:$seconds'; } + void _showContextMenu(BuildContext context, String videoPath) { + if (_compressing) return; + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + actions: [ + CupertinoActionSheetAction( + onPressed: () { + Navigator.of(ctx).pop(); + _compressAndSave(videoPath); + }, + child: const Text('💾 压缩保存'), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.of(ctx).pop(); + _playVideo(context, videoPath); + }, + child: const Text('▶️ 播放视频'), + ), + ], + cancelButton: CupertinoActionSheetAction( + isDestructiveAction: true, + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('取消'), + ), + ), + ); + } + + /// E4: 压缩视频并保存到相册 + Future _compressAndSave(String videoPath) async { + if (_compressing) return; + + try { + final hasAccess = await Gal.hasAccess(toAlbum: true); + if (!hasAccess) { + final granted = await Gal.requestAccess(toAlbum: true); + if (!granted) { + AppToast.showWarning('需要相册权限才能保存'); + return; + } + } + + setState(() { + _compressing = true; + _compressProgress = 0.0; + }); + + VideoCompress.compressProgress$.subscribe((progress) { + if (mounted && progress >= 0) { + setState(() => _compressProgress = progress / 100); + } + }); + + final result = await VideoCompress.compressVideo( + videoPath, + quality: VideoQuality.MediumQuality, + includeAudio: true, + ); + + if (result == null || result.file == null) { + if (mounted) { + setState(() => _compressing = false); + AppToast.showError('视频压缩失败'); + } + return; + } + + final compressedPath = result.file!.path; + await Gal.putVideo(compressedPath, album: '闲言'); + + if (mounted) { + setState(() => _compressing = false); + AppToast.showSuccess('已保存到相册'); + } + + Log.i('视频压缩保存成功: $compressedPath'); + } catch (e) { + Log.e('视频压缩保存失败', e); + if (mounted) { + setState(() => _compressing = false); + AppToast.showError('压缩保存失败: $e'); + } + } finally { + VideoCompress.cancelCompression(); + } + } + void _playVideo(BuildContext context, String path) { Navigator.of(context).push( CupertinoPageRoute( diff --git a/lib/features/inspiration/presentation/widgets/attachment_grid_sheet.dart b/lib/features/inspiration/presentation/widgets/chat_input/attachment_grid_sheet.dart similarity index 94% rename from lib/features/inspiration/presentation/widgets/attachment_grid_sheet.dart rename to lib/features/inspiration/presentation/widgets/chat_input/attachment_grid_sheet.dart index c44227fe..01383f76 100644 --- a/lib/features/inspiration/presentation/widgets/attachment_grid_sheet.dart +++ b/lib/features/inspiration/presentation/widgets/chat_input/attachment_grid_sheet.dart @@ -8,10 +8,10 @@ import 'package:flutter/cupertino.dart'; -import '../../../../core/theme/app_radius.dart'; -import '../../../../core/theme/app_spacing.dart'; -import '../../../../core/theme/app_theme.dart'; -import '../../../../core/theme/app_typography.dart'; +import '../../../../../core/theme/app_radius.dart'; +import '../../../../../core/theme/app_spacing.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_typography.dart'; class AttachmentGridItem { const AttachmentGridItem({ diff --git a/lib/features/inspiration/presentation/widgets/record_audio_sheet.dart b/lib/features/inspiration/presentation/widgets/chat_input/record_audio_sheet.dart similarity index 96% rename from lib/features/inspiration/presentation/widgets/record_audio_sheet.dart rename to lib/features/inspiration/presentation/widgets/chat_input/record_audio_sheet.dart index 61403a6e..f898b924 100644 --- a/lib/features/inspiration/presentation/widgets/record_audio_sheet.dart +++ b/lib/features/inspiration/presentation/widgets/chat_input/record_audio_sheet.dart @@ -10,10 +10,10 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; -import '../../../../core/theme/app_spacing.dart'; -import '../../../../core/theme/app_theme.dart'; -import '../../../../core/theme/app_typography.dart'; -import '../../services/chat_audio_service.dart'; +import '../../../../../core/theme/app_spacing.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_typography.dart'; +import '../../../services/chat_audio_service.dart'; class RecordAudioSheet extends StatefulWidget { const RecordAudioSheet({super.key, required this.onComplete}); diff --git a/lib/features/inspiration/presentation/widgets/reply_preview_bar.dart b/lib/features/inspiration/presentation/widgets/chat_input/reply_preview_bar.dart similarity index 94% rename from lib/features/inspiration/presentation/widgets/reply_preview_bar.dart rename to lib/features/inspiration/presentation/widgets/chat_input/reply_preview_bar.dart index dead9ea9..40425910 100644 --- a/lib/features/inspiration/presentation/widgets/reply_preview_bar.dart +++ b/lib/features/inspiration/presentation/widgets/chat_input/reply_preview_bar.dart @@ -8,11 +8,11 @@ import 'package:flutter/cupertino.dart'; -import '../../../../core/theme/app_radius.dart'; -import '../../../../core/theme/app_spacing.dart'; -import '../../../../core/theme/app_theme.dart'; -import '../../../../core/theme/app_typography.dart'; -import '../../models/chat_message.dart'; +import '../../../../../core/theme/app_radius.dart'; +import '../../../../../core/theme/app_spacing.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_typography.dart'; +import '../../../models/chat_message.dart'; class ReplyPreviewBar extends StatelessWidget { const ReplyPreviewBar({ diff --git a/lib/features/inspiration/presentation/widgets/rich_text_editor_sheet.dart b/lib/features/inspiration/presentation/widgets/chat_input/rich_text_editor_sheet.dart similarity index 97% rename from lib/features/inspiration/presentation/widgets/rich_text_editor_sheet.dart rename to lib/features/inspiration/presentation/widgets/chat_input/rich_text_editor_sheet.dart index 03e7bc37..98a79789 100644 --- a/lib/features/inspiration/presentation/widgets/rich_text_editor_sheet.dart +++ b/lib/features/inspiration/presentation/widgets/chat_input/rich_text_editor_sheet.dart @@ -12,9 +12,9 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; -import '../../../../core/theme/app_spacing.dart'; -import '../../../../core/theme/app_theme.dart'; -import '../../../../core/theme/app_typography.dart'; +import '../../../../../core/theme/app_spacing.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_typography.dart'; class RichTextEditorSheet extends StatefulWidget { const RichTextEditorSheet({super.key, required this.onSend}); diff --git a/lib/features/inspiration/presentation/widgets/data_source_badge.dart b/lib/features/inspiration/presentation/widgets/common/data_source_badge.dart similarity index 97% rename from lib/features/inspiration/presentation/widgets/data_source_badge.dart rename to lib/features/inspiration/presentation/widgets/common/data_source_badge.dart index 0884b40f..641e71c4 100644 --- a/lib/features/inspiration/presentation/widgets/data_source_badge.dart +++ b/lib/features/inspiration/presentation/widgets/common/data_source_badge.dart @@ -8,9 +8,9 @@ import 'package:flutter/cupertino.dart'; -import '../../../../core/theme/app_theme.dart'; -import '../../../../core/theme/app_radius.dart'; -import '../../models/tool_item.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_radius.dart'; +import '../../../models/tool_item.dart'; class DataSourceBadge extends StatelessWidget { const DataSourceBadge({ diff --git a/lib/features/inspiration/presentation/widgets/ip_detail_sheet.dart b/lib/features/inspiration/presentation/widgets/common/ip_detail_sheet.dart similarity index 95% rename from lib/features/inspiration/presentation/widgets/ip_detail_sheet.dart rename to lib/features/inspiration/presentation/widgets/common/ip_detail_sheet.dart index bd92a5ed..185b76d1 100644 --- a/lib/features/inspiration/presentation/widgets/ip_detail_sheet.dart +++ b/lib/features/inspiration/presentation/widgets/common/ip_detail_sheet.dart @@ -11,10 +11,10 @@ import 'dart:ui'; import 'package:flutter/cupertino.dart'; -import '../../../../core/theme/app_radius.dart'; -import '../../../../core/theme/app_spacing.dart'; -import '../../../../core/theme/app_theme.dart'; -import '../../../../core/theme/app_typography.dart'; +import '../../../../../core/theme/app_radius.dart'; +import '../../../../../core/theme/app_spacing.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_typography.dart'; class IpDetailSheet extends StatelessWidget { const IpDetailSheet({super.key, required this.ipDetailJson, required this.ext}); diff --git a/lib/features/inspiration/presentation/widgets/session_popup_menu.dart b/lib/features/inspiration/presentation/widgets/session/session_popup_menu.dart similarity index 100% rename from lib/features/inspiration/presentation/widgets/session_popup_menu.dart rename to lib/features/inspiration/presentation/widgets/session/session_popup_menu.dart diff --git a/lib/features/inspiration/presentation/widgets/session_row.dart b/lib/features/inspiration/presentation/widgets/session/session_row.dart similarity index 98% rename from lib/features/inspiration/presentation/widgets/session_row.dart rename to lib/features/inspiration/presentation/widgets/session/session_row.dart index bcfb482a..ea10d675 100644 --- a/lib/features/inspiration/presentation/widgets/session_row.dart +++ b/lib/features/inspiration/presentation/widgets/session/session_row.dart @@ -16,7 +16,7 @@ import 'package:xianyan/core/theme/app_spacing.dart'; import 'package:xianyan/core/theme/app_typography.dart'; import 'package:xianyan/core/theme/app_radius.dart'; import 'package:xianyan/features/inspiration/models/chat_session.dart'; -import 'package:xianyan/features/inspiration/presentation/widgets/session_popup_menu.dart'; +import 'package:xianyan/features/inspiration/presentation/widgets/session/session_popup_menu.dart'; class SessionRow extends StatelessWidget { const SessionRow({ @@ -207,6 +207,8 @@ class SessionRow extends StatelessWidget { return CupertinoColors.systemOrange.withValues(alpha: 0.12); case ChatSessionType.footprint: return CupertinoColors.systemGreen.withValues(alpha: 0.12); + case ChatSessionType.readlater: + return const Color(0xFF6C63FF).withValues(alpha: 0.12); case ChatSessionType.custom: return ext.bgSecondary; } diff --git a/lib/features/inspiration/presentation/widgets/session_search_bar.dart b/lib/features/inspiration/presentation/widgets/session/session_search_bar.dart similarity index 99% rename from lib/features/inspiration/presentation/widgets/session_search_bar.dart rename to lib/features/inspiration/presentation/widgets/session/session_search_bar.dart index 6833c1c1..b2cfa484 100644 --- a/lib/features/inspiration/presentation/widgets/session_search_bar.dart +++ b/lib/features/inspiration/presentation/widgets/session/session_search_bar.dart @@ -450,6 +450,9 @@ class _SessionSearchBarState extends ConsumerState { case ChatSessionType.footprint: context.push(AppRoutes.footprint); break; + case ChatSessionType.readlater: + context.push(AppRoutes.readlaterChat); + break; case ChatSessionType.custom: if (session.route != null) { context.push(session.route!); diff --git a/lib/features/inspiration/presentation/widgets/tool_bottom_bar.dart b/lib/features/inspiration/presentation/widgets/tool/tool_bottom_bar.dart similarity index 98% rename from lib/features/inspiration/presentation/widgets/tool_bottom_bar.dart rename to lib/features/inspiration/presentation/widgets/tool/tool_bottom_bar.dart index 49fb5510..3d487905 100644 --- a/lib/features/inspiration/presentation/widgets/tool_bottom_bar.dart +++ b/lib/features/inspiration/presentation/widgets/tool/tool_bottom_bar.dart @@ -7,7 +7,7 @@ /// ============================================================ import 'package:flutter/cupertino.dart'; -import '../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_theme.dart'; /// 工具面板底部固定栏组件 /// 包含三个按钮:编辑布局、更多工具、设置 diff --git a/lib/features/inspiration/presentation/widgets/tool_grid_item.dart b/lib/features/inspiration/presentation/widgets/tool/tool_grid_item.dart similarity index 96% rename from lib/features/inspiration/presentation/widgets/tool_grid_item.dart rename to lib/features/inspiration/presentation/widgets/tool/tool_grid_item.dart index 1683885a..689d0937 100644 --- a/lib/features/inspiration/presentation/widgets/tool_grid_item.dart +++ b/lib/features/inspiration/presentation/widgets/tool/tool_grid_item.dart @@ -8,11 +8,11 @@ import 'package:flutter/cupertino.dart'; -import '../../../../core/theme/app_theme.dart'; -import '../../../../core/theme/app_typography.dart'; -import '../../../../core/theme/app_radius.dart'; -import '../../../../shared/widgets/app_popup_menu.dart'; -import '../../models/tool_item.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_typography.dart'; +import '../../../../../core/theme/app_radius.dart'; +import '../../../../../shared/widgets/app_popup_menu.dart'; +import '../../../models/tool_item.dart'; class ToolGridItem extends StatefulWidget { const ToolGridItem({ diff --git a/lib/features/inspiration/presentation/widgets/tool_icon_helper.dart b/lib/features/inspiration/presentation/widgets/tool/tool_icon_helper.dart similarity index 100% rename from lib/features/inspiration/presentation/widgets/tool_icon_helper.dart rename to lib/features/inspiration/presentation/widgets/tool/tool_icon_helper.dart diff --git a/lib/features/inspiration/presentation/widgets/tool_panel.dart b/lib/features/inspiration/presentation/widgets/tool/tool_panel.dart similarity index 98% rename from lib/features/inspiration/presentation/widgets/tool_panel.dart rename to lib/features/inspiration/presentation/widgets/tool/tool_panel.dart index 13a78fd7..ed98655d 100644 --- a/lib/features/inspiration/presentation/widgets/tool_panel.dart +++ b/lib/features/inspiration/presentation/widgets/tool/tool_panel.dart @@ -15,14 +15,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import '../../../../core/theme/app_theme.dart'; -import '../../../../core/utils/logger.dart'; -import '../../../../shared/widgets/app_toast.dart'; -import '../../../../shared/widgets/category_icon.dart'; -import '../../models/tool_item.dart'; -import '../../providers/tool_center_provider.dart'; -import '../hanzi_tool_page.dart'; -import '../calc_tool_page.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/utils/logger.dart'; +import '../../../../../shared/widgets/app_toast.dart'; +import '../../../../../shared/widgets/category_icon.dart'; +import '../../../models/tool_item.dart'; +import '../../../providers/tool_center_provider.dart'; +import '../../pages/tool/hanzi_tool_page.dart'; +import '../../pages/tool/calc_tool_page.dart'; import 'tool_panel_handle.dart'; import 'tool_search_bar.dart'; import 'tool_recent_section.dart'; diff --git a/lib/features/inspiration/presentation/widgets/tool_panel_handle.dart b/lib/features/inspiration/presentation/widgets/tool/tool_panel_handle.dart similarity index 97% rename from lib/features/inspiration/presentation/widgets/tool_panel_handle.dart rename to lib/features/inspiration/presentation/widgets/tool/tool_panel_handle.dart index b4f5dc25..58202927 100644 --- a/lib/features/inspiration/presentation/widgets/tool_panel_handle.dart +++ b/lib/features/inspiration/presentation/widgets/tool/tool_panel_handle.dart @@ -7,7 +7,7 @@ /// ============================================================ import 'package:flutter/cupertino.dart'; -import '../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_theme.dart'; /// 工具面板顶部拖拽手柄组件 /// 包含拖拽指示条和动态提示文字(滚动到底部/上滑关闭) diff --git a/lib/features/inspiration/presentation/widgets/tool_recent_section.dart b/lib/features/inspiration/presentation/widgets/tool/tool_recent_section.dart similarity index 96% rename from lib/features/inspiration/presentation/widgets/tool_recent_section.dart rename to lib/features/inspiration/presentation/widgets/tool/tool_recent_section.dart index 90473d08..aaa50b1e 100644 --- a/lib/features/inspiration/presentation/widgets/tool_recent_section.dart +++ b/lib/features/inspiration/presentation/widgets/tool/tool_recent_section.dart @@ -9,10 +9,10 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_animate/flutter_animate.dart'; -import '../../../../core/theme/app_theme.dart'; -import '../../../../core/theme/app_spacing.dart'; -import '../../../../core/theme/app_typography.dart'; -import '../../models/tool_item.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_spacing.dart'; +import '../../../../../core/theme/app_typography.dart'; +import '../../../models/tool_item.dart'; /// 最近使用工具区 /// diff --git a/lib/features/inspiration/presentation/widgets/tool_recommend_section.dart b/lib/features/inspiration/presentation/widgets/tool/tool_recommend_section.dart similarity index 94% rename from lib/features/inspiration/presentation/widgets/tool_recommend_section.dart rename to lib/features/inspiration/presentation/widgets/tool/tool_recommend_section.dart index 32a3ba52..b957e17c 100644 --- a/lib/features/inspiration/presentation/widgets/tool_recommend_section.dart +++ b/lib/features/inspiration/presentation/widgets/tool/tool_recommend_section.dart @@ -9,10 +9,10 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_animate/flutter_animate.dart'; -import '../../../../core/theme/app_theme.dart'; -import '../../../../core/theme/app_spacing.dart'; -import '../../../../core/theme/app_typography.dart'; -import '../../models/tool_item.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_spacing.dart'; +import '../../../../../core/theme/app_typography.dart'; +import '../../../models/tool_item.dart'; import 'tool_grid_item.dart'; /// 推荐工具区 diff --git a/lib/features/inspiration/presentation/widgets/tool_search_bar.dart b/lib/features/inspiration/presentation/widgets/tool/tool_search_bar.dart similarity index 93% rename from lib/features/inspiration/presentation/widgets/tool_search_bar.dart rename to lib/features/inspiration/presentation/widgets/tool/tool_search_bar.dart index abdad96b..38171504 100644 --- a/lib/features/inspiration/presentation/widgets/tool_search_bar.dart +++ b/lib/features/inspiration/presentation/widgets/tool/tool_search_bar.dart @@ -8,10 +8,10 @@ import 'package:flutter/cupertino.dart'; -import '../../../../core/theme/app_theme.dart'; -import '../../../../core/theme/app_spacing.dart'; -import '../../../../core/theme/app_typography.dart'; -import '../../../../core/theme/app_radius.dart'; +import '../../../../../core/theme/app_theme.dart'; +import '../../../../../core/theme/app_spacing.dart'; +import '../../../../../core/theme/app_typography.dart'; +import '../../../../../core/theme/app_radius.dart'; /// 工具搜索栏 /// @@ -95,9 +95,7 @@ class _ToolSearchBarState extends State { decoration: BoxDecoration( color: ext.bgSecondary, borderRadius: AppRadius.mdBorder, - border: Border.all( - color: ext.textHint.withValues(alpha: 0.08), - ), + border: Border.all(color: ext.textHint.withValues(alpha: 0.08)), ), child: CupertinoTextField( controller: _controller, @@ -106,9 +104,7 @@ class _ToolSearchBarState extends State { placeholderStyle: AppTypography.subhead.copyWith( color: ext.textHint, ), - style: AppTypography.subhead.copyWith( - color: ext.textPrimary, - ), + style: AppTypography.subhead.copyWith(color: ext.textPrimary), padding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, vertical: AppSpacing.sm, diff --git a/lib/features/inspiration/providers/chat_provider.dart b/lib/features/inspiration/providers/chat_provider.dart index 5d5321ae..b15ec3b6 100644 --- a/lib/features/inspiration/providers/chat_provider.dart +++ b/lib/features/inspiration/providers/chat_provider.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 会话流状态管理 // 创建时间: 2026-04-30 -// 更新时间: 2026-05-09 -// 作用: 会话流消息状态+推送调度+用户消息+Drift持久化+导入导出 -// 上次更新: 移除自动回复_generateAIResponse+修复copyWith分类null处理 +// 更新时间: 2026-05-15 +// 作用: 会话流消息状态+推送调度+用户消息+Drift持久化+导入导出+全文搜索 +// 上次更新: v5.3.0 新增searchQuery+searchMessages全文搜索 // ============================================================ import 'dart:convert'; @@ -30,6 +30,7 @@ class ChatState { this.todayDateKey = '', this.draftText = '', this.hasMore = true, + this.searchQuery = '', }); final List messages; @@ -40,14 +41,43 @@ class ChatState { final String todayDateKey; final String draftText; final bool hasMore; + final String searchQuery; List get filteredMessages { - if (selectedCategory == null) return messages; - return messages.where((m) => m.category == selectedCategory).toList(); + var result = messages; + + if (selectedCategory != null) { + result = result.where((m) => m.category == selectedCategory).toList(); + } + + if (searchQuery.isNotEmpty) { + result = _searchInMessages(result, searchQuery); + } + + return result; + } + + static List _searchInMessages( + List msgs, + String query, + ) { + final q = query.toLowerCase(); + return msgs.where((m) { + return m.text.toLowerCase().contains(q) || + (m.author?.toLowerCase().contains(q) ?? false) || + (m.source?.toLowerCase().contains(q) ?? false) || + (m.meta?['url']?.toString().toLowerCase().contains(q) ?? false) || + (m.meta?['title']?.toString().toLowerCase().contains(q) ?? false) || + (m.meta?['description']?.toString().toLowerCase().contains(q) ?? + false) || + m.attachments.any((a) => a.fileName.toLowerCase().contains(q)); + }).toList(); } bool get hasDraft => draftText.isNotEmpty; + bool get isSearching => searchQuery.isNotEmpty; + ChatState copyWith({ List? messages, int? unreadCount, @@ -57,6 +87,7 @@ class ChatState { String? todayDateKey, String? draftText, bool? hasMore, + String? searchQuery, }) { return ChatState( messages: messages ?? this.messages, @@ -69,6 +100,7 @@ class ChatState { todayDateKey: todayDateKey ?? this.todayDateKey, draftText: draftText ?? this.draftText, hasMore: hasMore ?? this.hasMore, + searchQuery: searchQuery ?? this.searchQuery, ); } } @@ -341,6 +373,98 @@ class ChatNotifier extends StateNotifier { } } + /// 发送链接消息 + Future sendLinkMessage({ + required String url, + String? title, + String? description, + String? imageUrl, + String? sourceApp, + }) async { + try { + final record = await ChatMessageService.sendLink( + conversationId: conversationId, + url: url, + title: title, + description: description, + imageUrl: imageUrl, + sourceApp: sourceApp, + ); + final msg = ChatMessage.fromDrift(record); + final newMessages = [msg, ...state.messages]; + state = state.copyWith(messages: newMessages); + Log.i('链接消息已发送: $url'); + } catch (e) { + Log.e('发送链接消息失败', e); + } + } + + /// 发送文档消息 + Future sendDocumentMessage({ + required String fileName, + required String filePath, + required String fileType, + required int fileSize, + String? category, + Map? meta, + }) async { + try { + final record = await ChatMessageService.sendDocument( + conversationId: conversationId, + fileName: fileName, + filePath: filePath, + fileType: fileType, + fileSize: fileSize, + category: category, + meta: meta, + ); + final msg = ChatMessage.fromDrift(record); + final newMessages = [msg, ...state.messages]; + state = state.copyWith(messages: newMessages); + Log.i('文档消息已发送: $fileName'); + } catch (e) { + Log.e('发送文档消息失败', e); + } + } + + /// 发送稍后读句子消息 + Future sendReadLaterSentenceMessage({ + required String text, + String? author, + String? source, + String? feedType, + String? feedName, + int? likeCount, + int? commentCount, + int? favoriteCount, + int? views, + String? sentenceId, + }) async { + try { + final record = await ChatMessageService.sendReadLaterSentence( + conversationId: conversationId, + text: text, + author: author, + source: source, + feedType: feedType, + feedName: feedName, + likeCount: likeCount, + commentCount: commentCount, + favoriteCount: favoriteCount, + views: views, + sentenceId: sentenceId, + ); + final msg = ChatMessage.fromDrift(record); + final newMessages = [msg, ...state.messages]; + state = state.copyWith(messages: newMessages); + Log.i( + '稍后读句子已发送: ${text.length > 20 ? '${text.substring(0, 20)}...' : text}', + ); + } catch (e) { + Log.e('发送稍后读句子失败', e); + } + } + /// 已阅(增加已阅次数) Future markRead(String messageId) async { try { @@ -393,6 +517,22 @@ class ChatNotifier extends StateNotifier { state = state.copyWith(selectedCategory: category); } + /// 全文搜索消息 + List searchMessages(String query) { + if (query.isEmpty) return []; + return ChatState._searchInMessages(state.messages, query); + } + + /// 设置搜索关键词(实时过滤) + void setSearchQuery(String query) { + state = state.copyWith(searchQuery: query); + } + + /// 清除搜索 + void clearSearch() { + state = state.copyWith(searchQuery: ''); + } + /// 软删除消息(移入回收站) Future deleteMessage(String messageId) async { try { diff --git a/lib/features/inspiration/providers/chat_session_provider.dart b/lib/features/inspiration/providers/chat_session_provider.dart index 868188b8..17ddbc0d 100644 --- a/lib/features/inspiration/providers/chat_session_provider.dart +++ b/lib/features/inspiration/providers/chat_session_provider.dart @@ -115,6 +115,18 @@ class ChatSessionNotifier extends StateNotifier { final now = DateTime.now(); final systemSessions = [ + ChatSession( + id: 'readlater', + type: ChatSessionType.readlater, + emoji: '📖', + name: '稍后读', + lastMessage: '收藏内容,稍后阅读', + lastTime: now, + isPinned: true, + tag: 'HOT', + subtitle: '收藏内容,稍后阅读', + route: '/readlater-chat', + ), ChatSession( id: 'discover', type: ChatSessionType.discover, @@ -266,6 +278,16 @@ class ChatSessionNotifier extends StateNotifier { subtitle: '局域网 · 蓝牙 · NFC · WebRTC · USB', route: '/file-transfer', ), + ChatSession( + id: 'rss_feed', + type: ChatSessionType.custom, + emoji: '📡', + name: '会话流', + lastMessage: '三方网页 RSS/XML 订阅', + lastTime: now, + subtitle: '三方网页 RSS/XML 订阅', + route: '/rss-feed', + ), ]; final chatSessions = await _loadChatSessionsFromDrift(); @@ -356,7 +378,8 @@ class ChatSessionNotifier extends StateNotifier { void refreshFromChat(ChatState chatState) { final now = DateTime.now(); final updated = state.sessions.map((s) { - if (s.type == ChatSessionType.chat) { + if (s.type == ChatSessionType.chat || + s.type == ChatSessionType.readlater) { return s.copyWith( lastMessage: chatState.messages.isNotEmpty ? chatState.messages.first.text diff --git a/lib/features/inspiration/providers/inspiration_provider.dart b/lib/features/inspiration/providers/inspiration_provider.dart index a5ffab35..e349dc63 100644 --- a/lib/features/inspiration/providers/inspiration_provider.dart +++ b/lib/features/inspiration/providers/inspiration_provider.dart @@ -1,8 +1,8 @@ /// ============================================================ -/// 闲言APP — 灵感页面 Provider +/// 闲言APP — 发现页面 Provider /// 创建时间: 2026-04-20 /// 更新时间: 2026-04-29 -/// 作用: 灵感页面状态管理 — 分类切换+句子数据+收藏 +/// 作用: 发现页面状态管理 — 分类切换+句子数据+收藏 /// 上次更新: InspirationCategory 增加 svgAsset 字段 /// ============================================================ @@ -10,7 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/utils/logger.dart'; -/// 灵感分类 +/// 发现分类 enum InspirationCategory { hot('🔥', '热门', 'assets/svgs/categories/hot.svg'), love('💕', '爱情', 'assets/svgs/categories/love.svg'), @@ -26,7 +26,7 @@ enum InspirationCategory { final String svgAsset; } -/// 灵感句子模型 +/// 发现句子模型 class InspirationSentence { const InspirationSentence({ required this.id, @@ -56,7 +56,7 @@ class InspirationSentence { } } -/// 灵感页面状态 +/// 发现页面状态 class InspirationState { const InspirationState({ this.selectedCategory = InspirationCategory.hot, @@ -83,24 +83,107 @@ class InspirationState { /// Mock 句子数据 const _allSentences = [ - InspirationSentence(id: '1', text: '生活不是等待暴风雨过去,而是学会在雨中翩翩起舞。', category: InspirationCategory.hot, author: '维维安·格林'), - InspirationSentence(id: '2', text: '世界上只有一种英雄主义,就是在认清生活的真相后依然热爱生活。', category: InspirationCategory.motivate, author: '罗曼·罗兰', source: '巨人三传'), - InspirationSentence(id: '3', text: '我爱你,不是因为你是谁,而是因为我在你面前可以是谁。', category: InspirationCategory.love, author: '罗伊·克里夫特'), - InspirationSentence(id: '4', text: '山有木兮木有枝,心悦君兮君不知。', category: InspirationCategory.love, author: '佚名', source: '越人歌'), - InspirationSentence(id: '5', text: '采菊东篱下,悠然见南山。', category: InspirationCategory.nature, author: '陶渊明', source: '饮酒·其五'), - InspirationSentence(id: '6', text: '人生如逆旅,我亦是行人。', category: InspirationCategory.literature, author: '苏轼', source: '临江仙'), - InspirationSentence(id: '7', text: '希望是件美好的事,也许是世间最美好的事,美好的事永不消逝。', category: InspirationCategory.movie, author: '斯蒂芬·金', source: '肖申克的救赎'), - InspirationSentence(id: '8', text: '不管前方的路有多苦,只要走的方向正确,不管多么崎岖不平,都比站在原地更接近幸福。', category: InspirationCategory.motivate, author: '宫崎骏', source: '千与千寻'), - InspirationSentence(id: '9', text: '落霞与孤鹜齐飞,秋水共长天一色。', category: InspirationCategory.nature, author: '王勃', source: '滕王阁序'), - InspirationSentence(id: '10', text: '你若安好,便是晴天。', category: InspirationCategory.love, author: '林徽因'), - InspirationSentence(id: '11', text: '人间不值得,但你值得。', category: InspirationCategory.hot, author: '李诞'), - InspirationSentence(id: '12', text: '面朝大海,春暖花开。', category: InspirationCategory.literature, author: '海子', source: '面朝大海,春暖花开'), - InspirationSentence(id: '13', text: '一个人至少拥有一个梦想,有一个理由去坚强。', category: InspirationCategory.motivate, author: '三毛'), - InspirationSentence(id: '14', text: '春风十里不如你。', category: InspirationCategory.love, author: '冯唐'), - InspirationSentence(id: '15', text: '我们一路奋战,不是为了改变世界,而是为了不让世界改变我们。', category: InspirationCategory.movie, author: '熔炉'), + InspirationSentence( + id: '1', + text: '生活不是等待暴风雨过去,而是学会在雨中翩翩起舞。', + category: InspirationCategory.hot, + author: '维维安·格林', + ), + InspirationSentence( + id: '2', + text: '世界上只有一种英雄主义,就是在认清生活的真相后依然热爱生活。', + category: InspirationCategory.motivate, + author: '罗曼·罗兰', + source: '巨人三传', + ), + InspirationSentence( + id: '3', + text: '我爱你,不是因为你是谁,而是因为我在你面前可以是谁。', + category: InspirationCategory.love, + author: '罗伊·克里夫特', + ), + InspirationSentence( + id: '4', + text: '山有木兮木有枝,心悦君兮君不知。', + category: InspirationCategory.love, + author: '佚名', + source: '越人歌', + ), + InspirationSentence( + id: '5', + text: '采菊东篱下,悠然见南山。', + category: InspirationCategory.nature, + author: '陶渊明', + source: '饮酒·其五', + ), + InspirationSentence( + id: '6', + text: '人生如逆旅,我亦是行人。', + category: InspirationCategory.literature, + author: '苏轼', + source: '临江仙', + ), + InspirationSentence( + id: '7', + text: '希望是件美好的事,也许是世间最美好的事,美好的事永不消逝。', + category: InspirationCategory.movie, + author: '斯蒂芬·金', + source: '肖申克的救赎', + ), + InspirationSentence( + id: '8', + text: '不管前方的路有多苦,只要走的方向正确,不管多么崎岖不平,都比站在原地更接近幸福。', + category: InspirationCategory.motivate, + author: '宫崎骏', + source: '千与千寻', + ), + InspirationSentence( + id: '9', + text: '落霞与孤鹜齐飞,秋水共长天一色。', + category: InspirationCategory.nature, + author: '王勃', + source: '滕王阁序', + ), + InspirationSentence( + id: '10', + text: '你若安好,便是晴天。', + category: InspirationCategory.love, + author: '林徽因', + ), + InspirationSentence( + id: '11', + text: '人间不值得,但你值得。', + category: InspirationCategory.hot, + author: '李诞', + ), + InspirationSentence( + id: '12', + text: '面朝大海,春暖花开。', + category: InspirationCategory.literature, + author: '海子', + source: '面朝大海,春暖花开', + ), + InspirationSentence( + id: '13', + text: '一个人至少拥有一个梦想,有一个理由去坚强。', + category: InspirationCategory.motivate, + author: '三毛', + ), + InspirationSentence( + id: '14', + text: '春风十里不如你。', + category: InspirationCategory.love, + author: '冯唐', + ), + InspirationSentence( + id: '15', + text: '我们一路奋战,不是为了改变世界,而是为了不让世界改变我们。', + category: InspirationCategory.movie, + author: '熔炉', + ), ]; -/// 灵感页面 Notifier +/// 发现页面 Notifier class InspirationNotifier extends StateNotifier { InspirationNotifier() : super(const InspirationState()) { _loadSentences(); @@ -138,14 +221,14 @@ class InspirationNotifier extends StateNotifier { .toList(); state = state.copyWith(sentences: filtered, isLoading: false); } catch (e) { - Log.e('灵感数据加载失败', e); + Log.e('发现数据加载失败', e); state = state.copyWith(sentences: [], isLoading: false); } } } -/// 灵感页面 Provider +/// 发现页面 Provider final inspirationProvider = StateNotifierProvider( - (ref) => InspirationNotifier(), -); + (ref) => InspirationNotifier(), + ); diff --git a/lib/features/inspiration/services/chat_message_service.dart b/lib/features/inspiration/services/chat_message_service.dart index 7147872e..eedd7282 100644 --- a/lib/features/inspiration/services/chat_message_service.dart +++ b/lib/features/inspiration/services/chat_message_service.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 聊天消息服务 /// 创建时间: 2026-05-08 -/// 更新时间: 2026-05-08 +/// 更新时间: 2026-05-15 /// 作用: 消息的发送/查询/编辑/删除/已阅/回收站管理 -/// 上次更新: 新增sendVideo/sendAudio/sendRichText/updateIpInfo方法 +/// 上次更新: v13.0.0 新增sendLink/sendDocument/sendReadLaterSentence方法 /// ============================================================ import 'dart:convert'; @@ -387,4 +387,140 @@ class ChatMessageService { ); Log.d('IP信息已更新: msg=$messageId, ipText=$ipText'); } + + /// 发送链接消息 + static Future sendLink({ + required String conversationId, + required String url, + String? title, + String? description, + String? imageUrl, + String? sourceApp, + }) async { + final now = DateTime.now(); + final id = _uuid.v4(); + final meta = { + 'url': url, + if (title != null) 'title': title, + if (description != null) 'description': description, + if (imageUrl != null) 'imageUrl': imageUrl, + if (sourceApp != null) 'sourceApp': sourceApp, + }; + final msg = ChatMsgRecordsCompanion.insert( + id: id, + conversationId: conversationId, + type: 'link', + role: 'user', + content: title ?? url, + metaJson: Value(jsonEncode(meta)), + timestamp: now, + createdAt: now, + updatedAt: now, + ); + await _db.insertChatMsgRecord(msg); + await ChatConversationService.updateLastMessage( + conversationId, + '🔗 ${title ?? url}', + now, + ); + Log.d('链接消息已发送: $id → conv=$conversationId'); + return (await _db.getChatMsgRecord(id))!; + } + + /// 发送文档消息 + static Future sendDocument({ + required String conversationId, + required String fileName, + required String filePath, + required String fileType, + required int fileSize, + String? category, + Map? meta, + }) async { + final now = DateTime.now(); + final id = _uuid.v4(); + final effectiveMeta = { + ...?meta, + 'documentType': _documentTypeFromMime(fileType), + }; + final msg = ChatMsgRecordsCompanion.insert( + id: id, + conversationId: conversationId, + type: 'document', + role: 'user', + content: fileName, + category: Value(category), + metaJson: Value(jsonEncode(effectiveMeta)), + timestamp: now, + createdAt: now, + updatedAt: now, + ); + await _db.insertChatMsgRecord(msg); + await ChatConversationService.updateLastMessage( + conversationId, + '📄 $fileName', + now, + ); + Log.d('文档消息已发送: $id → conv=$conversationId'); + return (await _db.getChatMsgRecord(id))!; + } + + /// 发送稍后读句子消息 + static Future sendReadLaterSentence({ + required String conversationId, + required String text, + String? author, + String? source, + String? feedType, + String? feedName, + int? likeCount, + int? commentCount, + int? favoriteCount, + int? views, + String? sentenceId, + }) async { + final now = DateTime.now(); + final id = _uuid.v4(); + final meta = { + if (sentenceId != null) 'sentenceId': sentenceId, + if (feedType != null) 'feedType': feedType, + if (feedName != null) 'feedName': feedName, + if (likeCount != null) 'likeCount': likeCount, + if (commentCount != null) 'commentCount': commentCount, + if (favoriteCount != null) 'favoriteCount': favoriteCount, + if (views != null) 'views': views, + }; + final msg = ChatMsgRecordsCompanion.insert( + id: id, + conversationId: conversationId, + type: 'readlater_sentence', + role: 'assistant', + content: text, + author: Value(author), + source: Value(source), + category: Value(feedType), + metaJson: Value(jsonEncode(meta)), + timestamp: now, + createdAt: now, + updatedAt: now, + ); + await _db.insertChatMsgRecord(msg); + await ChatConversationService.updateLastMessage( + conversationId, + '📖 ${text.length > 20 ? '${text.substring(0, 20)}...' : text}', + now, + ); + Log.d('稍后读句子已发送: $id → conv=$conversationId'); + return (await _db.getChatMsgRecord(id))!; + } + + static String _documentTypeFromMime(String mime) { + if (mime.contains('pdf')) return 'pdf'; + if (mime.contains('word') || mime.contains('doc')) return 'word'; + if (mime.contains('excel') || mime.contains('sheet')) return 'excel'; + if (mime.contains('presentation') || mime.contains('ppt')) return 'ppt'; + if (mime.contains('zip') || mime.contains('rar') || mime.contains('7z')) return 'archive'; + if (mime.contains('text')) return 'txt'; + return 'other'; + } } diff --git a/lib/features/inspiration/services/readlater_folder_service.dart b/lib/features/inspiration/services/readlater_folder_service.dart new file mode 100644 index 00000000..980f903b --- /dev/null +++ b/lib/features/inspiration/services/readlater_folder_service.dart @@ -0,0 +1,295 @@ +/// ============================================================ +/// 闲言APP — 稍后读文件夹管理服务 +/// 创建时间: 2026-05-15 +/// 更新时间: 2026-05-15 +/// 作用: 稍后读消息文件夹CRUD — 基于AppKVStore持久化 +/// 上次更新: E14 初始创建,支持文件夹增删改查+消息归档 +/// ============================================================ + +import 'dart:convert'; + +import 'package:uuid/uuid.dart'; + +import '../../../core/storage/app_kv_store.dart'; +import '../../../core/utils/logger.dart'; + +// ============================================================ +// 文件夹数据模型 +// ============================================================ + +class ReadlaterFolder { + const ReadlaterFolder({ + required this.id, + required this.name, + this.emoji = '📁', + this.count = 0, + required this.createdAt, + required this.updatedAt, + }); + + final String id; + final String name; + final String emoji; + final int count; + final DateTime createdAt; + final DateTime updatedAt; + + Map toJson() => { + 'id': id, + 'name': name, + 'emoji': emoji, + 'count': count, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + }; + + factory ReadlaterFolder.fromJson(Map json) { + return ReadlaterFolder( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + emoji: json['emoji'] as String? ?? '📁', + count: json['count'] as int? ?? 0, + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt'] as String) + : DateTime.now(), + updatedAt: json['updatedAt'] != null + ? DateTime.parse(json['updatedAt'] as String) + : DateTime.now(), + ); + } + + ReadlaterFolder copyWith({ + String? id, + String? name, + String? emoji, + int? count, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return ReadlaterFolder( + id: id ?? this.id, + name: name ?? this.name, + emoji: emoji ?? this.emoji, + count: count ?? this.count, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} + +// ============================================================ +// 文件夹管理服务 +// ============================================================ + +class ReadlaterFolderService { + ReadlaterFolderService._(); + + static final instance = ReadlaterFolderService._(); + + static const _storageKey = 'readlater_folders'; + static const _mappingKey = 'readlater_message_folder'; + + // ============================================================ + // 数据读取 + // ============================================================ + + /// 读取文件夹列表 + List _loadFolders() { + try { + final raw = AppKVStore.getString(_storageKey); + if (raw == null || raw.isEmpty) return []; + final decoded = jsonDecode(raw) as List; + return decoded + .map((e) => ReadlaterFolder.fromJson(e as Map)) + .toList(); + } catch (e) { + Log.e('文件夹服务: 读取文件夹数据失败', e); + return []; + } + } + + /// 保存文件夹列表 + Future _saveFolders(List folders) async { + try { + final encoded = jsonEncode(folders.map((f) => f.toJson()).toList()); + await AppKVStore.setString(_storageKey, encoded); + Log.i('文件夹服务: 文件夹数据已保存 (${folders.length} 个)'); + } catch (e) { + Log.e('文件夹服务: 保存文件夹数据失败', e); + } + } + + /// 读取消息-文件夹映射 messageId -> folderId + Map _loadMapping() { + try { + final raw = AppKVStore.getString(_mappingKey); + if (raw == null || raw.isEmpty) return {}; + final decoded = jsonDecode(raw) as Map; + return decoded.map((k, v) => MapEntry(k, v.toString())); + } catch (e) { + Log.e('文件夹服务: 读取映射数据失败', e); + return {}; + } + } + + /// 保存消息-文件夹映射 + Future _saveMapping(Map mapping) async { + try { + await AppKVStore.setString(_mappingKey, jsonEncode(mapping)); + } catch (e) { + Log.e('文件夹服务: 保存映射数据失败', e); + } + } + + // ============================================================ + // 文件夹CRUD + // ============================================================ + + /// 获取所有文件夹 + Future> getFolders() async { + final folders = _loadFolders(); + final mapping = _loadMapping(); + + final countMap = {}; + for (final folderId in mapping.values) { + countMap[folderId] = (countMap[folderId] ?? 0) + 1; + } + + return folders.map((f) { + final actualCount = countMap[f.id] ?? 0; + if (actualCount != f.count) { + return f.copyWith(count: actualCount, updatedAt: DateTime.now()); + } + return f; + }).toList(); + } + + /// 创建文件夹 + Future createFolder(String name, {String? emoji}) async { + final now = DateTime.now(); + final folder = ReadlaterFolder( + id: const Uuid().v4(), + name: name.trim(), + emoji: emoji ?? '📁', + createdAt: now, + updatedAt: now, + ); + + final folders = _loadFolders()..add(folder); + await _saveFolders(folders); + + Log.i('文件夹服务: 创建文件夹「${folder.name}」(${folder.emoji})'); + return folder; + } + + /// 重命名文件夹 + Future renameFolder(String folderId, String newName) async { + final folders = _loadFolders(); + final index = folders.indexWhere((f) => f.id == folderId); + if (index == -1) { + Log.w('文件夹服务: 文件夹不存在 — $folderId'); + return; + } + + folders[index] = folders[index].copyWith( + name: newName.trim(), + updatedAt: DateTime.now(), + ); + await _saveFolders(folders); + + Log.i('文件夹服务: 文件夹重命名为「$newName」'); + } + + /// 删除文件夹 + Future deleteFolder(String folderId) async { + final folders = _loadFolders(); + final target = folders.firstWhere( + (f) => f.id == folderId, + orElse: () => ReadlaterFolder( + id: '', + name: '', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + + if (target.id.isEmpty) { + Log.w('文件夹服务: 文件夹不存在 — $folderId'); + return; + } + + folders.removeWhere((f) => f.id == folderId); + await _saveFolders(folders); + + final mapping = _loadMapping()..removeWhere((_, v) => v == folderId); + await _saveMapping(mapping); + + Log.i('文件夹服务: 删除文件夹「${target.name}」'); + } + + // ============================================================ + // 消息归档 + // ============================================================ + + /// 将消息移入文件夹 + Future moveMessageToFolder(String messageId, String folderId) async { + final folders = _loadFolders(); + final exists = folders.any((f) => f.id == folderId); + if (!exists) { + Log.w('文件夹服务: 目标文件夹不存在 — $folderId'); + return; + } + + final mapping = _loadMapping(); + mapping[messageId] = folderId; + await _saveMapping(mapping); + + Log.i('文件夹服务: 消息 $messageId 已移入文件夹 $folderId'); + } + + /// 从文件夹移出消息 + Future removeMessageFromFolder(String messageId) async { + final mapping = _loadMapping(); + if (!mapping.containsKey(messageId)) { + Log.w('文件夹服务: 消息未在任何文件夹中 — $messageId'); + return; + } + + mapping.remove(messageId); + await _saveMapping(mapping); + + Log.i('文件夹服务: 消息 $messageId 已从文件夹移出'); + } + + /// 获取文件夹内的消息ID列表 + Future> getMessageIdsInFolder(String folderId) async { + final mapping = _loadMapping(); + return mapping.entries + .where((e) => e.value == folderId) + .map((e) => e.key) + .toList(); + } + + /// 获取消息所在文件夹ID + String? getMessageFolder(String messageId) { + final mapping = _loadMapping(); + return mapping[messageId]; + } + + // ============================================================ + // 统计与清理 + // ============================================================ + + /// 获取文件夹总数 + int getFolderCount() => _loadFolders().length; + + /// 获取有归档的消息总数 + int getMappedMessageCount() => _loadMapping().length; + + /// 清空所有文件夹和映射 + Future clearAll() async { + await AppKVStore.remove(_storageKey); + await AppKVStore.remove(_mappingKey); + Log.i('文件夹服务: 所有文件夹数据已清空'); + } +} diff --git a/lib/features/inspiration/services/readlater_tag_service.dart b/lib/features/inspiration/services/readlater_tag_service.dart new file mode 100644 index 00000000..964c567b --- /dev/null +++ b/lib/features/inspiration/services/readlater_tag_service.dart @@ -0,0 +1,256 @@ +// ============================================================ +// 闲言APP — 稍后读标签管理服务 +// 创建时间: 2026-05-15 +// 更新时间: 2026-05-15 +// 作用: 稍后读消息标签CRUD — 基于AppKVStore持久化 +// 上次更新: 初始创建 — E9标签管理功能 +// ============================================================ + +import 'dart:convert'; + +import 'package:xianyan/core/storage/app_kv_store.dart'; +import 'package:xianyan/core/utils/logger.dart'; +import 'package:xianyan/features/inspiration/models/chat_message.dart'; + +class ReadlaterTagService { + ReadlaterTagService._(); + + static const _storageKey = 'readlater_tags'; + + // ============================================================ + // 读取标签数据 + // ============================================================ + + /// 从AppKVStore读取标签映射 messageId -> tags + static Map> _loadTagMap() { + try { + final raw = AppKVStore.getString(_storageKey); + if (raw == null || raw.isEmpty) return {}; + final decoded = jsonDecode(raw) as Map; + return decoded.map((key, value) { + final list = (value as List) + .map((e) => e.toString()) + .toList(); + return MapEntry(key, list); + }); + } catch (e) { + Log.e('标签服务: 读取标签数据失败', e); + return {}; + } + } + + /// 保存标签映射到AppKVStore + static Future _saveTagMap(Map> tagMap) async { + try { + final encoded = jsonEncode( + tagMap.map((key, value) => MapEntry(key, value)), + ); + await AppKVStore.setString(_storageKey, encoded); + Log.i('标签服务: 标签数据已保存 (${tagMap.length} 条消息)'); + } catch (e) { + Log.e('标签服务: 保存标签数据失败', e); + } + } + + // ============================================================ + // 标签CRUD操作 + // ============================================================ + + /// 获取所有标签(去重) + static List getAllTags() { + final tagMap = _loadTagMap(); + final allTags = {}; + for (final tags in tagMap.values) { + allTags.addAll(tags); + } + return allTags.toList()..sort(); + } + + /// 给消息添加标签 + static Future addTag(String messageId, String tag) async { + if (messageId.isEmpty || tag.trim().isEmpty) return false; + + final trimmedTag = tag.trim(); + final tagMap = _loadTagMap(); + final currentTags = tagMap[messageId] ?? []; + + if (currentTags.contains(trimmedTag)) { + Log.i('标签服务: 消息 $messageId 已有标签 "$trimmedTag"'); + return false; + } + + currentTags.add(trimmedTag); + tagMap[messageId] = currentTags; + await _saveTagMap(tagMap); + Log.i('标签服务: 消息 $messageId 添加标签 "$trimmedTag"'); + return true; + } + + /// 移除消息的指定标签 + static Future removeTag(String messageId, String tag) async { + if (messageId.isEmpty || tag.trim().isEmpty) return false; + + final trimmedTag = tag.trim(); + final tagMap = _loadTagMap(); + final currentTags = tagMap[messageId]; + + if (currentTags == null || !currentTags.contains(trimmedTag)) { + Log.i('标签服务: 消息 $messageId 无标签 "$trimmedTag"'); + return false; + } + + currentTags.remove(trimmedTag); + if (currentTags.isEmpty) { + tagMap.remove(messageId); + } else { + tagMap[messageId] = currentTags; + } + await _saveTagMap(tagMap); + Log.i('标签服务: 消息 $messageId 移除标签 "$trimmedTag"'); + return true; + } + + /// 获取消息的所有标签 + static List getTagsForMessage(String messageId) { + final tagMap = _loadTagMap(); + return tagMap[messageId] ?? []; + } + + /// 按标签筛选消息ID列表 + static List getMessageIdsByTag(String tag) { + if (tag.trim().isEmpty) return []; + + final trimmedTag = tag.trim(); + final tagMap = _loadTagMap(); + final result = []; + + tagMap.forEach((messageId, tags) { + if (tags.contains(trimmedTag)) { + result.add(messageId); + } + }); + + return result; + } + + /// 按标签筛选消息(从消息列表中过滤) + static List getMessagesByTag( + String tag, + List messages, + ) { + final ids = getMessageIdsByTag(tag); + final idSet = ids.toSet(); + return messages.where((m) => idSet.contains(m.id)).toList(); + } + + /// 批量设置消息标签(覆盖) + static Future setTagsForMessage( + String messageId, + List tags, + ) async { + if (messageId.isEmpty) return; + + final tagMap = _loadTagMap(); + if (tags.isEmpty) { + tagMap.remove(messageId); + } else { + tagMap[messageId] = tags.map((t) => t.trim()).where((t) => t.isNotEmpty).toList(); + } + await _saveTagMap(tagMap); + Log.i('标签服务: 消息 $messageId 标签已更新为 $tags'); + } + + /// 删除消息的所有标签 + static Future removeAllTagsForMessage(String messageId) async { + final tagMap = _loadTagMap(); + if (tagMap.containsKey(messageId)) { + tagMap.remove(messageId); + await _saveTagMap(tagMap); + Log.i('标签服务: 消息 $messageId 所有标签已移除'); + } + } + + // ============================================================ + // 标签统计 + // ============================================================ + + /// 获取标签使用统计 — 返回 tag -> count 的映射 + static Map getTagStats() { + final tagMap = _loadTagMap(); + final stats = {}; + + for (final tags in tagMap.values) { + for (final tag in tags) { + stats[tag] = (stats[tag] ?? 0) + 1; + } + } + + final sortedEntries = stats.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + return Map.fromEntries(sortedEntries); + } + + /// 获取标签总数 + static int getTagCount() => getAllTags().length; + + /// 获取有标签的消息总数 + static int getTaggedMessageCount() => _loadTagMap().length; + + /// 清空所有标签数据 + static Future clearAllTags() async { + await AppKVStore.remove(_storageKey); + Log.i('标签服务: 所有标签数据已清空'); + } + + // ============================================================ + // 同步: 从ChatMessage.ext['tags']导入到KVStore + // ============================================================ + + /// 从消息列表的ext['tags']字段同步到KVStore + static Future syncFromMessages(List messages) async { + final tagMap = _loadTagMap(); + var changed = false; + + for (final msg in messages) { + final extTags = msg.getTags; + if (extTags.isNotEmpty) { + final existingTags = tagMap[msg.id]; + if (existingTags == null || !_listEquals(existingTags, extTags)) { + tagMap[msg.id] = extTags; + changed = true; + } + } + } + + if (changed) { + await _saveTagMap(tagMap); + Log.i('标签服务: 从消息ext同步标签完成'); + } + } + + /// 导出KVStore标签到消息的ext['tags']字段 + static List exportToMessages(List messages) { + final tagMap = _loadTagMap(); + return messages.map((msg) { + final kvTags = tagMap[msg.id]; + if (kvTags != null && kvTags.isNotEmpty) { + final newExt = Map.from(msg.ext ?? {}); + newExt['tags'] = kvTags; + return msg.copyWith(ext: newExt); + } + return msg; + }).toList(); + } + + // ============================================================ + // 工具方法 + // ============================================================ + + static bool _listEquals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} diff --git a/lib/features/poetry/services/poetry_service.dart b/lib/features/poetry/services/poetry_service.dart index d5c865b7..c234f8f8 100644 --- a/lib/features/poetry/services/poetry_service.dart +++ b/lib/features/poetry/services/poetry_service.dart @@ -6,7 +6,7 @@ /// 上次更新: 初始创建 /// ============================================================ -import '../../../core/services/jinrishici_sdk_service.dart'; +import '../../../core/services/data/jinrishici_sdk_service.dart'; import '../../../core/utils/logger.dart'; import '../models/jinrishici_models.dart'; diff --git a/lib/features/profile/presentation/profile_page.dart b/lib/features/profile/presentation/profile_page.dart index 9ba8b341..e4624ce6 100644 --- a/lib/features/profile/presentation/profile_page.dart +++ b/lib/features/profile/presentation/profile_page.dart @@ -19,14 +19,11 @@ import '../../../core/theme/app_typography.dart'; import '../../../core/theme/app_radius.dart'; import '../../../core/router/app_router.dart'; import '../../../core/storage/kv_storage.dart'; -import '../../../core/storage/secure_storage.dart'; -import '../../../core/services/token_service.dart'; import '../../../shared/widgets/glass_container.dart'; import '../../../shared/widgets/responsive_layout.dart'; import '../../../features/settings/providers/theme_settings_provider.dart'; import '../../auth/models/user_model.dart'; import '../../auth/providers/auth_provider.dart'; -import '../../auth/services/auth_service.dart'; class ProfilePage extends ConsumerWidget { const ProfilePage({super.key}); @@ -154,13 +151,6 @@ class ProfilePage extends ConsumerWidget { onTap: () => context.push('/settings/account'), ), _SettingsDivider(ext: ext), - _SettingRow( - icon: CupertinoIcons.lock_shield_fill, - title: '安全与Token管理', - ext: ext, - onTap: () => _showSecuritySheet(context, ref, ext), - ), - _SettingsDivider(ext: ext), _SettingRow( icon: CupertinoIcons.arrow_down_circle_fill, title: '数据管理', @@ -309,17 +299,6 @@ class ProfilePage extends ConsumerWidget { ), ); } - - void _showSecuritySheet( - BuildContext context, - WidgetRef ref, - AppThemeExtension ext, - ) { - showCupertinoModalPopup( - context: context, - builder: (ctx) => _SecuritySheet(ext: ext), - ); - } } class _ProfileHeader extends StatelessWidget { @@ -386,12 +365,12 @@ class _ProfileHeader extends StatelessWidget { ), ) : isLoading - ? const CupertinoActivityIndicator() - : const Icon( - CupertinoIcons.person_fill, - size: 28, - color: CupertinoColors.white, - ), + ? const CupertinoActivityIndicator() + : const Icon( + CupertinoIcons.person_fill, + size: 28, + color: CupertinoColors.white, + ), ), ), const SizedBox(width: AppSpacing.md), @@ -403,8 +382,8 @@ class _ProfileHeader extends StatelessWidget { isLoading ? '加载中...' : (isLoggedIn && user != null - ? user.displayName - : '点击登录'), + ? user.displayName + : '点击登录'), style: AppTypography.title3.copyWith( color: ext.textPrimary, fontWeight: FontWeight.w700, @@ -415,8 +394,8 @@ class _ProfileHeader extends StatelessWidget { isLoading ? '正在恢复登录状态' : (isLoggedIn && user != null - ? (user.title?.name ?? '闲言用户') - : '发现好句,创作卡片'), + ? (user.title?.name ?? '闲言用户') + : '发现好句,创作卡片'), style: AppTypography.subhead.copyWith( color: ext.textSecondary, ), @@ -642,550 +621,3 @@ class _SettingsDivider extends StatelessWidget { ); } } - -class _SecuritySheet extends ConsumerStatefulWidget { - const _SecuritySheet({required this.ext}); - - final AppThemeExtension ext; - - @override - ConsumerState<_SecuritySheet> createState() => _SecuritySheetState(); -} - -class _SecuritySheetState extends ConsumerState<_SecuritySheet> { - bool _checking = false; - bool _refreshing = false; - TokenCheckResult? _tokenResult; - String? _tokenPreview; - DateTime? _tokenExpiry; - - @override - void initState() { - super.initState(); - _loadTokenInfo(); - } - - Future _loadTokenInfo() async { - final token = await SecureStorage.authToken; - if (token != null && token.isNotEmpty) { - setState(() { - _tokenPreview = - '${token.substring(0, 8)}...${token.substring(token.length - 4)}'; - }); - } - } - - Future _checkToken() async { - setState(() => _checking = true); - final result = await AuthService.checkToken(); - if (mounted) { - setState(() { - _tokenResult = result; - _checking = false; - if (result.valid && result.expiresIn > 0) { - _tokenExpiry = DateTime.now().add( - Duration(seconds: result.expiresIn), - ); - } - }); - } - } - - Future _refreshToken() async { - setState(() => _refreshing = true); - final success = await AuthService.refreshToken(); - if (mounted) { - setState(() => _refreshing = false); - if (success) { - _tokenResult = null; - _loadTokenInfo(); - _checkToken(); - showCupertinoDialog( - context: context, - builder: (ctx) => CupertinoAlertDialog( - title: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - CupertinoIcons.checkmark_circle_fill, - size: 20, - color: CupertinoColors.systemGreen, - ), - SizedBox(width: 8), - Text('刷新成功'), - ], - ), - content: const Text('Token已更新,新Token已保存。'), - actions: [ - CupertinoDialogAction( - child: const Text('好的'), - onPressed: () => Navigator.pop(ctx), - ), - ], - ), - ); - } else { - showCupertinoDialog( - context: context, - builder: (ctx) => CupertinoAlertDialog( - title: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - CupertinoIcons.xmark_circle_fill, - size: 20, - color: CupertinoColors.systemRed, - ), - SizedBox(width: 8), - Text('刷新失败'), - ], - ), - content: const Text('Token刷新失败,请重新登录。'), - actions: [ - CupertinoDialogAction( - child: const Text('好的'), - onPressed: () => Navigator.pop(ctx), - ), - ], - ), - ); - } - } - } - - void _showChangePassword() { - final oldCtrl = TextEditingController(); - final newCtrl = TextEditingController(); - final confirmCtrl = TextEditingController(); - - showCupertinoDialog( - context: context, - builder: (ctx) => CupertinoAlertDialog( - title: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(CupertinoIcons.lock_fill, size: 20, color: widget.ext.accent), - const SizedBox(width: 8), - const Text('修改密码'), - ], - ), - content: Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CupertinoTextField( - controller: oldCtrl, - placeholder: '当前密码', - obscureText: true, - padding: const EdgeInsets.all(AppSpacing.sm), - ), - const SizedBox(height: AppSpacing.xs), - CupertinoTextField( - controller: newCtrl, - placeholder: '新密码', - obscureText: true, - padding: const EdgeInsets.all(AppSpacing.sm), - ), - const SizedBox(height: AppSpacing.xs), - CupertinoTextField( - controller: confirmCtrl, - placeholder: '确认新密码', - obscureText: true, - padding: const EdgeInsets.all(AppSpacing.sm), - ), - const SizedBox(height: AppSpacing.xs), - Row( - children: [ - const Icon( - CupertinoIcons.exclamationmark_triangle_fill, - size: 14, - color: CupertinoColors.systemOrange, - ), - const SizedBox(width: AppSpacing.xs), - Expanded( - child: Text( - '修改密码后,当前Token将立即失效,\n所有设备需要重新登录。', - style: AppTypography.caption2.copyWith( - color: CupertinoColors.systemOrange, - ), - ), - ), - ], - ), - ], - ), - ), - actions: [ - CupertinoDialogAction( - child: const Text('取消'), - onPressed: () => Navigator.pop(ctx), - ), - CupertinoDialogAction( - isDefaultAction: true, - child: const Text('确认修改'), - onPressed: () async { - if (newCtrl.text != confirmCtrl.text) { - Navigator.pop(ctx); - _showTip('两次密码不一致'); - return; - } - Navigator.pop(ctx); - final success = await ref - .read(authProvider.notifier) - .changePassword( - oldPassword: oldCtrl.text, - newPassword: newCtrl.text, - ); - if (mounted) { - if (success) { - _showTip('密码修改成功,Token已失效,请重新登录'); - await SecureStorage.removeAuthToken(); - ref.read(authProvider.notifier).logout(); - if (mounted) context.go(AppRoutes.login); - } else { - _showTip('密码修改失败,请检查当前密码是否正确'); - } - } - }, - ), - ], - ), - ); - } - - void _showTip(String msg) { - if (!mounted) return; - showCupertinoDialog( - context: context, - builder: (ctx) => CupertinoAlertDialog( - content: Text(msg), - actions: [ - CupertinoDialogAction( - child: const Text('好的'), - onPressed: () => Navigator.pop(ctx), - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - final ext = widget.ext; - return Container( - decoration: BoxDecoration( - color: ext.bgPrimary, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - ), - child: SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - margin: const EdgeInsets.symmetric(vertical: AppSpacing.sm), - width: 36, - height: 4, - decoration: BoxDecoration( - color: ext.textHint.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(2), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), - child: Row( - children: [ - Row( - children: [ - Icon( - CupertinoIcons.lock_shield_fill, - size: 20, - color: ext.accent, - ), - const SizedBox(width: AppSpacing.sm), - Text( - '安全与Token管理', - style: AppTypography.title3.copyWith( - color: ext.textPrimary, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - const Spacer(), - CupertinoButton( - padding: EdgeInsets.zero, - minimumSize: const Size(32, 32), - onPressed: () => Navigator.pop(context), - child: Text( - '完成', - style: AppTypography.body.copyWith(color: ext.accent), - ), - ), - ], - ), - ), - const SizedBox(height: AppSpacing.sm), - Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), - child: _buildTokenStatusCard(ext), - ), - const SizedBox(height: AppSpacing.sm), - Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), - child: _buildActionButtons(ext), - ), - const SizedBox(height: AppSpacing.sm), - Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), - child: _buildWarningSection(ext), - ), - const SizedBox(height: AppSpacing.lg), - ], - ), - ), - ); - } - - Widget _buildTokenStatusCard(AppThemeExtension ext) { - final isValid = _tokenResult?.valid ?? false; - final expiresIn = _tokenResult?.expiresIn ?? 0; - final statusIcon = _checking - ? CupertinoIcons.hourglass - : isValid - ? CupertinoIcons.checkmark_circle_fill - : (_tokenResult != null - ? CupertinoIcons.xmark_circle_fill - : CupertinoIcons.question_circle); - final statusText = _checking - ? '检测中...' - : isValid - ? '有效 (剩余 ${_formatDuration(expiresIn)})' - : (_tokenResult != null ? '已失效' : '点击检测查看状态'); - - return GlassContainer( - depth: GlassDepth.elevated, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - statusIcon, - size: 20, - color: isValid - ? CupertinoColors.systemGreen - : ext.textSecondary, - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - 'Token状态: $statusText', - style: AppTypography.subhead.copyWith(color: ext.textPrimary), - ), - ), - ], - ), - if (_tokenPreview != null) ...[ - const SizedBox(height: AppSpacing.xs), - Row( - children: [ - Icon(CupertinoIcons.lock_fill, size: 14, color: ext.textHint), - const SizedBox(width: AppSpacing.xs), - Expanded( - child: Text( - _tokenPreview!, - style: AppTypography.caption1.copyWith( - color: ext.textSecondary, - fontFamily: 'monospace', - ), - ), - ), - ], - ), - ], - if (_tokenExpiry != null) ...[ - const SizedBox(height: AppSpacing.xs), - Row( - children: [ - Icon(CupertinoIcons.calendar, size: 14, color: ext.textHint), - const SizedBox(width: AppSpacing.xs), - Expanded( - child: Text( - '过期时间: ${_tokenExpiry?.year ?? 0}-${(_tokenExpiry?.month ?? 1).toString().padLeft(2, '0')}-${(_tokenExpiry?.day ?? 1).toString().padLeft(2, '0')} ' - '${(_tokenExpiry?.hour ?? 0).toString().padLeft(2, '0')}:${(_tokenExpiry?.minute ?? 0).toString().padLeft(2, '0')}', - style: AppTypography.caption1.copyWith( - color: ext.textSecondary, - ), - ), - ), - ], - ), - ], - ], - ), - ); - } - - Widget _buildActionButtons(AppThemeExtension ext) { - return GlassContainer( - depth: GlassDepth.elevated, - padding: EdgeInsets.zero, - child: Column( - children: [ - _buildSheetAction( - icon: CupertinoIcons.search, - title: '检测Token状态', - subtitle: '验证当前Token是否有效', - ext: ext, - isLoading: _checking, - onTap: _checkToken, - ), - _SettingsDivider(ext: ext), - _buildSheetAction( - icon: CupertinoIcons.arrow_clockwise, - title: '刷新Token', - subtitle: '获取新Token,旧Token立即失效', - ext: ext, - isLoading: _refreshing, - onTap: _refreshToken, - ), - _SettingsDivider(ext: ext), - _buildSheetAction( - icon: CupertinoIcons.lock_fill, - title: '修改密码', - subtitle: '修改后Token失效,需重新登录', - ext: ext, - onTap: _showChangePassword, - ), - _SettingsDivider(ext: ext), - _buildSheetAction( - icon: CupertinoIcons.trash_fill, - title: '清除Token', - subtitle: '删除本地Token,需重新登录', - ext: ext, - onTap: () async { - await SecureStorage.removeAuthToken(); - ref.read(authProvider.notifier).logout(); - if (mounted) { - Navigator.pop(context); - context.go(AppRoutes.login); - } - }, - ), - ], - ), - ); - } - - Widget _buildSheetAction({ - required IconData icon, - required String title, - required String subtitle, - required AppThemeExtension ext, - required VoidCallback onTap, - bool isLoading = false, - }) { - return CupertinoButton( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - onPressed: isLoading ? null : onTap, - child: Row( - children: [ - if (isLoading) - const CupertinoActivityIndicator() - else - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: ext.accent.withValues(alpha: 0.12), - borderRadius: AppRadius.smBorder, - ), - child: Icon(icon, size: 16, color: ext.accent), - ), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: AppTypography.body.copyWith(color: ext.textPrimary), - ), - Text( - subtitle, - style: AppTypography.caption2.copyWith( - color: ext.textSecondary, - ), - ), - ], - ), - ), - if (!isLoading) - Icon(CupertinoIcons.chevron_right, size: 16, color: ext.textHint), - ], - ), - ); - } - - Widget _buildWarningSection(AppThemeExtension ext) { - return Container( - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: CupertinoColors.systemOrange.withValues(alpha: 0.08), - borderRadius: AppRadius.mdBorder, - border: Border.all( - color: CupertinoColors.systemOrange.withValues(alpha: 0.2), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - CupertinoIcons.exclamationmark_triangle_fill, - size: 16, - color: CupertinoColors.systemOrange, - ), - const SizedBox(width: AppSpacing.xs), - Text( - '安全须知', - style: AppTypography.subhead.copyWith( - color: CupertinoColors.systemOrange, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - const SizedBox(height: AppSpacing.xs), - Text( - '• 修改密码后,当前Token将立即失效,所有设备需重新登录\n' - '• 刷新Token后,旧Token即刻失效\n' - '• Token过期后需重新登录获取新Token\n' - '• 请勿将Token分享给他人,Token等同于登录凭证\n' - '• 建议定期修改密码以确保账户安全', - style: AppTypography.caption1.copyWith( - color: ext.textSecondary, - height: 1.6, - ), - ), - ], - ), - ); - } - - String _formatDuration(int seconds) { - if (seconds <= 0) return '已过期'; - final days = seconds ~/ 86400; - final hours = (seconds % 86400) ~/ 3600; - if (days > 0) return '$days天$hours小时'; - final minutes = (seconds % 3600) ~/ 60; - if (hours > 0) return '$hours小时$minutes分钟'; - return '$minutes分钟'; - } -} diff --git a/lib/features/rank/presentation/rank_page.dart b/lib/features/rank/presentation/rank_page.dart new file mode 100644 index 00000000..08403a7f --- /dev/null +++ b/lib/features/rank/presentation/rank_page.dart @@ -0,0 +1,196 @@ +/// @name 赛季排行榜页面 +/// @date 2026-05-14 +/// @desc 展示赛季排行榜+排行类型切换+我的排名 +/// @update v13.0.0 初始版本 + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/rank_provider.dart'; +import '../../../shared/widgets/rank_item_card.dart'; + +class RankPage extends ConsumerStatefulWidget { + const RankPage({super.key}); + + @override + ConsumerState createState() => _RankPageState(); +} + +class _RankPageState extends ConsumerState { + final List> _rankTypes = [ + {'key': 'exp', 'label': '💎 经验', 'icon': '💎'}, + {'key': 'signin', 'label': '✅ 签到', 'icon': '✅'}, + {'key': 'badge', 'label': '🏅 勋章', 'icon': '🏅'}, + {'key': 'score', 'label': '⭐ 积分', 'icon': '⭐'}, + ]; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(rankProvider.notifier).loadLeaderboard(); + ref.read(rankProvider.notifier).loadMyRank(); + }); + } + + @override + Widget build(BuildContext context) { + final rankState = ref.watch(rankProvider); + final brightness = MediaQuery.platformBrightnessOf(context); + final isDark = brightness == Brightness.dark; + + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('🏆 排行榜'), + ), + child: SafeArea( + child: Column( + children: [ + _buildTypeSelector(isDark), + if (rankState.currentSeason != null) + _buildSeasonHeader(rankState.currentSeason!, isDark), + if (rankState.myRank != null && rankState.myRank! > 0) + _buildMyRank(rankState, isDark), + Expanded( + child: rankState.isLoading + ? const Center(child: CupertinoActivityIndicator()) + : rankState.leaderboard.isEmpty + ? Center( + child: Text( + '暂无排行数据', + style: TextStyle(color: CupertinoColors.secondaryLabel.resolveFrom(context)), + ), + ) + : ListView.builder( + padding: const EdgeInsets.only(top: 4, bottom: 40), + itemCount: rankState.leaderboard.length, + itemBuilder: (context, index) { + return RankItemCard( + item: rankState.leaderboard[index], + ); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _buildTypeSelector(bool isDark) { + final currentType = ref.watch(rankProvider).rankType; + return Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 4), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: isDark ? CupertinoColors.systemGrey6.darkColor : CupertinoColors.systemGrey5.color, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: _rankTypes.map((t) { + final isActive = currentType == t['key']; + return Expanded( + child: GestureDetector( + onTap: () { + ref.read(rankProvider.notifier).loadLeaderboard(type: t['key'] as String); + ref.read(rankProvider.notifier).loadMyRank(type: t['key'] as String); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: isActive ? CupertinoColors.white : null, + borderRadius: BorderRadius.circular(8), + boxShadow: isActive + ? [BoxShadow(color: CupertinoColors.black.withValues(alpha: 0.08), blurRadius: 4, offset: const Offset(0, 1))] + : null, + ), + child: Center( + child: Text( + t['label'] as String, + style: TextStyle( + fontSize: 13, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + color: isActive ? CupertinoColors.activeBlue : CupertinoColors.secondaryLabel, + ), + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } + + Widget _buildSeasonHeader(RankSeason season, bool isDark) { + final startDate = DateTime.fromMillisecondsSinceEpoch(season.startTime * 1000); + final endDate = DateTime.fromMillisecondsSinceEpoch(season.endTime * 1000); + return Container( + margin: const EdgeInsets.fromLTRB(16, 8, 16, 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: isDark ? CupertinoColors.systemGrey6.darkColor : CupertinoColors.systemBackground.color, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Text(season.type == 'weekly' ? '📅 周赛' : '🗓️ 月赛', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(width: 8), + Text( + '${startDate.month}/${startDate.day} - ${endDate.month}/${endDate.day}', + style: TextStyle(fontSize: 12, color: CupertinoColors.secondaryLabel.resolveFrom(context)), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: season.status == 'active' + ? CupertinoColors.activeGreen.withValues(alpha: 0.15) + : CupertinoColors.systemGrey.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + season.status == 'active' ? '进行中' : (season.status == 'settled' ? '已结算' : '待开始'), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: season.status == 'active' ? CupertinoColors.activeGreen : CupertinoColors.systemGrey, + ), + ), + ), + ], + ), + ); + } + + Widget _buildMyRank(RankState rankState, bool isDark) { + return Container( + margin: const EdgeInsets.fromLTRB(16, 8, 16, 4), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [CupertinoColors.activeBlue.withValues(alpha: 0.1), CupertinoColors.activeOrange.withValues(alpha: 0.05)], + ), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: CupertinoColors.activeBlue.withValues(alpha: 0.2)), + ), + child: Row( + children: [ + const Text('👤', style: TextStyle(fontSize: 20)), + const SizedBox(width: 8), + const Text('我的排名', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + const Spacer(), + Text( + '#${rankState.myRank}', + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: CupertinoColors.activeBlue), + ), + const SizedBox(width: 8), + Text( + '${rankState.myValue ?? 0}', + style: TextStyle(fontSize: 14, color: CupertinoColors.secondaryLabel.resolveFrom(context)), + ), + ], + ), + ); + } +} diff --git a/lib/features/rank/providers/rank_provider.dart b/lib/features/rank/providers/rank_provider.dart new file mode 100644 index 00000000..e7f81ec1 --- /dev/null +++ b/lib/features/rank/providers/rank_provider.dart @@ -0,0 +1,174 @@ +/// @name 赛季排行榜状态管理 +/// @date 2026-05-14 +/// @desc Riverpod状态管理: 赛季列表/排行榜/我的排名 +/// @update v13.0.0 初始版本 + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../services/rank_service.dart'; + +class RankSeason { + final int id; + final String name; + final String type; + final int startTime; + final int endTime; + final String status; + final List rewards; + + RankSeason({ + required this.id, + required this.name, + required this.type, + required this.startTime, + required this.endTime, + required this.status, + required this.rewards, + }); + + factory RankSeason.fromJson(Map json) { + return RankSeason( + id: (json['id'] ?? 0) as int, + name: (json['name'] ?? '') as String, + type: (json['type'] ?? 'weekly') as String, + startTime: (json['start_time'] ?? 0) as int, + endTime: (json['end_time'] ?? 0) as int, + status: (json['status'] ?? 'pending') as String, + rewards: json['rewards'] as List? ?? [], + ); + } +} + +class RankItem { + final int rank; + final int userId; + final String username; + final String avatar; + final int level; + final int value; + final bool rewardClaimed; + + RankItem({ + required this.rank, + required this.userId, + required this.username, + required this.avatar, + required this.level, + required this.value, + this.rewardClaimed = false, + }); + + factory RankItem.fromJson(Map json) { + return RankItem( + rank: (json['rank'] ?? 0) as int, + userId: (json['user_id'] ?? 0) as int, + username: (json['username'] ?? '') as String, + avatar: (json['avatar'] ?? '') as String, + level: (json['level'] ?? 1) as int, + value: (json['value'] ?? 0) as int, + rewardClaimed: json['reward_claimed'] == 1 || json['reward_claimed'] == true, + ); + } +} + +class RankState { + final List seasons; + final List leaderboard; + final RankSeason? currentSeason; + final String rankType; + final int? myRank; + final int? myValue; + final bool isLoading; + final String? error; + + const RankState({ + this.seasons = const [], + this.leaderboard = const [], + this.currentSeason, + this.rankType = 'exp', + this.myRank, + this.myValue, + this.isLoading = false, + this.error, + }); + + RankState copyWith({ + List? seasons, + List? leaderboard, + RankSeason? currentSeason, + String? rankType, + int? myRank, + int? myValue, + bool? isLoading, + String? error, + }) { + return RankState( + seasons: seasons ?? this.seasons, + leaderboard: leaderboard ?? this.leaderboard, + currentSeason: currentSeason ?? this.currentSeason, + rankType: rankType ?? this.rankType, + myRank: myRank ?? this.myRank, + myValue: myValue ?? this.myValue, + isLoading: isLoading ?? this.isLoading, + error: error, + ); + } +} + +class RankNotifier extends StateNotifier { + final RankService _rankService; + + RankNotifier(this._rankService) : super(const RankState()); + + Future loadSeasons() async { + try { + final response = await _rankService.getSeasons(); + final data = (response['data'] ?? response) as Map; + final List seasonList = data['seasons'] as List? ?? []; + final seasons = seasonList.map((e) => RankSeason.fromJson(e as Map)).toList(); + state = state.copyWith(seasons: seasons); + } catch (e) { + state = state.copyWith(error: e.toString()); + } + } + + Future loadLeaderboard({int? seasonId, String type = 'exp'}) async { + state = state.copyWith(isLoading: true, rankType: type); + try { + final response = await _rankService.getLeaderboard(seasonId: seasonId, type: type); + final data = (response['data'] ?? response) as Map; + final List list = data['list'] as List? ?? []; + final items = list.map((e) => RankItem.fromJson(e as Map)).toList(); + final seasonData = data['season'] as Map?; + final season = seasonData != null ? RankSeason.fromJson(seasonData) : null; + state = state.copyWith(leaderboard: items, currentSeason: season, isLoading: false); + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + Future loadMyRank({int? seasonId, String type = 'exp'}) async { + try { + final response = await _rankService.getMyRank(seasonId: seasonId, type: type); + final data = (response['data'] ?? response) as Map; + state = state.copyWith( + myRank: (data['rank'] ?? 0) as int, + myValue: (data['value'] ?? 0) as int, + ); + } catch (e) { + // silently fail + } + } + + Future?> claimReward({required int seasonId, required String type}) async { + try { + final response = await _rankService.claimReward(seasonId: seasonId, type: type); + return (response['data'] ?? response) as Map?; + } catch (e) { + return null; + } + } +} + +final rankProvider = StateNotifierProvider((ref) { + return RankNotifier(ref.read(rankServiceProvider)); +}); diff --git a/lib/features/rank/services/rank_service.dart b/lib/features/rank/services/rank_service.dart new file mode 100644 index 00000000..ae56a104 --- /dev/null +++ b/lib/features/rank/services/rank_service.dart @@ -0,0 +1,42 @@ +/// @name 赛季排行榜API服务 +/// @date 2026-05-14 +/// @desc 赛季排行榜API请求封装 +/// @update v13.0.0 初始版本 + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/network/api_client.dart'; + +final rankServiceProvider = Provider((ref) => RankService()); + +class RankService { + static final ApiClient _api = ApiClient.instance; + + Future> getSeasons({String? type}) async { + final query = {}; + if (type != null) query['type'] = type; + final response = await _api.get>('/api/rank/seasons', queryParameters: query); + return response.data!; + } + + Future> getLeaderboard({int? seasonId, String type = 'exp'}) async { + final query = {'type': type}; + if (seasonId != null) query['season_id'] = seasonId; + final response = await _api.get>('/api/rank/leaderboard', queryParameters: query); + return response.data!; + } + + Future> getMyRank({int? seasonId, String type = 'exp'}) async { + final query = {'type': type}; + if (seasonId != null) query['season_id'] = seasonId; + final response = await _api.get>('/api/rank/myRank', queryParameters: query); + return response.data!; + } + + Future> claimReward({required int seasonId, required String type}) async { + final response = await _api.post>('/api/rank/claimReward', data: { + 'season_id': seasonId, + 'type': type, + }); + return response.data!; + } +} diff --git a/lib/features/search/providers/search_provider.dart b/lib/features/search/providers/search_provider.dart index fa4bc2db..85fbba56 100644 --- a/lib/features/search/providers/search_provider.dart +++ b/lib/features/search/providers/search_provider.dart @@ -10,7 +10,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/services/connectivity_service.dart'; +import '../../../core/services/network/connectivity_service.dart'; import '../../../core/storage/app_kv_store.dart'; import '../../../core/utils/logger.dart'; import '../../home/models/feed_model.dart'; @@ -416,7 +416,7 @@ class SearchNotifier extends StateNotifier { keyword: trimmed, type: state.type, mode: state.mode, - ).timeout(const Duration(seconds: 10)); + ).timeout(const Duration(seconds: 15)); final history = [ trimmed, @@ -440,7 +440,7 @@ class SearchNotifier extends StateNotifier { _loadHighlightInBackground(trimmed); } on TimeoutException { Log.w('搜索 "$trimmed" 超时'); - state = state.copyWith(isLoading: false, error: '搜索超时,请重试'); + state = state.copyWith(isLoading: false, error: '搜索超时,请检查网络后重试'); } catch (e) { Log.e('搜索失败', e); state = state.copyWith(isLoading: false, error: '搜索失败,请重试'); @@ -457,7 +457,7 @@ class SearchNotifier extends StateNotifier { keyword: keyword, mode: state.mode, limit: 10, - ).timeout(const Duration(seconds: 8)); + ).timeout(const Duration(seconds: 12)); allItems.addAll(quickResult.list); allStats.addAll(quickResult.typeStats); @@ -685,12 +685,12 @@ class SearchNotifier extends StateNotifier { keyword: trimmed, type: state.type, mode: state.mode, - ).timeout(const Duration(seconds: 10)), + ).timeout(const Duration(seconds: 15)), SearchAllService.highlight( keyword: trimmed, type: state.type, mode: state.mode, - ).timeout(const Duration(seconds: 10)), + ).timeout(const Duration(seconds: 15)), ]); final searchResult = results[0] as SearchAllResult; diff --git a/lib/features/settings/presentation/account_settings_page.dart b/lib/features/settings/presentation/account_settings_page.dart index bd4b2563..7323d341 100644 --- a/lib/features/settings/presentation/account_settings_page.dart +++ b/lib/features/settings/presentation/account_settings_page.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 账户设置页面 /// 创建时间: 2026-04-28 -/// 更新时间: 2026-05-12 +/// 更新时间: 2026-05-15 /// 作用: 账户信息管理、头像/昵称/签名编辑 -/// 上次更新: v9.2.0 注销入口改为跳转独立注销页面 +/// 上次更新: v10.1.0 新增密保问题管理按钮 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -17,10 +17,13 @@ import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../core/theme/app_typography.dart'; import '../../../core/theme/app_radius.dart'; +import '../../../core/storage/secure_storage.dart'; +import '../../../core/services/auth/token_service.dart'; import '../../../shared/widgets/glass_container.dart'; import '../../../shared/widgets/app_toast.dart'; import '../../auth/models/user_model.dart'; import '../../auth/providers/auth_provider.dart'; +import '../../auth/services/auth_service.dart'; import '../../auth/services/user_security_service.dart'; class AccountSettingsPage extends ConsumerWidget { @@ -138,6 +141,33 @@ class AccountSettingsPage extends ConsumerWidget { title: '修改密码', onTap: () => context.push(AppRoutes.passwordSettings), ), + _Divider(ext: ext), + _ActionRow( + ext: ext, + icon: CupertinoIcons.shield, + title: '密保问题', + onTap: () => context.push(AppRoutes.securityQuestion), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildVerifyBadge(ext, user?.hasSecQuestion ?? false), + const SizedBox(width: 4), + Text( + user?.hasSecQuestion ?? false ? '已设置' : '未设置', + style: AppTypography.caption1.copyWith( + color: ext.textHint, + ), + ), + ], + ), + ), + _Divider(ext: ext), + _ActionRow( + ext: ext, + icon: CupertinoIcons.lock_shield_fill, + title: '安全与Token管理', + onTap: () => _showSecuritySheet(context, ref, ext), + ), ], ), ), @@ -400,61 +430,121 @@ void _showBindEmailDialog( UserModel? user, ) { final ext = AppTheme.ext(context); - final controller = TextEditingController(text: user?.email ?? ''); + final emailController = TextEditingController(text: user?.email ?? ''); + final secAnswerController = TextEditingController(); + String verifyMethod = 'receipt'; + final hasSec = user?.hasSecQuestion ?? false; + showCupertinoDialog( context: context, - builder: (ctx) => CupertinoAlertDialog( - title: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(CupertinoIcons.mail, size: 18, color: ext.accent), - const SizedBox(width: 6), - const Text('绑定邮箱'), - ], - ), - content: Padding( - padding: const EdgeInsets.only(top: 8), - child: CupertinoTextField( - controller: controller, - placeholder: '请输入邮箱地址', - keyboardType: TextInputType.emailAddress, - clearButtonMode: OverlayVisibilityMode.editing, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: CupertinoColors.systemGrey6, - borderRadius: AppRadius.mdBorder, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) => CupertinoAlertDialog( + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(CupertinoIcons.mail, size: 18, color: ext.accent), + const SizedBox(width: 6), + const Text('绑定邮箱'), + ], + ), + content: Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CupertinoTextField( + controller: emailController, + placeholder: '请输入邮箱地址', + keyboardType: TextInputType.emailAddress, + clearButtonMode: OverlayVisibilityMode.editing, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: CupertinoColors.systemGrey6, + borderRadius: AppRadius.mdBorder, + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: CupertinoSlidingSegmentedControl( + groupValue: verifyMethod, + onValueChanged: (value) { + if (value != null) { + setDialogState(() => verifyMethod = value); + } + }, + children: { + 'receipt': const Padding( + padding: EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Text('📧 回执', style: TextStyle(fontSize: 12)), + ), + if (hasSec) + 'sec_question': const Padding( + padding: EdgeInsets.symmetric( + horizontal: 4, + vertical: 4, + ), + child: Text('🛡️ 密保', style: TextStyle(fontSize: 12)), + ), + }, + ), + ), + if (verifyMethod == 'sec_question') ...[ + const SizedBox(height: 8), + CupertinoTextField( + controller: secAnswerController, + placeholder: '输入密保答案', + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: CupertinoColors.systemGrey6, + borderRadius: AppRadius.mdBorder, + ), + ), + ], + ], ), ), + actions: [ + CupertinoDialogAction( + child: const Text('取消'), + onPressed: () { + emailController.dispose(); + secAnswerController.dispose(); + Navigator.pop(ctx); + }, + ), + CupertinoDialogAction( + isDefaultAction: true, + child: const Text('确认'), + onPressed: () async { + final email = emailController.text.trim(); + Navigator.pop(ctx); + if (email.isEmpty || !email.contains('@')) { + AppToast.showWarning('请输入有效的邮箱地址'); + emailController.dispose(); + secAnswerController.dispose(); + return; + } + try { + await UserSecurityService.changeEmail( + email: email, + verifyMethod: verifyMethod, + secAnswer: verifyMethod == 'sec_question' + ? secAnswerController.text.trim() + : null, + ); + AppToast.showSuccess('邮箱绑定成功'); + ref.read(authProvider.notifier).refreshUser(); + } catch (e) { + AppToast.showError('绑定失败: $e'); + } finally { + emailController.dispose(); + secAnswerController.dispose(); + } + }, + ), + ], ), - actions: [ - CupertinoDialogAction( - child: const Text('取消'), - onPressed: () { - controller.dispose(); - Navigator.pop(ctx); - }, - ), - CupertinoDialogAction( - isDefaultAction: true, - child: const Text('确认'), - onPressed: () async { - final email = controller.text.trim(); - Navigator.pop(ctx); - controller.dispose(); - if (email.isEmpty || !email.contains('@')) { - AppToast.showWarning('请输入有效的邮箱地址'); - return; - } - try { - await UserSecurityService.changeEmail(email: email); - AppToast.showSuccess('邮箱绑定成功'); - ref.read(authProvider.notifier).refreshUser(); - } catch (e) { - AppToast.showError('绑定失败: $e'); - } - }, - ), - ], ), ); } @@ -565,3 +655,445 @@ Widget _buildVerifyBadge(AppThemeExtension ext, bool verified) { ), ); } + +void _showSecuritySheet( + BuildContext context, + WidgetRef ref, + AppThemeExtension ext, +) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => _SecuritySheet(ext: ext), + ); +} + +class _SecuritySheet extends ConsumerStatefulWidget { + const _SecuritySheet({required this.ext}); + + final AppThemeExtension ext; + + @override + ConsumerState<_SecuritySheet> createState() => _SecuritySheetState(); +} + +class _SecuritySheetState extends ConsumerState<_SecuritySheet> { + bool _checking = false; + bool _refreshing = false; + TokenCheckResult? _tokenResult; + String? _tokenPreview; + DateTime? _tokenExpiry; + + @override + void initState() { + super.initState(); + _loadTokenInfo(); + } + + Future _loadTokenInfo() async { + final token = await SecureStorage.authToken; + if (token != null && token.isNotEmpty) { + setState(() { + _tokenPreview = + '${token.substring(0, 8)}...${token.substring(token.length - 4)}'; + }); + } + } + + Future _checkToken() async { + setState(() => _checking = true); + final result = await AuthService.checkToken(); + if (mounted) { + setState(() { + _tokenResult = result; + _checking = false; + if (result.valid && result.expiresIn > 0) { + _tokenExpiry = DateTime.now().add( + Duration(seconds: result.expiresIn), + ); + } + }); + } + } + + Future _refreshToken() async { + setState(() => _refreshing = true); + final success = await AuthService.refreshToken(); + if (mounted) { + setState(() => _refreshing = false); + if (success) { + _tokenResult = null; + _loadTokenInfo(); + _checkToken(); + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.checkmark_circle_fill, + size: 20, + color: CupertinoColors.systemGreen, + ), + SizedBox(width: 8), + Text('刷新成功'), + ], + ), + content: const Text('Token已更新,新Token已保存。'), + actions: [ + CupertinoDialogAction( + child: const Text('好的'), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + ); + } else { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.xmark_circle_fill, + size: 20, + color: CupertinoColors.systemRed, + ), + SizedBox(width: 8), + Text('刷新失败'), + ], + ), + content: const Text('Token刷新失败,请重新登录。'), + actions: [ + CupertinoDialogAction( + child: const Text('好的'), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final ext = widget.ext; + return Container( + decoration: BoxDecoration( + color: ext.bgPrimary, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + width: 36, + height: 4, + decoration: BoxDecoration( + color: ext.textHint.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: Row( + children: [ + Row( + children: [ + Icon( + CupertinoIcons.lock_shield_fill, + size: 20, + color: ext.accent, + ), + const SizedBox(width: AppSpacing.sm), + Text( + '安全与Token管理', + style: AppTypography.title3.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const Spacer(), + CupertinoButton( + padding: EdgeInsets.zero, + minimumSize: const Size(32, 32), + onPressed: () => Navigator.pop(context), + child: Text( + '完成', + style: AppTypography.body.copyWith(color: ext.accent), + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.sm), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: _buildTokenStatusCard(ext), + ), + const SizedBox(height: AppSpacing.sm), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: _buildActionButtons(ext), + ), + const SizedBox(height: AppSpacing.sm), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: _buildWarningSection(ext), + ), + const SizedBox(height: AppSpacing.lg), + ], + ), + ), + ); + } + + Widget _buildTokenStatusCard(AppThemeExtension ext) { + final isValid = _tokenResult?.valid ?? false; + final expiresIn = _tokenResult?.expiresIn ?? 0; + final statusIcon = _checking + ? CupertinoIcons.hourglass + : isValid + ? CupertinoIcons.checkmark_circle_fill + : (_tokenResult != null + ? CupertinoIcons.xmark_circle_fill + : CupertinoIcons.question_circle); + final statusText = _checking + ? '检测中...' + : isValid + ? '有效 (剩余 ${_formatDuration(expiresIn)})' + : (_tokenResult != null ? '已失效' : '点击检测查看状态'); + + return GlassContainer( + depth: GlassDepth.elevated, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + statusIcon, + size: 20, + color: isValid + ? CupertinoColors.systemGreen + : ext.textSecondary, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + 'Token状态: $statusText', + style: AppTypography.subhead.copyWith(color: ext.textPrimary), + ), + ), + ], + ), + if (_tokenPreview != null) ...[ + const SizedBox(height: AppSpacing.xs), + Row( + children: [ + Icon(CupertinoIcons.lock_fill, size: 14, color: ext.textHint), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Text( + _tokenPreview!, + style: AppTypography.caption1.copyWith( + color: ext.textSecondary, + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ], + if (_tokenExpiry != null) ...[ + const SizedBox(height: AppSpacing.xs), + Row( + children: [ + Icon(CupertinoIcons.calendar, size: 14, color: ext.textHint), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Text( + '过期时间: ${_tokenExpiry?.year ?? 0}-${(_tokenExpiry?.month ?? 1).toString().padLeft(2, '0')}-${(_tokenExpiry?.day ?? 1).toString().padLeft(2, '0')} ' + '${(_tokenExpiry?.hour ?? 0).toString().padLeft(2, '0')}:${(_tokenExpiry?.minute ?? 0).toString().padLeft(2, '0')}', + style: AppTypography.caption1.copyWith( + color: ext.textSecondary, + ), + ), + ), + ], + ), + ], + ], + ), + ); + } + + Widget _buildActionButtons(AppThemeExtension ext) { + return GlassContainer( + depth: GlassDepth.elevated, + padding: EdgeInsets.zero, + child: Column( + children: [ + _buildSheetAction( + icon: CupertinoIcons.search, + title: '检测Token状态', + subtitle: '验证当前Token是否有效', + ext: ext, + isLoading: _checking, + onTap: _checkToken, + ), + Divider( + height: 0.5, + thickness: 0.5, + indent: AppSpacing.md, + color: ext.textHint.withValues(alpha: 0.2), + ), + _buildSheetAction( + icon: CupertinoIcons.arrow_clockwise, + title: '刷新Token', + subtitle: '获取新Token,旧Token立即失效', + ext: ext, + isLoading: _refreshing, + onTap: _refreshToken, + ), + Divider( + height: 0.5, + thickness: 0.5, + indent: AppSpacing.md, + color: ext.textHint.withValues(alpha: 0.2), + ), + _buildSheetAction( + icon: CupertinoIcons.trash_fill, + title: '清除Token', + subtitle: '删除本地Token,需重新登录', + ext: ext, + onTap: () async { + await SecureStorage.removeAuthToken(); + ref.read(authProvider.notifier).logout(); + if (mounted) { + Navigator.pop(context); + context.go(AppRoutes.login); + } + }, + ), + ], + ), + ); + } + + Widget _buildSheetAction({ + required IconData icon, + required String title, + required String subtitle, + required AppThemeExtension ext, + required VoidCallback onTap, + bool isLoading = false, + }) { + return CupertinoButton( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + onPressed: isLoading ? null : onTap, + child: Row( + children: [ + if (isLoading) + const CupertinoActivityIndicator() + else + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: ext.accent.withValues(alpha: 0.12), + borderRadius: AppRadius.smBorder, + ), + child: Icon(icon, size: 16, color: ext.accent), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: AppTypography.body.copyWith(color: ext.textPrimary), + ), + Text( + subtitle, + style: AppTypography.caption2.copyWith( + color: ext.textSecondary, + ), + ), + ], + ), + ), + if (!isLoading) + Icon(CupertinoIcons.chevron_right, size: 16, color: ext.textHint), + ], + ), + ); + } + + Widget _buildWarningSection(AppThemeExtension ext) { + return Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: CupertinoColors.systemOrange.withValues(alpha: 0.08), + borderRadius: AppRadius.mdBorder, + border: Border.all( + color: CupertinoColors.systemOrange.withValues(alpha: 0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + CupertinoIcons.exclamationmark_triangle_fill, + size: 16, + color: CupertinoColors.systemOrange, + ), + const SizedBox(width: AppSpacing.xs), + Text( + '安全须知', + style: AppTypography.subhead.copyWith( + color: CupertinoColors.systemOrange, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xs), + Text( + '• 刷新Token后,旧Token即刻失效\n' + '• Token过期后需重新登录获取新Token\n' + '• 请勿将Token分享给他人,Token等同于登录凭证\n' + '• 建议定期修改密码以确保账户安全', + style: AppTypography.caption1.copyWith( + color: ext.textSecondary, + height: 1.6, + ), + ), + ], + ), + ); + } + + String _formatDuration(int seconds) { + if (seconds <= 0) return '已过期'; + final days = seconds ~/ 86400; + final hours = (seconds % 86400) ~/ 3600; + if (days > 0) return '$days天$hours小时'; + final minutes = (seconds % 3600) ~/ 60; + if (hours > 0) return '$hours小时$minutes分钟'; + return '$minutes分钟'; + } +} diff --git a/lib/features/settings/presentation/change_password_page.dart b/lib/features/settings/presentation/change_password_page.dart index 5ae67cde..03ea1e2c 100644 --- a/lib/features/settings/presentation/change_password_page.dart +++ b/lib/features/settings/presentation/change_password_page.dart @@ -1,11 +1,13 @@ /// ============================================================ /// 闲言APP — 修改密码页面 /// 创建时间: 2026-04-28 -/// 更新时间: 2026-05-04 -/// 作用: 修改用户密码 -/// 上次更新: emoji替换CupertinoIcon +/// 更新时间: 2026-05-15 +/// 作用: 修改用户密码,支持多验证方式(原密码/密保/邮箱验证码) +/// 上次更新: v10.2.0 添加邮箱验证码发送步骤和逻辑 /// ============================================================ +import 'dart:async'; + import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -17,35 +19,128 @@ import '../../../core/theme/app_radius.dart'; import '../../../shared/widgets/glass_container.dart'; import '../../../shared/widgets/app_toast.dart'; import '../../auth/providers/auth_provider.dart'; +import '../../auth/services/user_security_service.dart'; class ChangePasswordPage extends ConsumerStatefulWidget { const ChangePasswordPage({super.key}); @override - ConsumerState createState() => _ChangePasswordPageState(); + ConsumerState createState() => + _ChangePasswordPageState(); } class _ChangePasswordPageState extends ConsumerState { - final _currentPasswordController = TextEditingController(); + final _verifyController = TextEditingController(); final _newPasswordController = TextEditingController(); final _confirmPasswordController = TextEditingController(); - bool _obscureCurrent = true; + final _emailCodeController = TextEditingController(); + bool _obscureVerify = true; bool _obscureNew = true; bool _obscureConfirm = true; bool _isLoading = false; + String _verifyMethod = 'password'; + + bool _isSendingCode = false; + bool _emailVerified = false; + int _countdown = 0; + Timer? _countdownTimer; @override void dispose() { - _currentPasswordController.dispose(); + _verifyController.dispose(); _newPasswordController.dispose(); _confirmPasswordController.dispose(); + _emailCodeController.dispose(); + _countdownTimer?.cancel(); super.dispose(); } + AppThemeExtension get ext => AppTheme.ext(context); + + bool get _hasSecQuestion { + final user = ref.read(authProvider).user; + return user?.hasSecQuestion ?? false; + } + + String get _userEmail { + final user = ref.read(authProvider).user; + return user?.email ?? ''; + } + + String get _maskedEmail { + final email = _userEmail; + if (email.isEmpty) return '未绑定邮箱'; + final parts = email.split('@'); + if (parts.length != 2) return email; + final name = parts[0]; + final domain = parts[1]; + if (name.length <= 2) return '${name[0]}***@$domain'; + return '${name.substring(0, 2)}***@$domain'; + } + + void _startCountdown() { + _countdownTimer?.cancel(); + setState(() => _countdown = 60); + _countdownTimer = Timer.periodic( + const Duration(seconds: 1), + (timer) { + if (_countdown <= 1) { + timer.cancel(); + if (mounted) setState(() => _countdown = 0); + } else { + if (mounted) setState(() => _countdown--); + } + }, + ); + } + + Future _sendEmailCode() async { + if (_userEmail.isEmpty) { + AppToast.showWarning('请先绑定邮箱'); + return; + } + setState(() => _isSendingCode = true); + try { + await UserSecurityService.sendEms( + email: _userEmail, + event: EmsEvent.changepwd, + ); + if (mounted) { + AppToast.showSuccess('验证码已发送至 $_maskedEmail'); + _startCountdown(); + } + } catch (e) { + if (mounted) AppToast.showError('发送验证码失败: $e'); + } finally { + if (mounted) setState(() => _isSendingCode = false); + } + } + + Future _verifyEmailCode() async { + final code = _emailCodeController.text.trim(); + if (code.isEmpty) { + AppToast.showWarning('请输入邮箱验证码'); + return false; + } + try { + final valid = await UserSecurityService.checkEms( + email: _userEmail, + captcha: code, + event: EmsEvent.changepwd, + ); + if (!valid) { + if (mounted) AppToast.showError('验证码错误或已过期'); + return false; + } + return true; + } catch (e) { + if (mounted) AppToast.showError('验证码校验失败: $e'); + return false; + } + } + @override Widget build(BuildContext context) { - final ext = AppTheme.ext(context); - return CupertinoPageScaffold( backgroundColor: ext.bgPrimary, navigationBar: CupertinoNavigationBar( @@ -73,126 +168,313 @@ class _ChangePasswordPageState extends ConsumerState { padding: const EdgeInsets.all(AppSpacing.md), children: [ const SizedBox(height: AppSpacing.lg), - GlassContainer( - depth: GlassDepth.elevated, - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - CupertinoIcons.lock_shield_fill, - size: 18, - color: ext.accent, - ), - const SizedBox(width: 6), - Text( - '安全验证', - style: AppTypography.headline.copyWith( - color: ext.textPrimary, - ), - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - Text( - '请输入当前密码和新密码完成修改', - style: AppTypography.subhead.copyWith( - color: ext.textSecondary, - ), - ), - const SizedBox(height: AppSpacing.lg), - _buildPasswordField( - ext: ext, - controller: _currentPasswordController, - placeholder: '当前密码', - obscure: _obscureCurrent, - onToggle: () => - setState(() => _obscureCurrent = !_obscureCurrent), - ), - const SizedBox(height: AppSpacing.md), - _buildPasswordField( - ext: ext, - controller: _newPasswordController, - placeholder: '新密码', - obscure: _obscureNew, - onToggle: () => setState(() => _obscureNew = !_obscureNew), - ), - const SizedBox(height: AppSpacing.md), - _buildPasswordField( - ext: ext, - controller: _confirmPasswordController, - placeholder: '确认新密码', - obscure: _obscureConfirm, - onToggle: () => - setState(() => _obscureConfirm = !_obscureConfirm), - ), - const SizedBox(height: AppSpacing.lg), - SizedBox( - width: double.infinity, - child: CupertinoButton( - color: ext.accent, - borderRadius: AppRadius.mdBorder, - onPressed: _isLoading ? null : _handleChangePassword, - child: _isLoading - ? const CupertinoActivityIndicator( - color: CupertinoColors.white, - ) - : Text( - '确认修改', - style: AppTypography.subhead.copyWith( - color: CupertinoColors.white, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ], - ), - ), + _buildVerifySection(), const SizedBox(height: AppSpacing.md), - GlassContainer( - padding: const EdgeInsets.all(AppSpacing.md), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - CupertinoIcons.lightbulb, - size: 16, - color: ext.accent, - ), - const SizedBox(width: 6), - Text( - '密码要求', - style: AppTypography.subhead.copyWith( - color: ext.textPrimary, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - const SizedBox(height: AppSpacing.sm), - _buildRequirement(ext, '至少 6 个字符'), - _buildRequirement(ext, '包含字母和数字'), - _buildRequirement(ext, '不能与当前密码相同'), - ], - ), - ), + _buildNewPasswordSection(), + const SizedBox(height: AppSpacing.md), + _buildRequirementsCard(), + const SizedBox(height: AppSpacing.xl), + _buildSubmitButton(), + const SizedBox(height: AppSpacing.xxl), ], ), ), ); } + Widget _buildVerifySection() { + final verifyOptions = >[ + {'value': 'password', 'label': '🔑 原密码'}, + ]; + if (_hasSecQuestion) { + verifyOptions.add({'value': 'sec_question', 'label': '🛡️ 密保'}); + } + verifyOptions.add({'value': 'receipt', 'label': '📧 邮箱'}); + + return GlassContainer( + depth: GlassDepth.elevated, + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + CupertinoIcons.lock_shield_fill, + size: 18, + color: ext.accent, + ), + const SizedBox(width: 6), + Text( + '身份验证', + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Text( + '请选择一种验证方式', + style: AppTypography.subhead.copyWith(color: ext.textSecondary), + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: CupertinoSlidingSegmentedControl( + groupValue: _verifyMethod, + onValueChanged: (value) { + if (value != null) { + setState(() { + _verifyMethod = value; + _verifyController.clear(); + _emailCodeController.clear(); + _emailVerified = false; + }); + } + }, + children: { + for (final opt in verifyOptions) + opt['value'] as String: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 6, + ), + child: Text( + opt['label'] as String, + style: AppTypography.caption1, + ), + ), + }, + ), + ), + if (_verifyMethod == 'receipt') ...[ + const SizedBox(height: AppSpacing.md), + _buildEmailVerifySection(), + ] else ...[ + const SizedBox(height: AppSpacing.md), + _buildPasswordField( + controller: _verifyController, + placeholder: _verifyMethod == 'password' + ? '当前密码' + : '密保答案', + obscure: _verifyMethod == 'password' ? _obscureVerify : false, + onToggle: () => setState(() => _obscureVerify = !_obscureVerify), + showToggle: _verifyMethod == 'password', + ), + ], + ], + ), + ); + } + + Widget _buildEmailVerifySection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(CupertinoIcons.mail_solid, size: 16, color: ext.accent), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Text( + '验证邮箱:$_maskedEmail', + style: AppTypography.subhead.copyWith(color: ext.textPrimary), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: AppRadius.mdBorder, + ), + child: CupertinoTextField( + controller: _emailCodeController, + placeholder: '输入邮箱验证码', + placeholderStyle: AppTypography.body.copyWith( + color: ext.textHint, + ), + style: AppTypography.body.copyWith(color: ext.textPrimary), + keyboardType: TextInputType.number, + decoration: null, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + onChanged: (_) { + if (_emailVerified) { + setState(() => _emailVerified = false); + } + }, + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + SizedBox( + height: 44, + child: CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + color: _countdown > 0 + ? ext.textHint.withValues(alpha: 0.3) + : ext.accent, + borderRadius: AppRadius.mdBorder, + onPressed: _countdown > 0 || _isSendingCode + ? null + : _sendEmailCode, + child: _isSendingCode + ? const CupertinoActivityIndicator( + color: CupertinoColors.white, + ) + : Text( + _countdown > 0 ? '${_countdown}s' : '发送验证码', + style: AppTypography.caption1.copyWith( + color: CupertinoColors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + if (_emailVerified) ...[ + const SizedBox(height: AppSpacing.xs), + Row( + children: [ + const Icon( + CupertinoIcons.checkmark_circle_fill, + size: 14, + color: CupertinoColors.systemGreen, + ), + const SizedBox(width: 4), + Text( + '邮箱验证成功', + style: AppTypography.caption1.copyWith( + color: CupertinoColors.systemGreen, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + const SizedBox(height: AppSpacing.xs), + Row( + children: [ + Icon( + CupertinoIcons.info_circle, + size: 14, + color: ext.textHint, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + '验证码将发送至您的绑定邮箱,60秒内有效', + style: AppTypography.caption2.copyWith(color: ext.textHint), + ), + ), + ], + ), + ], + ); + } + + Widget _buildNewPasswordSection() { + return GlassContainer( + depth: GlassDepth.elevated, + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(CupertinoIcons.lock_fill, size: 18, color: ext.accent), + const SizedBox(width: 6), + Text( + '设置新密码', + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.lg), + _buildPasswordField( + controller: _newPasswordController, + placeholder: '新密码', + obscure: _obscureNew, + onToggle: () => setState(() => _obscureNew = !_obscureNew), + ), + const SizedBox(height: AppSpacing.md), + _buildPasswordField( + controller: _confirmPasswordController, + placeholder: '确认新密码', + obscure: _obscureConfirm, + onToggle: () => + setState(() => _obscureConfirm = !_obscureConfirm), + ), + ], + ), + ); + } + + Widget _buildRequirementsCard() { + return GlassContainer( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(CupertinoIcons.lightbulb, size: 16, color: ext.accent), + const SizedBox(width: 6), + Text( + '密码要求', + style: AppTypography.subhead.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + _buildRequirement('至少 6 个字符'), + _buildRequirement('包含字母和数字'), + _buildRequirement('不能与当前密码相同'), + ], + ), + ); + } + + Widget _buildSubmitButton() { + return SizedBox( + width: double.infinity, + child: CupertinoButton( + color: ext.accent, + borderRadius: AppRadius.mdBorder, + onPressed: _isLoading ? null : _handleChangePassword, + child: _isLoading + ? const CupertinoActivityIndicator(color: CupertinoColors.white) + : Text( + '确认修改', + style: AppTypography.subhead.copyWith( + color: CupertinoColors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } + Widget _buildPasswordField({ - required AppThemeExtension ext, required TextEditingController controller, required String placeholder, required bool obscure, required VoidCallback onToggle, + bool showToggle = true, }) { return Container( decoration: BoxDecoration( @@ -217,21 +499,22 @@ class _ChangePasswordPageState extends ConsumerState { ), ), ), - CupertinoButton( - padding: const EdgeInsets.only(right: AppSpacing.sm), - onPressed: onToggle, - child: Icon( - obscure ? CupertinoIcons.eye_slash : CupertinoIcons.eye, - size: 18, - color: ext.textHint, + if (showToggle) + CupertinoButton( + padding: const EdgeInsets.only(right: AppSpacing.sm), + onPressed: onToggle, + child: Icon( + obscure ? CupertinoIcons.eye_slash : CupertinoIcons.eye, + size: 18, + color: ext.textHint, + ), ), - ), ], ), ); } - Widget _buildRequirement(AppThemeExtension ext, String text) { + Widget _buildRequirement(String text) { return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( @@ -250,13 +533,27 @@ class _ChangePasswordPageState extends ConsumerState { ); } - void _handleChangePassword() async { - final current = _currentPasswordController.text.trim(); + Future _handleChangePassword() async { final newPwd = _newPasswordController.text.trim(); final confirm = _confirmPasswordController.text.trim(); - if (current.isEmpty || newPwd.isEmpty || confirm.isEmpty) { - AppToast.showWarning('请填写所有密码字段'); + if (_verifyMethod == 'receipt') { + if (!_emailVerified) { + final codeValid = await _verifyEmailCode(); + if (!codeValid) return; + setState(() => _emailVerified = true); + } + } else { + final verifyValue = _verifyController.text.trim(); + if (verifyValue.isEmpty) { + AppToast.showWarning( + _verifyMethod == 'password' ? '请输入当前密码' : '请输入密保答案', + ); + return; + } + } + if (newPwd.isEmpty || confirm.isEmpty) { + AppToast.showWarning('请填写新密码'); return; } if (newPwd.length < 6) { @@ -267,10 +564,6 @@ class _ChangePasswordPageState extends ConsumerState { AppToast.showWarning('两次输入的新密码不一致'); return; } - if (current == newPwd) { - AppToast.showWarning('新密码不能与当前密码相同'); - return; - } setState(() => _isLoading = true); try { @@ -278,16 +571,28 @@ class _ChangePasswordPageState extends ConsumerState { final userId = authState.user?.id.toString() ?? ''; final authNotifier = ref.read(authProvider.notifier); final success = await authNotifier.changePassword( - oldPassword: current, newPassword: newPwd, + oldPassword: _verifyMethod == 'password' + ? _verifyController.text.trim() + : null, + secAnswer: _verifyMethod == 'sec_question' + ? _verifyController.text.trim() + : null, userId: userId, + verifyMethod: _verifyMethod, ); if (!mounted) return; if (success) { AppToast.showSuccess('密码修改成功'); context.pop(); } else { - AppToast.showError('当前密码不正确'); + AppToast.showError( + _verifyMethod == 'password' + ? '当前密码不正确' + : _verifyMethod == 'sec_question' + ? '密保答案不正确' + : '验证失败', + ); } } catch (e) { AppToast.showError('修改失败,请重试'); diff --git a/lib/features/settings/presentation/data_management_page.dart b/lib/features/settings/presentation/data_management_page.dart index 2e7229ff..c39d8794 100644 --- a/lib/features/settings/presentation/data_management_page.dart +++ b/lib/features/settings/presentation/data_management_page.dart @@ -22,7 +22,7 @@ import 'package:share_plus/share_plus.dart' as share_plus; import 'package:file_picker/file_picker.dart'; import 'package:hive/hive.dart'; -import '../../../core/services/backup_service.dart'; +import '../../../core/services/data/backup_service.dart'; import '../../../core/storage/database/app_database.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_spacing.dart'; diff --git a/lib/features/settings/presentation/general_settings_page.dart b/lib/features/settings/presentation/general_settings_page.dart index 3a345323..53c4f077 100644 --- a/lib/features/settings/presentation/general_settings_page.dart +++ b/lib/features/settings/presentation/general_settings_page.dart @@ -13,9 +13,9 @@ import 'package:go_router/go_router.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../core/router/app_router.dart'; -import '../../../core/services/haptic_service.dart'; -import '../../../core/services/settings_export_service.dart'; -import '../../../core/services/data_export_service.dart'; +import '../../../core/services/device/haptic_service.dart'; +import '../../../core/services/data/settings_export_service.dart'; +import '../../../core/services/data/data_export_service.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../core/theme/app_typography.dart'; diff --git a/lib/features/settings/presentation/more_settings_page.dart b/lib/features/settings/presentation/more_settings_page.dart index 4a560b08..160f3399 100644 --- a/lib/features/settings/presentation/more_settings_page.dart +++ b/lib/features/settings/presentation/more_settings_page.dart @@ -10,7 +10,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart' show Divider; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/services/haptic_service.dart'; +import '../../../core/services/device/haptic_service.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../core/theme/app_typography.dart'; diff --git a/lib/features/settings/presentation/notification_settings_page.dart b/lib/features/settings/presentation/notification_settings_page.dart index ce6ed593..9d85bcc8 100644 --- a/lib/features/settings/presentation/notification_settings_page.dart +++ b/lib/features/settings/presentation/notification_settings_page.dart @@ -11,10 +11,10 @@ import 'package:flutter/material.dart' show Divider, TimeOfDay; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../../../core/services/haptic_service.dart'; -import '../../../core/services/notification_scheduler.dart'; -import '../../../core/services/notification_service.dart'; -import '../../../core/services/readlater_reminder_service.dart'; +import '../../../core/services/device/haptic_service.dart'; +import '../../../core/services/notification/notification_scheduler.dart'; +import '../../../core/services/notification/notification_service.dart'; +import '../../../core/services/notification/readlater_reminder_service.dart'; import '../../../core/theme/app_radius.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../core/theme/app_theme.dart'; @@ -78,7 +78,8 @@ class NotificationSettingsState { chargingReadLater: chargingReadLater ?? this.chargingReadLater, fortuneReminder: fortuneReminder ?? this.fortuneReminder, fortuneReminderHour: fortuneReminderHour ?? this.fortuneReminderHour, - fortuneReminderMinute: fortuneReminderMinute ?? this.fortuneReminderMinute, + fortuneReminderMinute: + fortuneReminderMinute ?? this.fortuneReminderMinute, ); } } @@ -215,7 +216,10 @@ class NotificationSettingsNotifier /// 设置运势推送时间 Future setFortuneReminderTime(int hour, int minute) async { - state = state.copyWith(fortuneReminderHour: hour, fortuneReminderMinute: minute); + state = state.copyWith( + fortuneReminderHour: hour, + fortuneReminderMinute: minute, + ); if (state.fortuneReminder) { await NotificationScheduler.setFortuneTime(hour, minute); } diff --git a/lib/features/settings/presentation/permission_management_page.dart b/lib/features/settings/presentation/permission_management_page.dart index 710c967a..cc86cc37 100644 --- a/lib/features/settings/presentation/permission_management_page.dart +++ b/lib/features/settings/presentation/permission_management_page.dart @@ -10,7 +10,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart' show RefreshIndicator; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/services/permission_service.dart'; +import '../../../core/services/auth/permission_service.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../core/theme/app_typography.dart'; diff --git a/lib/features/settings/presentation/security_question_page.dart b/lib/features/settings/presentation/security_question_page.dart new file mode 100644 index 00000000..934f6603 --- /dev/null +++ b/lib/features/settings/presentation/security_question_page.dart @@ -0,0 +1,710 @@ +/// ============================================================ +/// 闲言APP — 密保问题管理页面 +/// 创建时间: 2026-05-15 +/// 更新时间: 2026-05-15 +/// 作用: 设置/修改密保问题,支持多验证方式(含邮箱验证码) +/// 上次更新: v10.2.0 添加邮箱验证码发送步骤和逻辑 +/// ============================================================ + +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/theme/app_theme.dart'; +import '../../../core/theme/app_spacing.dart'; +import '../../../core/theme/app_typography.dart'; +import '../../../core/theme/app_radius.dart'; +import '../../../shared/widgets/glass_container.dart'; +import '../../../shared/widgets/app_toast.dart'; +import '../../auth/providers/auth_provider.dart'; +import '../../auth/services/auth_service.dart'; +import '../../auth/services/user_security_service.dart'; + +class SecurityQuestionPage extends ConsumerStatefulWidget { + const SecurityQuestionPage({super.key}); + + @override + ConsumerState createState() => + _SecurityQuestionPageState(); +} + +class _SecurityQuestionPageState extends ConsumerState { + List _questions = []; + int? _selectedQuestion; + String _selectedQuestionText = ''; + final _answerController = TextEditingController(); + final _verifyController = TextEditingController(); + final _emailCodeController = TextEditingController(); + String _verifyMethod = 'password'; + bool _isLoading = false; + + bool _isSendingCode = false; + bool _emailVerified = false; + int _countdown = 0; + Timer? _countdownTimer; + + @override + void initState() { + super.initState(); + _loadQuestions(); + } + + @override + void dispose() { + _answerController.dispose(); + _verifyController.dispose(); + _emailCodeController.dispose(); + _countdownTimer?.cancel(); + super.dispose(); + } + + Future _loadQuestions() async { + try { + final questions = await AuthService.secQuestions(); + if (mounted) { + setState(() { + _questions = questions; + }); + } + } catch (e) { + if (mounted) { + AppToast.showError('加载密保问题失败'); + } + } + } + + AppThemeExtension get ext => AppTheme.ext(context); + + bool get _hasSecQuestion { + final user = ref.read(authProvider).user; + return user?.hasSecQuestion ?? false; + } + + String get _currentQuestionText { + final user = ref.read(authProvider).user; + return user?.secQuestionText ?? ''; + } + + String get _userEmail { + final user = ref.read(authProvider).user; + return user?.email ?? ''; + } + + String get _maskedEmail { + final email = _userEmail; + if (email.isEmpty) return '未绑定邮箱'; + final parts = email.split('@'); + if (parts.length != 2) return email; + final name = parts[0]; + final domain = parts[1]; + if (name.length <= 2) return '${name[0]}***@$domain'; + return '${name.substring(0, 2)}***@$domain'; + } + + void _startCountdown() { + _countdownTimer?.cancel(); + setState(() => _countdown = 60); + _countdownTimer = Timer.periodic( + const Duration(seconds: 1), + (timer) { + if (_countdown <= 1) { + timer.cancel(); + if (mounted) setState(() => _countdown = 0); + } else { + if (mounted) setState(() => _countdown--); + } + }, + ); + } + + Future _sendEmailCode() async { + if (_userEmail.isEmpty) { + AppToast.showWarning('请先绑定邮箱'); + return; + } + setState(() => _isSendingCode = true); + try { + await UserSecurityService.sendEms( + email: _userEmail, + event: EmsEvent.changesecquestion, + ); + if (mounted) { + AppToast.showSuccess('验证码已发送至 $_maskedEmail'); + _startCountdown(); + } + } catch (e) { + if (mounted) AppToast.showError('发送验证码失败: $e'); + } finally { + if (mounted) setState(() => _isSendingCode = false); + } + } + + Future _verifyEmailCode() async { + final code = _emailCodeController.text.trim(); + if (code.isEmpty) { + AppToast.showWarning('请输入邮箱验证码'); + return false; + } + try { + final valid = await UserSecurityService.checkEms( + email: _userEmail, + captcha: code, + event: EmsEvent.changesecquestion, + ); + if (!valid) { + if (mounted) AppToast.showError('验证码错误或已过期'); + return false; + } + return true; + } catch (e) { + if (mounted) AppToast.showError('验证码校验失败: $e'); + return false; + } + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + + return CupertinoPageScaffold( + backgroundColor: ext.bgPrimary, + navigationBar: CupertinoNavigationBar( + middle: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(CupertinoIcons.shield, size: 18, color: ext.textPrimary), + const SizedBox(width: 6), + Text( + '密保问题', + style: AppTypography.title3.copyWith(color: ext.textPrimary), + ), + ], + ), + backgroundColor: ext.bgElevated.withValues(alpha: 0.85), + border: null, + leading: CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () => context.pop(), + child: Icon(CupertinoIcons.back, color: ext.textPrimary), + ), + ), + child: SafeArea( + child: ListView( + padding: const EdgeInsets.all(AppSpacing.md), + children: [ + const SizedBox(height: AppSpacing.lg), + _buildStatusCard(authState), + const SizedBox(height: AppSpacing.md), + _buildVerifySection(), + const SizedBox(height: AppSpacing.md), + _buildNewQuestionSection(), + const SizedBox(height: AppSpacing.xl), + _buildSubmitButton(), + const SizedBox(height: AppSpacing.xxl), + ], + ), + ), + ); + } + + Widget _buildStatusCard(AuthState authState) { + final hasSec = _hasSecQuestion; + return GlassContainer( + depth: GlassDepth.elevated, + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + hasSec + ? CupertinoIcons.checkmark_shield_fill + : CupertinoIcons.shield, + size: 20, + color: hasSec + ? CupertinoColors.systemGreen + : CupertinoColors.systemOrange, + ), + const SizedBox(width: AppSpacing.sm), + Text( + hasSec ? '密保已设置' : '密保未设置', + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + if (hasSec) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + '当前密保问题:$_currentQuestionText', + style: AppTypography.subhead.copyWith(color: ext.textSecondary), + ), + ], + const SizedBox(height: AppSpacing.sm), + Text( + hasSec + ? '修改密保需验证身份,支持密码、原密保答案或邮箱验证' + : '设置密保问题可增强账号安全,用于找回密码和身份验证', + style: AppTypography.footnote.copyWith(color: ext.textHint), + ), + ], + ), + ); + } + + Widget _buildVerifySection() { + final hasSec = _hasSecQuestion; + final verifyOptions = >[ + { + 'value': 'password', + 'label': '🔑 密码验证', + 'hint': '输入当前密码', + }, + ]; + if (hasSec) { + verifyOptions.add({ + 'value': 'sec_question', + 'label': '🛡️ 密保验证', + 'hint': '输入当前密保答案', + }); + } + verifyOptions.add({ + 'value': 'receipt', + 'label': '📧 邮箱验证', + 'hint': '通过邮箱验证码验证', + }); + + return GlassContainer( + depth: GlassDepth.elevated, + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(CupertinoIcons.lock_shield_fill, size: 16, color: ext.accent), + const SizedBox(width: 6), + Text( + '身份验证', + style: AppTypography.subhead.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Text( + '请选择一种验证方式', + style: AppTypography.footnote.copyWith(color: ext.textSecondary), + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: CupertinoSlidingSegmentedControl( + groupValue: _verifyMethod, + onValueChanged: (value) { + if (value != null) { + setState(() { + _verifyMethod = value; + _verifyController.clear(); + _emailCodeController.clear(); + _emailVerified = false; + }); + } + }, + children: { + for (final opt in verifyOptions) + opt['value'] as String: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 6, + ), + child: Text( + opt['label'] as String, + style: AppTypography.caption1, + ), + ), + }, + ), + ), + if (_verifyMethod == 'receipt') ...[ + const SizedBox(height: AppSpacing.md), + _buildEmailVerifySection(), + ] else ...[ + const SizedBox(height: AppSpacing.md), + Container( + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: AppRadius.mdBorder, + ), + child: CupertinoTextField( + controller: _verifyController, + placeholder: _verifyMethod == 'password' ? '输入当前密码' : '输入当前密保答案', + placeholderStyle: AppTypography.body.copyWith( + color: ext.textHint, + ), + style: AppTypography.body.copyWith(color: ext.textPrimary), + obscureText: _verifyMethod == 'password', + decoration: null, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + ), + ), + ], + ], + ), + ); + } + + Widget _buildEmailVerifySection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(CupertinoIcons.mail_solid, size: 16, color: ext.accent), + const SizedBox(width: AppSpacing.xs), + Expanded( + child: Text( + '验证邮箱:$_maskedEmail', + style: AppTypography.subhead.copyWith(color: ext.textPrimary), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: AppRadius.mdBorder, + ), + child: CupertinoTextField( + controller: _emailCodeController, + placeholder: '输入邮箱验证码', + placeholderStyle: AppTypography.body.copyWith( + color: ext.textHint, + ), + style: AppTypography.body.copyWith(color: ext.textPrimary), + keyboardType: TextInputType.number, + decoration: null, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + onChanged: (_) { + if (_emailVerified) { + setState(() => _emailVerified = false); + } + }, + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + SizedBox( + height: 44, + child: CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + color: _countdown > 0 + ? ext.textHint.withValues(alpha: 0.3) + : ext.accent, + borderRadius: AppRadius.mdBorder, + onPressed: _countdown > 0 || _isSendingCode + ? null + : _sendEmailCode, + child: _isSendingCode + ? const CupertinoActivityIndicator( + color: CupertinoColors.white, + ) + : Text( + _countdown > 0 ? '${_countdown}s' : '发送验证码', + style: AppTypography.caption1.copyWith( + color: CupertinoColors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + if (_emailVerified) ...[ + const SizedBox(height: AppSpacing.xs), + Row( + children: [ + const Icon( + CupertinoIcons.checkmark_circle_fill, + size: 14, + color: CupertinoColors.systemGreen, + ), + const SizedBox(width: 4), + Text( + '邮箱验证成功', + style: AppTypography.caption1.copyWith( + color: CupertinoColors.systemGreen, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + const SizedBox(height: AppSpacing.xs), + Row( + children: [ + Icon( + CupertinoIcons.info_circle, + size: 14, + color: ext.textHint, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + '验证码将发送至您的绑定邮箱,60秒内有效', + style: AppTypography.caption2.copyWith(color: ext.textHint), + ), + ), + ], + ), + ], + ); + } + + Widget _buildNewQuestionSection() { + return GlassContainer( + depth: GlassDepth.elevated, + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(CupertinoIcons.shield_fill, size: 16, color: ext.accent), + const SizedBox(width: 6), + Text( + '新密保问题', + style: AppTypography.subhead.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + CupertinoButton( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + color: ext.bgSecondary, + borderRadius: AppRadius.mdBorder, + onPressed: _questions.isEmpty ? null : () => _showQuestionPicker(), + child: Row( + children: [ + Icon( + CupertinoIcons.question_circle, + size: 16, + color: ext.textSecondary, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + _selectedQuestionText.isNotEmpty + ? _selectedQuestionText + : '选择密保问题', + style: AppTypography.subhead.copyWith( + color: _selectedQuestionText.isNotEmpty + ? ext.textPrimary + : ext.textHint, + ), + ), + ), + Icon(CupertinoIcons.chevron_right, size: 14, color: ext.textHint), + ], + ), + ), + const SizedBox(height: AppSpacing.md), + Container( + decoration: BoxDecoration( + color: ext.bgSecondary, + borderRadius: AppRadius.mdBorder, + ), + child: CupertinoTextField( + controller: _answerController, + placeholder: '输入密保答案(1-50位)', + placeholderStyle: AppTypography.body.copyWith(color: ext.textHint), + style: AppTypography.body.copyWith(color: ext.textPrimary), + decoration: null, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm + 2, + ), + maxLength: 50, + ), + ), + ], + ), + ); + } + + Widget _buildSubmitButton() { + return SizedBox( + width: double.infinity, + child: CupertinoButton( + color: ext.accent, + borderRadius: AppRadius.mdBorder, + onPressed: _isLoading ? null : _handleSubmit, + child: _isLoading + ? const CupertinoActivityIndicator(color: CupertinoColors.white) + : Text( + _hasSecQuestion ? '确认修改' : '确认设置', + style: AppTypography.subhead.copyWith( + color: CupertinoColors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } + + void _showQuestionPicker() { + showCupertinoModalPopup( + context: context, + builder: (ctx) => Container( + height: 260, + color: ext.bgElevated.withValues(alpha: 0.95), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CupertinoButton( + child: Text( + '取消', + style: AppTypography.subhead.copyWith( + color: ext.textSecondary, + ), + ), + onPressed: () => Navigator.pop(ctx), + ), + Text( + '选择密保问题', + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + CupertinoButton( + child: Text( + '确定', + style: AppTypography.subhead.copyWith( + color: ext.accent, + fontWeight: FontWeight.w600, + ), + ), + onPressed: () => Navigator.pop(ctx), + ), + ], + ), + Expanded( + child: CupertinoPicker( + itemExtent: 36, + scrollController: FixedExtentScrollController( + initialItem: _selectedQuestion != null + ? _questions + .indexWhere((q) => q.id == _selectedQuestion) + .clamp(0, _questions.length - 1) + : 0, + ), + onSelectedItemChanged: (index) { + if (index < _questions.length) { + setState(() { + _selectedQuestion = _questions[index].id; + _selectedQuestionText = _questions[index].question; + }); + } + }, + children: _questions.map((q) { + return Center( + child: Text( + q.question, + style: AppTypography.subhead.copyWith( + color: ext.textPrimary, + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ), + ); + } + + Future _handleSubmit() async { + if (_selectedQuestion == null) { + AppToast.showWarning('请选择密保问题'); + return; + } + final answer = _answerController.text.trim(); + if (answer.isEmpty) { + AppToast.showWarning('请输入密保答案'); + return; + } + if (_verifyMethod != 'receipt') { + final verifyValue = _verifyController.text.trim(); + if (verifyValue.isEmpty) { + AppToast.showWarning( + _verifyMethod == 'password' ? '请输入当前密码' : '请输入当前密保答案', + ); + return; + } + } else { + if (!_emailVerified) { + final codeValid = await _verifyEmailCode(); + if (!codeValid) return; + setState(() => _emailVerified = true); + } + } + + final authState = ref.read(authProvider); + final userId = authState.user?.id.toString() ?? ''; + + setState(() => _isLoading = true); + try { + await AuthService.changeSecQuestion( + secQuestion: _selectedQuestion!, + secAnswer: answer, + verifyMethod: _verifyMethod, + oldPassword: _verifyMethod == 'password' + ? _verifyController.text.trim() + : null, + secAnswerOld: _verifyMethod == 'sec_question' + ? _verifyController.text.trim() + : null, + userId: userId, + ); + if (!mounted) return; + AppToast.showSuccess( + _hasSecQuestion ? '密保问题修改成功' : '密保问题设置成功', + ); + ref.read(authProvider.notifier).refreshUser(); + context.pop(); + } catch (e) { + if (mounted) { + AppToast.showError('操作失败: $e'); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } +} diff --git a/lib/features/settings/presentation/smart_mode_settings_page.dart b/lib/features/settings/presentation/smart_mode_settings_page.dart index 7ba1caa9..a8c215c9 100644 --- a/lib/features/settings/presentation/smart_mode_settings_page.dart +++ b/lib/features/settings/presentation/smart_mode_settings_page.dart @@ -10,8 +10,8 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_animate/flutter_animate.dart'; -import '../../../core/services/connectivity_service.dart'; -import '../../../core/services/haptic_service.dart'; +import '../../../core/services/network/connectivity_service.dart'; +import '../../../core/services/device/haptic_service.dart'; import '../../../core/services/smart_mode_service.dart'; import '../../../core/theme/app_radius.dart'; import '../../../core/theme/app_spacing.dart'; diff --git a/lib/features/settings/providers/general_settings_provider.dart b/lib/features/settings/providers/general_settings_provider.dart index 65834451..530585bd 100644 --- a/lib/features/settings/providers/general_settings_provider.dart +++ b/lib/features/settings/providers/general_settings_provider.dart @@ -11,13 +11,13 @@ import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; -import '../../../core/services/haptic_service.dart'; -import '../../../core/services/screen_wake_service.dart'; +import '../../../core/services/device/haptic_service.dart'; +import '../../../core/services/device/screen_wake_service.dart'; import '../../../core/services/sound_service.dart' as svc; -import '../../../core/services/app_lock_service.dart'; -import '../../../core/services/battery_optimization_service.dart'; -import '../../../core/services/network_proxy_service.dart'; -import '../../../core/services/notification_scheduler.dart'; +import '../../../core/services/device/app_lock_service.dart'; +import '../../../core/services/device/battery_optimization_service.dart'; +import '../../../core/services/network/network_proxy_service.dart'; +import '../../../core/services/notification/notification_scheduler.dart'; import '../../../core/storage/kv_storage.dart'; import '../../../core/utils/logger.dart'; diff --git a/lib/features/signin/presentation/signin_page.dart b/lib/features/signin/presentation/signin_page.dart index 0c84ff7b..c62a86c3 100644 --- a/lib/features/signin/presentation/signin_page.dart +++ b/lib/features/signin/presentation/signin_page.dart @@ -3,7 +3,7 @@ /// 创建时间: 2026-04-28 /// 更新时间: 2026-05-14 /// 作用: 每日签到界面,展示签到状态和奖励 + 庆祝效果 -/// 上次更新: 修复已签到日期显示'补'而非'签',使用signedDates+todaySigned +/// 上次更新: 签到卡片积分旁增加等级标签(Lv.N 称号) /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -22,6 +22,7 @@ import '../../../shared/widgets/app_icon.dart'; import '../../../shared/widgets/glass_container.dart'; import '../../../shared/widgets/offline_banner.dart'; import '../../../shared/widgets/responsive_layout.dart'; +import '../../../core/utils/level_utils.dart'; import '../../auth/providers/auth_provider.dart'; import '../providers/signin_provider.dart'; @@ -285,6 +286,35 @@ class _SigninPageState extends ConsumerState { color: ext.textSecondary, ), ), + const SizedBox(width: AppSpacing.sm), + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 2, + ), + decoration: BoxDecoration( + color: getLevelColor(user.level).withValues(alpha: 0.15), + borderRadius: AppRadius.pillBorder, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.star_fill, + size: 10, + color: getLevelColor(user.level), + ), + const SizedBox(width: 3), + Text( + getLevelBadge(user.level), + style: AppTypography.caption2.copyWith( + color: getLevelColor(user.level), + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), ], ), ], diff --git a/lib/features/signin/providers/signin_provider.dart b/lib/features/signin/providers/signin_provider.dart index 94b75883..976d994b 100644 --- a/lib/features/signin/providers/signin_provider.dart +++ b/lib/features/signin/providers/signin_provider.dart @@ -3,7 +3,7 @@ /// 创建时间: 2026-04-28 /// 更新时间: 2026-05-14 /// 作用: 每日签到/签到日历/补签功能状态管理 -/// 上次更新: 修复已签到日期显示'补'而非'签',新增signedDates字段和健壮日历解析 +/// 上次更新: 修复parseSignedDates无法识别Map类型签到记录的BUG /// ============================================================ import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -68,10 +68,13 @@ class SigninState { final cal = calendarData['calendar']; if (cal is Map) { cal.forEach((key, value) { - final isSigned = value == true || + final isSigned = + value == true || value == 1 || value == '1' || - value == 'true'; + value == 'true' || + value is Map || + (value is List && value.isNotEmpty); if (isSigned) { final normalized = _normalizeDate(key, year, month); if (normalized.isNotEmpty) result.add(normalized); @@ -81,7 +84,8 @@ class SigninState { for (final item in cal) { if (item is Map) { final date = item['date']?.toString() ?? ''; - final signed = item['signed'] == true || + final signed = + item['signed'] == true || item['signed'] == 1 || item['signed'] == '1' || item['signed'] == 'true'; @@ -91,7 +95,8 @@ class SigninState { } } else if (item is int) { if (item >= 1 && item <= 31) { - final normalized = '$year-${month.toString().padLeft(2, '0')}-${item.toString().padLeft(2, '0')}'; + final normalized = + '$year-${month.toString().padLeft(2, '0')}-${item.toString().padLeft(2, '0')}'; result.add(normalized); } } else { @@ -108,7 +113,8 @@ class SigninState { if (days is List) { for (final item in days) { if (item is int && item >= 1 && item <= 31) { - final normalized = '$year-${month.toString().padLeft(2, '0')}-${item.toString().padLeft(2, '0')}'; + final normalized = + '$year-${month.toString().padLeft(2, '0')}-${item.toString().padLeft(2, '0')}'; result.add(normalized); } else { final str = item?.toString() ?? ''; @@ -199,7 +205,8 @@ class SigninNotifier extends StateNotifier { signedDates: parsed, ); if (state.todaySigned) { - final todayStr = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + final todayStr = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; if (!parsed.contains(todayStr)) { final updated = Set.from(parsed)..add(todayStr); state = state.copyWith(signedDates: updated); @@ -225,6 +232,8 @@ class SigninNotifier extends StateNotifier { } } -final signinProvider = StateNotifierProvider((ref) { +final signinProvider = StateNotifierProvider(( + ref, +) { return SigninNotifier(); }); diff --git a/lib/features/task/presentation/daily_task_page.dart b/lib/features/task/presentation/daily_task_page.dart new file mode 100644 index 00000000..1f9798a3 --- /dev/null +++ b/lib/features/task/presentation/daily_task_page.dart @@ -0,0 +1,275 @@ +/// @name 每日任务页面 +/// @date 2026-05-14 +/// @desc 展示今日任务列表+进度+领取+完美日 +/// @update v12.0.0 初始版本 + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/task_provider.dart'; +import '../../../shared/widgets/task_card.dart'; + +class DailyTaskPage extends ConsumerStatefulWidget { + const DailyTaskPage({super.key}); + + @override + ConsumerState createState() => _DailyTaskPageState(); +} + +class _DailyTaskPageState extends ConsumerState { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(taskProvider.notifier).loadTodayTasks(); + }); + } + + @override + Widget build(BuildContext context) { + final taskState = ref.watch(taskProvider); + final brightness = MediaQuery.platformBrightnessOf(context); + final isDark = brightness == Brightness.dark; + + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('📋 每日任务'), + ), + child: SafeArea( + child: taskState.isLoading + ? const Center(child: CupertinoActivityIndicator()) + : CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + if (taskState.summary != null) + _buildSummary(taskState.summary!, isDark), + if (taskState.error != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(20), + child: Text( + taskState.error!, + style: const TextStyle(color: CupertinoColors.destructiveRed), + textAlign: TextAlign.center, + ), + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final task = taskState.tasks[index]; + return TaskCard( + icon: task.icon, + name: task.name, + progress: task.progress, + target: task.target, + percent: task.percent, + completed: task.completed, + claimed: task.claimed, + expReward: task.expReward, + scoreReward: task.scoreReward, + onClaim: task.completed && !task.claimed + ? () => _claimTask(task.id, task.name) + : null, + ); + }, + childCount: taskState.tasks.length, + ), + ), + if (taskState.summary?.isPerfectDay == true && + taskState.summary?.perfectClaimed == false) + SliverToBoxAdapter( + child: _buildPerfectDayCard(isDark), + ), + const SliverPadding(padding: EdgeInsets.only(bottom: 40)), + ], + ), + ), + ); + } + + Widget _buildSummary(TaskSummary summary, bool isDark) { + return SliverToBoxAdapter( + child: Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isDark + ? CupertinoColors.systemGrey6.darkColor + : CupertinoColors.systemBackground.color, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: CupertinoColors.separator.withValues(alpha: 0.5), + width: 0.5, + ), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatItem('📋', '总任务', summary.total, isDark), + _buildStatItem('✅', '已完成', summary.completed, isDark), + _buildStatItem('🎁', '已领取', summary.claimed, isDark), + ], + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: summary.total > 0 ? summary.completed / summary.total : 0, + backgroundColor: isDark + ? CupertinoColors.systemGrey4.darkColor + : CupertinoColors.systemGrey5.color, + valueColor: AlwaysStoppedAnimation( + summary.isPerfectDay + ? CupertinoColors.activeOrange + : CupertinoColors.activeBlue, + ), + minHeight: 8, + ), + ), + if (summary.isPerfectDay) ...[ + const SizedBox(height: 8), + Text( + '🎉 完美日!所有任务已完成', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: CupertinoColors.activeOrange.resolveFrom(context), + ), + ), + ], + ], + ), + ), + ); + } + + Widget _buildStatItem(String emoji, String label, int value, bool isDark) { + return Column( + children: [ + Text(emoji, style: const TextStyle(fontSize: 22)), + const SizedBox(height: 4), + Text( + '$value', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: isDark ? CupertinoColors.white : CupertinoColors.black, + ), + ), + Text( + label, + style: TextStyle( + fontSize: 12, + color: CupertinoColors.secondaryLabel.resolveFrom(context), + ), + ), + ], + ); + } + + Widget _buildPerfectDayCard(bool isDark) { + return Container( + margin: const EdgeInsets.fromLTRB(16, 12, 16, 8), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFFF9500), Color(0xFFFF3B30)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: CupertinoColors.activeOrange.withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + children: [ + const Text( + '🌟 完美日奖励', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: CupertinoColors.white, + ), + ), + const SizedBox(height: 8), + const Text( + '所有任务已完成!领取额外奖励', + style: TextStyle(fontSize: 14, color: CupertinoColors.white), + ), + const SizedBox(height: 4), + const Text( + '+20 💎 +10 ⭐', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: CupertinoColors.white, + ), + ), + const SizedBox(height: 12), + CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 8), + borderRadius: BorderRadius.circular(12), + color: CupertinoColors.white, + onPressed: _claimPerfectDay, + child: const Text( + '🎊 领取完美日奖励', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFFFF9500), + ), + ), + ), + ], + ), + ); + } + + Future _claimTask(int taskId, String taskName) async { + final result = await ref.read(taskProvider.notifier).claimReward(taskId); + if (result != null && mounted) { + _showRewardDialog(taskName, result); + } + } + + Future _claimPerfectDay() async { + final result = await ref.read(taskProvider.notifier).claimPerfectDay(); + if (result != null && mounted) { + _showRewardDialog('完美日', result); + } + } + + void _showRewardDialog(String name, Map data) { + final expReward = (data['exp_reward'] ?? 0) as int; + final scoreReward = (data['score_reward'] ?? 0) as int; + + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: Text('🎉 $name'), + content: Column( + children: [ + const SizedBox(height: 8), + if (expReward > 0) Text('+$expReward 💎 经验值'), + if (scoreReward > 0) Text('+$scoreReward ⭐ 积分'), + ], + ), + actions: [ + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () => Navigator.pop(ctx), + child: const Text('太棒了!'), + ), + ], + ), + ); + } +} diff --git a/lib/features/task/providers/task_provider.dart b/lib/features/task/providers/task_provider.dart new file mode 100644 index 00000000..23abfc1e --- /dev/null +++ b/lib/features/task/providers/task_provider.dart @@ -0,0 +1,178 @@ +/// @name 每日任务状态管理 +/// @date 2026-05-14 +/// @desc Riverpod状态管理: 今日任务列表/进度上报/领取奖励/完美日 +/// @update v12.0.1 修复: 类型转换错误, dynamic显式转换 + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../services/task_service.dart'; + +class DailyTask { + final int id; + final String name; + final String icon; + final String type; + final int target; + final String action; + final String customUrl; + final String customPage; + final int expReward; + final int scoreReward; + final int isRandom; + int progress; + bool completed; + bool claimed; + int percent; + + DailyTask({ + required this.id, + required this.name, + required this.icon, + required this.type, + required this.target, + required this.action, + required this.customUrl, + required this.customPage, + required this.expReward, + required this.scoreReward, + required this.isRandom, + required this.progress, + required this.completed, + required this.claimed, + required this.percent, + }); + + factory DailyTask.fromJson(Map json) { + return DailyTask( + id: (json['id'] ?? 0) as int, + name: (json['name'] ?? '') as String, + icon: (json['icon'] ?? '📋') as String, + type: (json['type'] ?? '') as String, + target: (json['target'] ?? 1) as int, + action: (json['action'] ?? '') as String, + customUrl: (json['custom_url'] ?? '') as String, + customPage: (json['custom_page'] ?? '') as String, + expReward: (json['exp_reward'] ?? 0) as int, + scoreReward: (json['score_reward'] ?? 0) as int, + isRandom: (json['is_random'] ?? 0) as int, + progress: (json['progress'] ?? 0) as int, + completed: json['completed'] == 1 || json['completed'] == true, + claimed: json['claimed'] == 1 || json['claimed'] == true, + percent: (json['percent'] ?? 0) as int, + ); + } +} + +class TaskSummary { + final int total; + final int completed; + final int claimed; + final bool isPerfectDay; + final bool perfectClaimed; + final String date; + + TaskSummary({ + required this.total, + required this.completed, + required this.claimed, + required this.isPerfectDay, + required this.perfectClaimed, + required this.date, + }); + + factory TaskSummary.fromJson(Map json) { + return TaskSummary( + total: (json['total'] ?? 0) as int, + completed: (json['completed'] ?? 0) as int, + claimed: (json['claimed'] ?? 0) as int, + isPerfectDay: + json['is_perfect_day'] == true || json['is_perfect_day'] == 1, + perfectClaimed: + json['perfect_claimed'] == true || json['perfect_claimed'] == 1, + date: (json['date'] ?? '') as String, + ); + } +} + +class TaskState { + final List tasks; + final TaskSummary? summary; + final bool isLoading; + final String? error; + + const TaskState({ + this.tasks = const [], + this.summary, + this.isLoading = false, + this.error, + }); + + TaskState copyWith({ + List? tasks, + TaskSummary? summary, + bool? isLoading, + String? error, + }) { + return TaskState( + tasks: tasks ?? this.tasks, + summary: summary ?? this.summary, + isLoading: isLoading ?? this.isLoading, + error: error, + ); + } +} + +class TaskNotifier extends StateNotifier { + final TaskService _taskService; + + TaskNotifier(this._taskService) : super(const TaskState()); + + Future loadTodayTasks() async { + state = state.copyWith(isLoading: true); + try { + final response = await _taskService.getTodayTasks(); + final data = (response['data'] ?? response) as Map; + final List taskList = data['tasks'] as List? ?? []; + final tasks = taskList + .map((e) => DailyTask.fromJson(e as Map)) + .toList(); + final summary = TaskSummary.fromJson(data); + state = state.copyWith(tasks: tasks, summary: summary, isLoading: false); + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + Future reportProgress(int taskId, {int increment = 1}) async { + try { + await _taskService.reportProgress(taskId, increment: increment); + await loadTodayTasks(); + return true; + } catch (e) { + return false; + } + } + + Future?> claimReward(int taskId) async { + try { + final response = await _taskService.claimReward(taskId); + await loadTodayTasks(); + return (response['data'] ?? response) as Map?; + } catch (e) { + return null; + } + } + + Future?> claimPerfectDay() async { + try { + final response = await _taskService.claimPerfectDay(); + await loadTodayTasks(); + return (response['data'] ?? response) as Map?; + } catch (e) { + return null; + } + } +} + +final taskProvider = StateNotifierProvider((ref) { + return TaskNotifier(ref.read(taskServiceProvider)); +}); diff --git a/lib/features/task/services/task_service.dart b/lib/features/task/services/task_service.dart new file mode 100644 index 00000000..9668203d --- /dev/null +++ b/lib/features/task/services/task_service.dart @@ -0,0 +1,60 @@ +/// @name 每日任务API服务 +/// @date 2026-05-14 +/// @desc 每日任务系统API请求封装 +/// @update v12.0.2 修复: 添加泛型类型参数消除推断警告 + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/network/api_client.dart'; + +final taskServiceProvider = Provider((ref) => TaskService()); + +class TaskService { + static final ApiClient _api = ApiClient.instance; + + Future> getTodayTasks() async { + final response = await _api.get>('/api/task/today'); + return response.data!; + } + + Future> reportProgress(int taskId, {int increment = 1}) async { + final response = await _api.post>('/api/task/reportProgress', data: { + 'task_id': taskId, + 'increment': increment, + }); + return response.data!; + } + + Future> claimReward(int taskId) async { + final response = await _api.post>('/api/task/claim', data: { + 'task_id': taskId, + }); + return response.data!; + } + + Future> claimPerfectDay() async { + final response = await _api.post>('/api/task/claimPerfect', data: {}); + return response.data!; + } + + Future> registerCustomTask({ + required String name, + String icon = '🎯', + String? customUrl, + String? customPage, + int target = 1, + int expReward = 5, + int scoreReward = 2, + }) async { + final data = { + 'name': name, + 'icon': icon, + 'target': target, + 'exp_reward': expReward, + 'score_reward': scoreReward, + }; + if (customUrl != null) data['custom_url'] = customUrl; + if (customPage != null) data['custom_page'] = customPage; + final response = await _api.post>('/api/task/registerCustom', data: data); + return response.data!; + } +} diff --git a/lib/features/user_center/models/user_center_models.dart b/lib/features/user_center/models/user_center_models.dart index 73ad45e3..905ad094 100644 --- a/lib/features/user_center/models/user_center_models.dart +++ b/lib/features/user_center/models/user_center_models.dart @@ -1,9 +1,9 @@ /// ============================================================ /// 闲言APP — 用户中心数据模型 /// 创建时间: 2026-04-29 -/// 更新时间: 2026-05-10 +/// 更新时间: 2026-05-14 /// 作用: 用户中心相关数据模型(公开主页/互动记录/热力图/面板等) -/// 上次更新: v9.0.0 DashboardModel新增comment_count/view_count/readlater_count; PublicProfileModel新增avatar_url +/// 上次更新: v11.0.0 DashboardModel/PublicProfileModel新增level/exp/expToNext/expProgress/levelTitle字段 /// ============================================================ class PublicProfileModel { @@ -15,6 +15,11 @@ class PublicProfileModel { this.avatarUrl = '', this.bio = '', this.score = 0, + this.level = 1, + this.exp = 0, + this.expToNext = 0, + this.expProgress = 0.0, + this.levelTitle = '', this.signinDays = 0, this.articleCount = 0, this.favoriteCount = 0, @@ -29,6 +34,11 @@ class PublicProfileModel { final String avatarUrl; final String bio; final int score; + final int level; + final int exp; + final int expToNext; + final double expProgress; + final String levelTitle; final int signinDays; final int articleCount; final int favoriteCount; @@ -58,6 +68,11 @@ class PublicProfileModel { avatarUrl: json['avatar_url'] as String? ?? '', bio: json['bio'] as String? ?? '', score: json['score'] as int? ?? 0, + level: json['level'] as int? ?? 1, + exp: json['exp'] as int? ?? 0, + expToNext: json['exp_to_next'] as int? ?? 0, + expProgress: (json['exp_progress'] as num?)?.toDouble() ?? 0.0, + levelTitle: json['level_title'] as String? ?? '', signinDays: json['signin_days'] as int? ?? 0, articleCount: json['article_count'] as int? ?? 0, favoriteCount: json['favorite_count'] as int? ?? 0, @@ -120,6 +135,11 @@ class DashboardModel { const DashboardModel({ this.userId = 0, this.score = 0, + this.level = 1, + this.exp = 0, + this.expToNext = 0, + this.expProgress = 0.0, + this.levelTitle = '', this.signinDays = 0, this.signinCount = 0, this.favoriteCount = 0, @@ -134,6 +154,11 @@ class DashboardModel { final int userId; final int score; + final int level; + final int exp; + final int expToNext; + final double expProgress; + final String levelTitle; final int signinDays; final int signinCount; final int favoriteCount; @@ -149,6 +174,11 @@ class DashboardModel { return DashboardModel( userId: json['user_id'] as int? ?? 0, score: json['score'] as int? ?? 0, + level: json['level'] as int? ?? 1, + exp: json['exp'] as int? ?? 0, + expToNext: json['exp_to_next'] as int? ?? 0, + expProgress: (json['exp_progress'] as num?)?.toDouble() ?? 0.0, + levelTitle: json['level_title'] as String? ?? '', signinDays: json['signin_days'] as int? ?? 0, signinCount: json['signin_count'] as int? ?? 0, favoriteCount: json['favorite_count'] as int? ?? 0, diff --git a/lib/features/user_center/presentation/my_devices_page.dart b/lib/features/user_center/presentation/my_devices_page.dart index e00fde40..2304cc77 100644 --- a/lib/features/user_center/presentation/my_devices_page.dart +++ b/lib/features/user_center/presentation/my_devices_page.dart @@ -16,7 +16,7 @@ import 'package:local_auth/local_auth.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../../core/router/app_router.dart'; -import '../../../core/services/device_info_service.dart'; +import '../../../core/services/device/device_info_service.dart'; import '../../../core/theme/app_radius.dart'; import '../../../core/theme/app_spacing.dart'; import '../../../core/theme/app_typography.dart'; diff --git a/lib/features/user_center/presentation/widgets/profile_header_row.dart b/lib/features/user_center/presentation/widgets/profile_header_row.dart index b072024b..265f7f1e 100644 --- a/lib/features/user_center/presentation/widgets/profile_header_row.dart +++ b/lib/features/user_center/presentation/widgets/profile_header_row.dart @@ -2,8 +2,8 @@ /// 闲言APP — 个人中心-头像+用户名行 /// 创建时间: 2026-05-14 /// 更新时间: 2026-05-14 -/// 作用: 头像+用户名+称号+编辑按钮的同行展示组件 -/// 上次更新: 从 user_center_page.dart 拆分提取 +/// 作用: 头像+用户名+等级标签+编辑按钮的同行展示组件 +/// 上次更新: 称号标签改用user.level等级体系,颜色按等级映射 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -13,6 +13,7 @@ import '../../../../core/theme/app_theme.dart'; import '../../../../core/theme/app_spacing.dart'; import '../../../../core/theme/app_typography.dart'; import '../../../../core/theme/app_radius.dart'; +import '../../../../core/utils/level_utils.dart'; import '../../../../shared/widgets/glass_container.dart'; import '../../../auth/models/user_model.dart'; import 'edit_field_bottom_sheet.dart'; @@ -92,7 +93,7 @@ class ProfileHeaderRow extends StatelessWidget { } Widget _buildUserInfo() { - final titleInfo = _getTitleForScore(displayScore); + final levelColor = getLevelColor(user.level); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -115,22 +116,18 @@ class ProfileHeaderRow extends StatelessWidget { vertical: 2, ), decoration: BoxDecoration( - color: ext.accent.withValues(alpha: 0.15), + color: levelColor.withValues(alpha: 0.15), borderRadius: AppRadius.pillBorder, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - _titleIcon(titleInfo.$2), - size: 10, - color: ext.accent, - ), - const SizedBox(width: 2), + Icon(CupertinoIcons.star_fill, size: 10, color: levelColor), + const SizedBox(width: 3), Text( - titleInfo.$1, + getLevelBadge(user.level), style: AppTypography.caption2.copyWith( - color: ext.accent, + color: levelColor, fontWeight: FontWeight.w600, ), ), @@ -150,34 +147,6 @@ class ProfileHeaderRow extends StatelessWidget { ); } - (String, String) _getTitleForScore(int score) { - if (score >= 5000) return ('大师', '👑'); - if (score >= 1500) return ('专家', '🏆'); - if (score >= 500) return ('达人', '⭐'); - if (score >= 200) return ('熟练工', '🔧'); - if (score >= 50) return ('学徒', '📖'); - return ('新手', '🌱'); - } - - IconData _titleIcon(String icon) { - switch (icon) { - case '👑': - return CupertinoIcons.star_circle_fill; - case '🏆': - return CupertinoIcons.star_fill; - case '⭐': - return CupertinoIcons.star_fill; - case '🔧': - return CupertinoIcons.wrench_fill; - case '📖': - return CupertinoIcons.book_fill; - case '🌱': - return CupertinoIcons.leaf_arrow_circlepath; - default: - return CupertinoIcons.arrow_2_circlepath; - } - } - Widget _buildEditButton(BuildContext context) { return CupertinoButton( padding: const EdgeInsets.symmetric( diff --git a/lib/features/user_center/presentation/widgets/quick_action_grid.dart b/lib/features/user_center/presentation/widgets/quick_action_grid.dart index aa950ef8..90360b4b 100644 --- a/lib/features/user_center/presentation/widgets/quick_action_grid.dart +++ b/lib/features/user_center/presentation/widgets/quick_action_grid.dart @@ -3,7 +3,7 @@ /// 创建时间: 2026-05-14 /// 更新时间: 2026-05-14 /// 作用: 快捷操作入口网格(签到/学习/成就/打卡/统计等) -/// 上次更新: 从 user_center_page.dart 拆分提取 +/// 上次更新: 新增勋章墙/每日任务/排行榜3个快捷入口 /// ============================================================ import 'package:flutter/cupertino.dart'; @@ -48,6 +48,24 @@ class QuickActionGrid extends StatelessWidget { route: AppRoutes.checkin, color: 0xFFAF52DE, ), + _QuickActionData( + icon: CupertinoIcons.shield_fill, + title: '勋章墙', + route: AppRoutes.badgeWall, + color: 0xFFFFD60A, + ), + _QuickActionData( + icon: CupertinoIcons.checkmark_rectangle_fill, + title: '每日任务', + route: AppRoutes.dailyTask, + color: 0xFF40E0D0, + ), + _QuickActionData( + icon: CupertinoIcons.chart_bar_fill, + title: '排行榜', + route: AppRoutes.rank, + color: 0xFFFF6B35, + ), _QuickActionData( icon: CupertinoIcons.chart_bar_square_fill, title: '数据统计', diff --git a/lib/features/user_center/providers/interaction_provider.dart b/lib/features/user_center/providers/interaction_provider.dart index 1901c51f..28f8d8e1 100644 --- a/lib/features/user_center/providers/interaction_provider.dart +++ b/lib/features/user_center/providers/interaction_provider.dart @@ -11,7 +11,7 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_flutter/hive_flutter.dart'; -import '../../../core/services/connectivity_service.dart'; +import '../../../core/services/network/connectivity_service.dart'; import '../../../core/storage/app_kv_store.dart'; import '../../../core/utils/logger.dart'; import '../../user_center/services/user_center_service.dart'; diff --git a/lib/features/user_center/services/user_center_service.dart b/lib/features/user_center/services/user_center_service.dart index 88adfea9..1edcf3ef 100644 --- a/lib/features/user_center/services/user_center_service.dart +++ b/lib/features/user_center/services/user_center_service.dart @@ -1,7 +1,7 @@ /// ============================================================ /// 闲言APP — 用户中心服务 /// 创建时间: 2026-04-29 -/// 更新时间: 2026-05-11 +/// 更新时间: 2026-05-15 /// 作用: 用户中心相关API封装(信息/签到/收藏/笔记/点赞/互动/面板/金币/主页/统计/设备管理) /// 上次更新: v10.0.0 registerDevice新增ipCity/ipRange参数; 新增myDevices接口 /// ============================================================ @@ -91,16 +91,36 @@ class UserCenterService { // 邮箱/手机变更 (回执验证,委托给UserSecurityService) // ============================================================ - /// 修改邮箱 (回执验证) + /// 修改邮箱 (支持回执/密保验证) static Future> changeEmail({ required String email, + String verifyMethod = 'receipt', + String? secAnswer, }) async { try { - final receipt = ReceiptHelper.generate(ReceiptAction.changeemail, email); + final data = { + 'email': email, + 'verify_method': verifyMethod, + }; + + switch (verifyMethod) { + case 'sec_question': + if (secAnswer == null || secAnswer.isEmpty) { + throw const ApiException(code: 0, message: '请输入密保答案'); + } + data['sec_answer'] = secAnswer; + break; + case 'receipt': + default: + final receipt = ReceiptHelper.generate(ReceiptAction.changeemail, email); + data['receipt'] = receipt.receipt; + data['sig'] = receipt.sig; + break; + } final response = await _api.post>( '/api/user_security/changeemail', - data: {'email': email, 'receipt': receipt.receipt, 'sig': receipt.sig}, + data: data, ); final apiResp = ApiResponse>.fromJson( response.data as Map, diff --git a/lib/features/weather/services/weather_service.dart b/lib/features/weather/services/weather_service.dart index eab6b9a2..118576d0 100644 --- a/lib/features/weather/services/weather_service.dart +++ b/lib/features/weather/services/weather_service.dart @@ -6,7 +6,7 @@ /// 上次更新: 改用今日诗词SDK提供的天气接口,移除tianqiapi /// ============================================================ -import '../../../core/services/jinrishici_sdk_service.dart'; +import '../../../core/services/data/jinrishici_sdk_service.dart'; import '../../../core/utils/logger.dart'; import '../../home/services/searchall_service.dart'; import '../models/weather_models.dart'; diff --git a/lib/main.dart b/lib/main.dart index 29b47030..9af2d609 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,9 @@ // ============================================================ // 闲言APP — 应用入口 // 创建时间: 2026-04-20 -// 更新时间: 2026-04-28 +// 更新时间: 2026-05-14 // 作用: main 函数,初始化存储 + 液态玻璃 + 异常捕获 + 启动 App -// 上次更新: 接入 Hive + 深度链接初始化 +// 上次更新: 升级 liquid_glass_widgets 至 v0.11.0,适配新 wrap API // ============================================================ import 'package:flutter/material.dart'; @@ -14,12 +14,13 @@ import 'package:liquid_glass_widgets/liquid_glass_widgets.dart'; import 'package:flutter/services.dart'; import 'app/app.dart'; -import 'core/services/deep_link_service.dart'; -import 'core/services/local_notification_service.dart'; -import 'core/services/screen_wake_service.dart'; +import 'core/services/network/deep_link_service.dart'; +import 'core/services/notification/local_notification_service.dart'; +import 'core/services/device/screen_wake_service.dart'; +import 'core/services/readlater/sharing_receiver_service.dart'; import 'core/services/sound_service.dart'; -import 'core/services/battery_optimization_service.dart'; -import 'core/services/readlater_reminder_service.dart'; +import 'core/services/device/battery_optimization_service.dart'; +import 'core/services/notification/readlater_reminder_service.dart'; import 'core/storage/app_kv_store.dart'; import 'core/storage/kv_storage.dart'; import 'core/utils/logger.dart'; @@ -75,6 +76,14 @@ void main() async { Log.e('深度链接服务初始化失败', e); } + try { + SharingReceiverService().init(); + SharingReceiverService().setNavigatorKey(rootNavigatorKey); + Log.i('分享接收服务初始化完成'); + } catch (e) { + Log.e('分享接收服务初始化失败', e); + } + try { await LocalNotificationService.init(); Log.i('本地通知服务初始化完成'); @@ -119,7 +128,11 @@ void main() async { Catcher2( runAppFunction: () { - runApp(LiquidGlassWidgets.wrap(const ProviderScope(child: XianyanApp()))); + runApp( + LiquidGlassWidgets.wrap( + child: const ProviderScope(child: XianyanApp()), + ), + ); }, debugConfig: Catcher2Options( SilentReportMode(), diff --git a/lib/shared/widgets/level_card.dart b/lib/shared/widgets/level_card.dart new file mode 100644 index 00000000..b2f9fa6f --- /dev/null +++ b/lib/shared/widgets/level_card.dart @@ -0,0 +1,244 @@ +// 创建时间: 2026-05-14 +// 更新时间: 2026-05-14 +// 名称: LevelCard +// 作用: 等级卡片组件,展示用户等级+EXP进度 +// 上次更新内容: 初始创建 + +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; + +import '../../core/theme/app_radius.dart'; +import '../../core/theme/app_spacing.dart'; +import '../../core/theme/app_theme.dart'; +import '../../core/theme/app_typography.dart'; +import '../../core/theme/glass_tokens.dart'; + +/// 等级颜色映射 +/// +/// 根据等级区间返回对应主题色 +Color _levelColor(int level) { + return switch (level) { + >= 1 && <= 3 => const Color(0xFF5B9BD5), + >= 4 && <= 5 => const Color(0xFF70AD47), + >= 6 && <= 7 => const Color(0xFFFFC000), + >= 8 && <= 9 => const Color(0xFFFF6600), + 10 => const Color(0xFFE74C3C), + _ => const Color(0xFF5B9BD5), + }; +} + +/// 等级卡片组件 +/// +/// iOS 风格毛玻璃卡片,展示用户等级与经验进度。 +/// 左侧为等级徽章,右侧为称号、进度条和EXP数值。 +class LevelCard extends StatelessWidget { + const LevelCard({ + super.key, + required this.level, + required this.exp, + required this.expToNext, + required this.expProgress, + required this.levelTitle, + this.onTap, + }); + + /// 等级 1-10 + final int level; + + /// 当前EXP + final int exp; + + /// 距下一级EXP + final int expToNext; + + /// 进度 0.0-1.0 + final double expProgress; + + /// 等级称号 + final String levelTitle; + + /// 点击回调 + final VoidCallback? onTap; + + /// 是否满级 + bool get _isMaxLevel => level >= 10; + + /// 安全进度值 + double get _safeProgress => _isMaxLevel ? 1.0 : expProgress.clamp(0.0, 1.0); + + @override + Widget build(BuildContext context) { + final ext = AppTheme.ext(context); + final color = _levelColor(level); + + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: ClipRRect( + borderRadius: AppRadius.lgBorder, + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: GlassTokens.baseBlur * ext.glassBlurMultiplier, + sigmaY: GlassTokens.baseBlur * ext.glassBlurMultiplier, + ), + child: Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: ext.glassColor.withValues( + alpha: ext.isDark + ? GlassTokens.baseOpacityDark + : GlassTokens.baseOpacityLight, + ), + borderRadius: AppRadius.lgBorder, + border: Border.all( + color: ext.glassColor.withValues( + alpha: GlassTokens.borderOpacity, + ), + width: GlassTokens.borderWidth, + ), + ), + child: Row( + children: [ + _buildBadge(color), + const SizedBox(width: AppSpacing.md), + Expanded(child: _buildInfo(ext, color)), + ], + ), + ), + ), + ), + ); + } + + /// 左侧等级徽章 + Widget _buildBadge(Color color) { + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.3), + blurRadius: 8, + spreadRadius: 1, + ), + ], + ), + alignment: Alignment.center, + child: Text( + 'Lv.$level', + style: AppTypography.callout.copyWith( + color: CupertinoColors.white, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ); + } + + /// 右侧信息区域 + Widget _buildInfo(AppThemeExtension ext, Color color) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(ext), + const SizedBox(height: AppSpacing.sm - 2), + _buildProgressBar(ext, color), + const SizedBox(height: AppSpacing.xs), + _buildExpText(ext, color), + ], + ); + } + + /// 等级称号 + Widget _buildTitle(AppThemeExtension ext) { + return Row( + children: [ + Text( + levelTitle, + style: AppTypography.headline.copyWith( + color: ext.textPrimary, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + if (_isMaxLevel) ...[ + const SizedBox(width: AppSpacing.xs), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 1, + ), + decoration: BoxDecoration( + color: _levelColor(level).withValues(alpha: 0.15), + borderRadius: AppRadius.smBorder, + ), + child: Text( + 'MAX', + style: AppTypography.caption2.copyWith( + color: _levelColor(level), + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ); + } + + /// EXP进度条 + Widget _buildProgressBar(AppThemeExtension ext, Color color) { + return ClipRRect( + borderRadius: BorderRadius.circular(3), + child: SizedBox( + height: 6, + child: Stack( + children: [ + Container( + decoration: BoxDecoration( + color: ext.isDark + ? const Color(0xFF3A3A4A) + : const Color(0xFFE0E0E0), + borderRadius: BorderRadius.circular(3), + ), + ), + FractionallySizedBox( + widthFactor: _safeProgress, + child: Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(3), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.4), + blurRadius: 4, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + /// EXP数值文本 + Widget _buildExpText(AppThemeExtension ext, Color color) { + final displayText = _isMaxLevel + ? '🏆 MAX LEVEL' + : '$exp / $expToNext EXP'; + + return Text( + displayText, + style: AppTypography.caption1.copyWith( + color: _isMaxLevel ? color : ext.textHint, + fontWeight: _isMaxLevel ? FontWeight.w600 : FontWeight.normal, + ), + ); + } +} diff --git a/lib/shared/widgets/rank_item_card.dart b/lib/shared/widgets/rank_item_card.dart new file mode 100644 index 00000000..8bff404b --- /dev/null +++ b/lib/shared/widgets/rank_item_card.dart @@ -0,0 +1,157 @@ +/// @name 排行榜项卡片组件 +/// @date 2026-05-14 +/// @desc 排行榜单项: 排名+头像+用户名+等级+值 +/// @update v13.0.0 初始版本 + +import 'package:flutter/cupertino.dart'; +import '../../features/rank/providers/rank_provider.dart'; +import '../../core/utils/level_utils.dart'; + +class RankItemCard extends StatelessWidget { + final RankItem item; + final bool isCurrentUser; + + const RankItemCard({ + super.key, + required this.item, + this.isCurrentUser = false, + }); + + @override + Widget build(BuildContext context) { + final brightness = MediaQuery.platformBrightnessOf(context); + final isDark = brightness == Brightness.dark; + + Color bgColor; + if (isCurrentUser) { + bgColor = CupertinoColors.activeBlue.withValues(alpha: 0.08); + } else if (item.rank == 1) { + bgColor = const Color(0xFFFFD700).withValues(alpha: 0.08); + } else if (item.rank == 2) { + bgColor = const Color(0xFFC0C0C0).withValues(alpha: 0.08); + } else if (item.rank == 3) { + bgColor = const Color(0xFFCD7F32).withValues(alpha: 0.08); + } else { + bgColor = isDark + ? CupertinoColors.systemGrey6.darkColor + : CupertinoColors.systemBackground.color; + } + + String rankEmoji; + if (item.rank == 1) { + rankEmoji = '🥇'; + } else if (item.rank == 2) { + rankEmoji = '🥈'; + } else if (item.rank == 3) { + rankEmoji = '🥉'; + } else { + rankEmoji = ''; + } + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 3), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(12), + border: isCurrentUser + ? Border.all( + color: CupertinoColors.activeBlue.withValues(alpha: 0.3), + width: 1.5, + ) + : null, + ), + child: Row( + children: [ + SizedBox( + width: 40, + child: rankEmoji.isNotEmpty + ? Center( + child: Text( + rankEmoji, + style: const TextStyle(fontSize: 22), + ), + ) + : Center( + child: Text( + '${item.rank}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: CupertinoColors.secondaryLabel.resolveFrom( + context, + ), + ), + ), + ), + ), + const SizedBox(width: 10), + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: getLevelColor(item.level).withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(19), + ), + child: Center( + child: item.avatar.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(19), + child: Image.network( + item.avatar, + width: 38, + height: 38, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + _buildLevelIcon(item.level), + ), + ) + : _buildLevelIcon(item.level), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.username.isNotEmpty ? item.username : '用户${item.userId}', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isCurrentUser + ? CupertinoColors.activeBlue + : CupertinoColors.label.resolveFrom(context), + ), + ), + const SizedBox(height: 1), + Text( + 'Lv.${item.level}', + style: TextStyle( + fontSize: 11, + color: getLevelColor(item.level), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + Text( + '${item.value}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: item.rank <= 3 + ? CupertinoColors.activeOrange + : CupertinoColors.label.resolveFrom(context), + ), + ), + ], + ), + ); + } + + Widget _buildLevelIcon(int level) { + return Text(getLevelBadge(level), style: const TextStyle(fontSize: 18)); + } +} diff --git a/lib/shared/widgets/tab_icon_sprite.dart b/lib/shared/widgets/tab_icon_sprite.dart index ddb3756f..8c996db4 100644 --- a/lib/shared/widgets/tab_icon_sprite.dart +++ b/lib/shared/widgets/tab_icon_sprite.dart @@ -3,10 +3,11 @@ // 创建时间: 2026-05-14 // 更新时间: 2026-05-14 // 作用: 底部导航Tab图标,支持表情精灵/呼吸光效/果冻弹跳/相邻注视/文字显隐 -// 上次更新: 完全重绘所有精灵 — 精细贝塞尔曲线+丰富细节+流畅造型 +// 上次更新: 修复底部Tab栏间距过大+文字过小 — 缩小glowSize/iconSize/增大fontSize // ============================================================ import 'dart:math' as math; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; @@ -249,58 +250,68 @@ class _TabIconSpriteState extends State final labelAlpha = 1.0 - _labelOpacity.value; final labelSlide = _labelOffset.value; + final iconSize = widget.isSelected ? 36.0 : 24.0; + final glowSize = 40.0 * widget.bounceMultiplier; + return Column( mainAxisSize: MainAxisSize.min, children: [ - Stack( - alignment: Alignment.center, - children: [ - if (widget.isSelected) - _GlowRing( - color: ext.accent, - alpha: glowAlpha * 0.5, - size: 48 * widget.bounceMultiplier, - ), - Transform.scale( - scale: scale, - child: Transform.rotate( - angle: rotation, - child: SizedBox( - width: 32, - height: 32, - child: CustomPaint( - painter: _RefinedSpritePainter( - type: widget.type, - characterId: widget.characterId, - expressionProgress: exprProgress, - lookOffset: lookDir, - eyeScale: widget.eyeScale, - mouthCurve: widget.mouthCurve, - isSelected: widget.isSelected, - color: widget.isSelected - ? (ext.isDark ? Colors.white : ext.accent) - : (ext.isDark - ? Colors.white38 - : const Color(0xFFAEAEB2)), + SizedBox( + width: glowSize, + height: glowSize, + child: Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + if (widget.isSelected) + CustomPaint( + size: Size(glowSize * 1.4, glowSize * 1.4), + painter: _GlowPainter( + color: ext.accent, + alpha: glowAlpha * 0.55, + radiusMultiplier: 1.4, + ), + ), + Transform.scale( + scale: scale, + child: Transform.rotate( + angle: rotation, + child: SizedBox( + width: iconSize, + height: iconSize, + child: CustomPaint( + painter: _RefinedSpritePainter( + type: widget.type, + characterId: widget.characterId, + expressionProgress: exprProgress, + lookOffset: lookDir, + eyeScale: widget.eyeScale, + mouthCurve: widget.mouthCurve, + isSelected: widget.isSelected, + color: widget.isSelected + ? (ext.isDark ? Colors.white : ext.accent) + : (ext.isDark + ? Colors.white38 + : const Color(0xFFAEAEB2)), + ), ), ), ), ), - ), - if (widget.isSelected) - ...List.generate( - 5, - (i) => _FloatingParticle( - color: ext.accent, - index: i, - progress: glowAlpha, + if (widget.isSelected) + ...List.generate( + 7, + (i) => _FloatingParticle( + color: ext.accent, + index: i, + progress: glowAlpha, + ), ), - ), - ], + ], + ), ), - const SizedBox(height: 2), SizedBox( - height: 14, + height: 16, child: FractionalTranslation( translation: labelSlide, child: Opacity( @@ -314,7 +325,8 @@ class _TabIconSpriteState extends State ? Colors.white38 : const Color(0xFFAEAEB2)), fontWeight: FontWeight.w500, - fontSize: 10, + fontSize: 12, + height: 1.2, ), ), ), @@ -327,33 +339,45 @@ class _TabIconSpriteState extends State } } -class _GlowRing extends StatelessWidget { - const _GlowRing({ +class _GlowPainter extends CustomPainter { + _GlowPainter({ required this.color, required this.alpha, - required this.size, + this.radiusMultiplier = 1.0, }); final Color color; final double alpha; - final double size; + final double radiusMultiplier; @override - Widget build(BuildContext context) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: color.withValues(alpha: alpha.clamp(0.0, 1.0)), - blurRadius: 16, - spreadRadius: 4, - ), + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = (size.width / 2) * radiusMultiplier; + + final paint = Paint() + ..shader = ui.Gradient.radial( + center, + radius, + [ + color.withValues(alpha: alpha.clamp(0.0, 1.0)), + color.withValues(alpha: alpha.clamp(0.0, 1.0) * 0.6), + color.withValues(alpha: alpha.clamp(0.0, 1.0) * 0.2), + color.withValues(alpha: 0.0), ], - ), - ); + [0.0, 0.35, 0.7, 1.0], + ) + ..isAntiAlias = true + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8); + + canvas.drawCircle(center, radius, paint); + } + + @override + bool shouldRepaint(_GlowPainter oldDelegate) { + return alpha != oldDelegate.alpha || + color != oldDelegate.color || + radiusMultiplier != oldDelegate.radiusMultiplier; } } @@ -370,12 +394,12 @@ class _FloatingParticle extends StatelessWidget { @override Widget build(BuildContext context) { - final angle = (index * 1.2566) + progress * 1.047; - final radius = 16.0 + progress * 10.0; + final angle = (index * 0.898) + progress * 1.047; + final radius = 22.0 + progress * 14.0; final dx = radius * math.cos(angle); final dy = radius * math.sin(angle); - final particleAlpha = (1.0 - progress).clamp(0.0, 1.0) * 0.6; - final particleSize = 3.0 - index * 0.3; + final particleAlpha = (1.0 - progress).clamp(0.0, 1.0) * 0.7; + final particleSize = 4.5 - index * 0.3; return Transform.translate( offset: Offset(dx, dy), @@ -387,8 +411,9 @@ class _FloatingParticle extends StatelessWidget { color: color.withValues(alpha: particleAlpha), boxShadow: [ BoxShadow( - color: color.withValues(alpha: particleAlpha * 0.5), - blurRadius: 3, + color: color.withValues(alpha: particleAlpha * 0.6), + blurRadius: 6, + spreadRadius: 2, ), ], ), @@ -470,7 +495,7 @@ class _RefinedSpritePainter extends CustomPainter { final body = Path(); body.addRRect( RRect.fromRectAndCorners( - Rect.fromLTWH(5, 12, 22, 14), + const Rect.fromLTWH(5, 12, 22, 14), topLeft: const Radius.circular(2), topRight: const Radius.circular(2), bottomLeft: const Radius.circular(3), @@ -482,7 +507,7 @@ class _RefinedSpritePainter extends CustomPainter { final door = Path(); door.addRRect( RRect.fromRectAndCorners( - Rect.fromLTWH(13, 19, 6, 7), + const Rect.fromLTWH(13, 19, 6, 7), topLeft: const Radius.circular(1), topRight: const Radius.circular(1), bottomLeft: const Radius.circular(3), @@ -497,7 +522,7 @@ class _RefinedSpritePainter extends CustomPainter { final window = _fill(alpha: 0.25); canvas.drawRRect( RRect.fromRectAndCorners( - Rect.fromLTWH(7, 14, 4, 3), + const Rect.fromLTWH(7, 14, 4, 3), topLeft: const Radius.circular(0.8), topRight: const Radius.circular(0.8), bottomLeft: const Radius.circular(0.8), @@ -507,7 +532,7 @@ class _RefinedSpritePainter extends CustomPainter { ); canvas.drawRRect( RRect.fromRectAndCorners( - Rect.fromLTWH(21, 14, 4, 3), + const Rect.fromLTWH(21, 14, 4, 3), topLeft: const Radius.circular(0.8), topRight: const Radius.circular(0.8), bottomLeft: const Radius.circular(0.8), @@ -568,13 +593,13 @@ class _RefinedSpritePainter extends CustomPainter { Paint stroke, ) { final head = Path(); - head.addOval(Rect.fromLTWH(8, 4, 16, 14)); + head.addOval(const Rect.fromLTWH(8, 4, 16, 14)); canvas.drawPath(head, fill); final body = Path(); body.addRRect( RRect.fromRectAndCorners( - Rect.fromLTWH(7, 17, 18, 10), + const Rect.fromLTWH(7, 17, 18, 10), topLeft: const Radius.circular(6), topRight: const Radius.circular(6), bottomLeft: const Radius.circular(4), @@ -662,7 +687,7 @@ class _RefinedSpritePainter extends CustomPainter { final body = Path(); body.addRRect( RRect.fromRectAndCorners( - Rect.fromLTWH(5, 12, 22, 14), + const Rect.fromLTWH(5, 12, 22, 14), topLeft: const Radius.circular(2), topRight: const Radius.circular(2), bottomLeft: const Radius.circular(3), @@ -674,7 +699,7 @@ class _RefinedSpritePainter extends CustomPainter { final door = Path(); door.addRRect( RRect.fromRectAndCorners( - Rect.fromLTWH(13, 19, 6, 7), + const Rect.fromLTWH(13, 19, 6, 7), topLeft: const Radius.circular(1), topRight: const Radius.circular(1), bottomLeft: const Radius.circular(3), @@ -737,13 +762,13 @@ class _RefinedSpritePainter extends CustomPainter { Paint stroke, ) { final head = Path(); - head.addOval(Rect.fromLTWH(8, 4, 16, 14)); + head.addOval(const Rect.fromLTWH(8, 4, 16, 14)); canvas.drawPath(head, fill); final body = Path(); body.addRRect( RRect.fromRectAndCorners( - Rect.fromLTWH(7, 17, 18, 10), + const Rect.fromLTWH(7, 17, 18, 10), topLeft: const Radius.circular(6), topRight: const Radius.circular(6), bottomLeft: const Radius.circular(4), @@ -836,7 +861,7 @@ class _RefinedSpritePainter extends CustomPainter { final body = Path(); body.addRRect( RRect.fromRectAndCorners( - Rect.fromLTWH(5, 12, 22, 14), + const Rect.fromLTWH(5, 12, 22, 14), topLeft: const Radius.circular(2), topRight: const Radius.circular(2), bottomLeft: const Radius.circular(3), @@ -848,7 +873,7 @@ class _RefinedSpritePainter extends CustomPainter { final door = Path(); door.addRRect( RRect.fromRectAndCorners( - Rect.fromLTWH(13, 19, 6, 7), + const Rect.fromLTWH(13, 19, 6, 7), topLeft: const Radius.circular(1), topRight: const Radius.circular(1), bottomLeft: const Radius.circular(3), @@ -899,13 +924,13 @@ class _RefinedSpritePainter extends CustomPainter { void _paintBoyProfile(Canvas canvas, Paint fill, Paint subFill) { final head = Path(); - head.addOval(Rect.fromLTWH(8, 4, 16, 14)); + head.addOval(const Rect.fromLTWH(8, 4, 16, 14)); canvas.drawPath(head, fill); final body = Path(); body.addRRect( RRect.fromRectAndCorners( - Rect.fromLTWH(7, 17, 18, 10), + const Rect.fromLTWH(7, 17, 18, 10), topLeft: const Radius.circular(6), topRight: const Radius.circular(6), bottomLeft: const Radius.circular(4), @@ -969,7 +994,7 @@ class _RefinedSpritePainter extends CustomPainter { final body = Path(); body.addRRect( RRect.fromRectAndCorners( - Rect.fromLTWH(5, 12, 22, 14), + const Rect.fromLTWH(5, 12, 22, 14), topLeft: const Radius.circular(2), topRight: const Radius.circular(2), bottomLeft: const Radius.circular(3), @@ -981,7 +1006,7 @@ class _RefinedSpritePainter extends CustomPainter { final door = Path(); door.addRRect( RRect.fromRectAndCorners( - Rect.fromLTWH(13, 19, 6, 7), + const Rect.fromLTWH(13, 19, 6, 7), topLeft: const Radius.circular(1), topRight: const Radius.circular(1), bottomLeft: const Radius.circular(3), @@ -1032,13 +1057,13 @@ class _RefinedSpritePainter extends CustomPainter { void _paintGirlProfile(Canvas canvas, Paint fill, Paint subFill) { final head = Path(); - head.addOval(Rect.fromLTWH(8, 4, 16, 14)); + head.addOval(const Rect.fromLTWH(8, 4, 16, 14)); canvas.drawPath(head, fill); final body = Path(); body.addRRect( RRect.fromRectAndCorners( - Rect.fromLTWH(7, 17, 18, 10), + const Rect.fromLTWH(7, 17, 18, 10), topLeft: const Radius.circular(6), topRight: const Radius.circular(6), bottomLeft: const Radius.circular(4), @@ -1268,7 +1293,7 @@ class _RefinedSpritePainter extends CustomPainter { // ============================================================ void _paintChimneyHeart(Canvas canvas) { final heartPaint = _fill(alpha: 0.55); - final hx = 22.0, hy = 5.0, hs = 2.0; + const hx = 22.0, hy = 5.0, hs = 2.0; final heart = Path(); heart.moveTo(hx, hy + hs * 0.3); heart.cubicTo( diff --git a/lib/shared/widgets/task_card.dart b/lib/shared/widgets/task_card.dart new file mode 100644 index 00000000..c55c964f --- /dev/null +++ b/lib/shared/widgets/task_card.dart @@ -0,0 +1,182 @@ +/// @name 任务卡片组件 +/// @date 2026-05-14 +/// @desc 每日任务卡片: 图标+名称+进度条+领取按钮 +/// @update v12.0.0 初始版本 + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class TaskCard extends StatelessWidget { + final String icon; + final String name; + final int progress; + final int target; + final int percent; + final bool completed; + final bool claimed; + final int expReward; + final int scoreReward; + final VoidCallback? onClaim; + final VoidCallback? onTap; + + const TaskCard({ + super.key, + required this.icon, + required this.name, + required this.progress, + required this.target, + required this.percent, + required this.completed, + required this.claimed, + required this.expReward, + required this.scoreReward, + this.onClaim, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final brightness = MediaQuery.platformBrightnessOf(context); + final isDark = brightness == Brightness.dark; + + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isDark + ? CupertinoColors.systemGrey6.darkColor + : CupertinoColors.systemBackground.color, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: completed + ? CupertinoColors.activeGreen.withValues(alpha: 0.3) + : CupertinoColors.separator.withValues(alpha: 0.5), + width: completed ? 1.5 : 0.5, + ), + boxShadow: [ + BoxShadow( + color: CupertinoColors.black.withValues( + alpha: isDark ? 0.2 : 0.04, + ), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + Row( + children: [ + Text(icon, style: const TextStyle(fontSize: 26)), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: completed + ? CupertinoColors.secondaryLabel.resolveFrom( + context, + ) + : CupertinoColors.label.resolveFrom(context), + decoration: claimed + ? TextDecoration.lineThrough + : null, + ), + ), + const SizedBox(height: 2), + Text( + '$progress/$target', + style: TextStyle( + fontSize: 12, + color: CupertinoColors.secondaryLabel.resolveFrom( + context, + ), + ), + ), + ], + ), + ), + if (claimed) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: CupertinoColors.systemGrey.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + '✅ 已领取', + style: TextStyle( + fontSize: 12, + color: CupertinoColors.secondaryLabel, + ), + ), + ) + else if (completed) + CupertinoButton( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 4, + ), + borderRadius: BorderRadius.circular(8), + color: CupertinoColors.activeOrange, + onPressed: onClaim, + child: Text( + '领取 +$expReward💎+$scoreReward⭐', + style: const TextStyle( + fontSize: 12, + color: CupertinoColors.white, + ), + ), minimumSize: const Size(28, 28), + ) + else + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: CupertinoColors.activeBlue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '+$expReward💎 +$scoreReward⭐', + style: const TextStyle( + fontSize: 11, + color: CupertinoColors.activeBlue, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: percent / 100.0, + backgroundColor: isDark + ? CupertinoColors.systemGrey4.darkColor + : CupertinoColors.systemGrey5.color, + valueColor: AlwaysStoppedAnimation( + completed + ? CupertinoColors.activeGreen + : CupertinoColors.activeBlue, + ), + minHeight: 5, + ), + ), + ], + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index edaa8dc8..f9e772df 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1333,6 +1333,13 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.0" + home_widget: + dependency: "direct main" + description: + path: "packages/home_widget" + relative: true + source: path + version: "0.9.1" hotreloader: dependency: transitive description: @@ -1574,10 +1581,11 @@ packages: liquid_glass_widgets: dependency: "direct main" description: - path: "packages/liquid_glass_widgets" - relative: true - source: path - version: "0.7.17" + name: liquid_glass_widgets + sha256: "71ddc9a5e6c90473624fc9a7c0a2e213deafeaa71ee0fad0deced11dd3fe7154" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.11.0" list_counter: dependency: transitive description: @@ -2119,6 +2127,13 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.1.0" + receive_sharing_intent: + dependency: "direct main" + description: + path: "packages/receive_sharing_intent" + relative: true + source: path + version: "1.8.1" record: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 4de2aaaa..0dd518c8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,7 +9,7 @@ name: xianyan description: "闲言 — 文字阅读更纯粹。句子阅读 + 壁纸制作 APP" publish_to: 'none' -version: 5.19.0+26051103 +version: 5.2.0+26051501 environment: sdk: ^3.9.2 @@ -78,11 +78,14 @@ dependencies: # --- Supabase 后端 --- supabase_flutter: ^2.5.0 + # --- 桌面小组件 --- + home_widget: + path: packages/home_widget + # 部分库引用本地 packages 目录 # --- iOS 26 Liquid Glass 组件 (本地源码) --- - liquid_glass_widgets: - path: packages/liquid_glass_widgets + liquid_glass_widgets: 0.11.0 liquid_glass_easy: path: packages/liquid_glass_easy @@ -221,15 +224,21 @@ dependencies: nearby_service: path: packages/nearby_service -# ============================================================ -# 开发依赖 -# ============================================================ + flutter_localizations: sdk: flutter timezone: any ndef_record: any sqflite: any cross_file: any + receive_sharing_intent: + path: packages/receive_sharing_intent + + +# ============================================================ +# 开发依赖 +# ============================================================ + dev_dependencies: flutter_test: sdk: flutter diff --git a/server/index.js b/server/index.js index 75aa5200..83e54f74 100644 --- a/server/index.js +++ b/server/index.js @@ -63,6 +63,13 @@ class SnapdropServer { peer.socket.on('error', console.error); this._keepAlive(peer); + this._send(peer, { + type: 'registered', + id: peer.id, + fingerprint: peer.fingerprint || '', + userId: peer.userId || '' + }); + this._send(peer, { type: 'display-name', message: { @@ -118,6 +125,17 @@ class SnapdropServer { break; case 'heartbeat': sender.lastBeat = Date.now(); + this._send(sender, { + type: 'heartbeat_ack', + timestamp: Date.now() + }); + break; + case 'discover': + this._handleDiscover(sender, message); + break; + case 'ping': + sender.lastBeat = Date.now(); + this._send(sender, { type: 'pong', timestamp: Date.now() }); break; case 'textMessage': this._handleTextMessage(sender, message); @@ -142,7 +160,7 @@ class SnapdropServer { break; } - const handledTypes = ['disconnect', 'pong', 'register', 'discoverMyDevices', 'transportNegotiate', 'wsRelay', 'pair-request', 'pairRequest', 'pair-accept', 'pairAccept', 'pair-reject', 'pairReject', 'heartbeat', 'textMessage', 'fileMeta', 'canvas-stroke', 'canvas-cursor', 'canvas-join', 'canvas-leave', 'canvas-snapshot']; + const handledTypes = ['disconnect', 'pong', 'register', 'discoverMyDevices', 'discover', 'transportNegotiate', 'wsRelay', 'pair-request', 'pairRequest', 'pair-accept', 'pairAccept', 'pair-reject', 'pairReject', 'heartbeat', 'ping', 'textMessage', 'fileMeta', 'canvas-stroke', 'canvas-cursor', 'canvas-join', 'canvas-leave', 'canvas-snapshot']; if (message.to && !handledTypes.includes(message.type)) { let recipientId = message.to; let recipient = this._resolveTarget(sender, recipientId); @@ -208,6 +226,22 @@ class SnapdropServer { }); } + _handleDiscover(sender, message) { + const allPeers = []; + for (const roomId in this._rooms) { + for (const peerId in this._rooms[roomId]) { + const peer = this._rooms[roomId][peerId]; + if (peer.id !== sender.id) { + allPeers.push(peer.getInfo()); + } + } + } + this._send(sender, { + type: 'discover_response', + devices: allPeers + }); + } + _handleDiscoverMyDevices(sender, message) { const payload = message.payload || message.data || {}; const userId = payload.userId || message.userId || sender.userId; diff --git a/test/file_transfer/resume_transfer_test.dart b/test/file_transfer/resume_transfer_test.dart deleted file mode 100644 index 9b4ac3c8..00000000 --- a/test/file_transfer/resume_transfer_test.dart +++ /dev/null @@ -1,1007 +0,0 @@ -// ============================================================ -// 闲言APP — 断点续传集成测试 -// 创建时间: 2026-05-14 -// 更新时间: 2026-05-14 -// 作用: 测试TransferResumeState/ChunkAssembler/WsRelayResumeHandler/TransferTask的断点续传逻辑 -// 上次更新: v5.19.0 初始创建 -// ============================================================ - -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:crypto/crypto.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:xianyan/features/file_transfer/models/transfer_device.dart'; -import 'package:xianyan/features/file_transfer/models/transfer_enums.dart'; -import 'package:xianyan/features/file_transfer/models/transfer_task.dart'; -import 'package:xianyan/features/file_transfer/services/signaling_service.dart'; -import 'package:xianyan/features/file_transfer/services/transport/ws_relay_chunk_assembler.dart'; -import 'package:xianyan/features/file_transfer/services/transport/ws_relay_resume_handler.dart'; - -class MockSignalingService extends Mock implements SignalingService {} - -class FakeSignalingMessage extends Fake implements SignalingMessage {} - -void main() { - setUpAll(() { - registerFallbackValue(FakeSignalingMessage()); - }); - - group('TransferResumeState', () { - test('toJson/fromJson round-trip', () { - final now = DateTime.now(); - final state = TransferResumeState( - taskId: 'task-001', - fileName: 'photo.jpg', - fileSize: 1024000, - totalChunks: 10, - receivedChunkIndices: [0, 1, 2, 3, 5, 7], - lastActiveTime: now, - isPaused: true, - ); - final json = state.toJson(); - final restored = TransferResumeState.fromJson(json); - - expect(restored.taskId, 'task-001'); - expect(restored.fileName, 'photo.jpg'); - expect(restored.fileSize, 1024000); - expect(restored.totalChunks, 10); - expect(restored.receivedChunkIndices, [0, 1, 2, 3, 5, 7]); - expect(restored.lastActiveTime.millisecondsSinceEpoch, - now.millisecondsSinceEpoch); - expect(restored.isPaused, true); - }); - - test('fromJson handles null/missing fields gracefully', () { - final restored = TransferResumeState.fromJson({}); - expect(restored.taskId, ''); - expect(restored.fileName, ''); - expect(restored.fileSize, 0); - expect(restored.totalChunks, 0); - expect(restored.receivedChunkIndices, []); - expect(restored.isPaused, false); - }); - - test('missingChunks calculates correctly', () { - final state = TransferResumeState( - taskId: 't1', - fileName: 'a.txt', - fileSize: 100, - totalChunks: 5, - receivedChunkIndices: [0, 2, 4], - lastActiveTime: DateTime.now(), - ); - expect(state.missingChunks, [1, 3]); - }); - - test('missingChunks empty when all received', () { - final state = TransferResumeState( - taskId: 't1', - fileName: 'a.txt', - fileSize: 100, - totalChunks: 3, - receivedChunkIndices: [0, 1, 2], - lastActiveTime: DateTime.now(), - ); - expect(state.missingChunks, []); - }); - - test('missingChunks all when none received', () { - final state = TransferResumeState( - taskId: 't1', - fileName: 'a.txt', - fileSize: 100, - totalChunks: 4, - receivedChunkIndices: [], - lastActiveTime: DateTime.now(), - ); - expect(state.missingChunks, [0, 1, 2, 3]); - }); - - test('progress calculates correctly', () { - final state = TransferResumeState( - taskId: 't1', - fileName: 'a.txt', - fileSize: 100, - totalChunks: 10, - receivedChunkIndices: [0, 1, 2, 3], - lastActiveTime: DateTime.now(), - ); - expect(state.progress, closeTo(0.4, 0.001)); - }); - - test('progress is 0 when totalChunks is 0', () { - final state = TransferResumeState( - taskId: 't1', - fileName: 'a.txt', - fileSize: 100, - totalChunks: 0, - receivedChunkIndices: [], - lastActiveTime: DateTime.now(), - ); - expect(state.progress, 0.0); - }); - - test('progress is 1.0 when all chunks received', () { - final state = TransferResumeState( - taskId: 't1', - fileName: 'a.txt', - fileSize: 100, - totalChunks: 3, - receivedChunkIndices: [0, 1, 2], - lastActiveTime: DateTime.now(), - ); - expect(state.progress, 1.0); - }); - }); - - group('ChunkAssembler', () { - test('addChunk and progress', () { - final assembler = ChunkAssembler( - taskId: 't1', - fileName: 'test.bin', - fileSize: 300, - checksum: '', - totalChunks: 3, - fromId: 'peer-1', - ); - - expect(assembler.receivedChunks, 0); - expect(assembler.progress, 0.0); - expect(assembler.isComplete, false); - - assembler.addChunk(0, base64Encode(Uint8List.fromList([1, 2, 3]))); - expect(assembler.receivedChunks, 1); - expect(assembler.progress, closeTo(1 / 3, 0.001)); - expect(assembler.hasChunk(0), true); - expect(assembler.hasChunk(1), false); - - assembler.addChunk(1, base64Encode(Uint8List.fromList([4, 5, 6]))); - assembler.addChunk(2, base64Encode(Uint8List.fromList([7, 8, 9]))); - expect(assembler.receivedChunks, 3); - expect(assembler.isComplete, true); - expect(assembler.progress, 1.0); - }); - - test('missingChunks detection', () { - final assembler = ChunkAssembler( - taskId: 't1', - fileName: 'test.bin', - fileSize: 300, - checksum: '', - totalChunks: 5, - fromId: 'peer-1', - ); - - assembler.addChunk(0, 'AAA'); - assembler.addChunk(3, 'BBB'); - - expect(assembler.missingChunks, [1, 2, 4]); - }); - - test('missingChunks empty when complete', () { - final assembler = ChunkAssembler( - taskId: 't1', - fileName: 'test.bin', - fileSize: 200, - checksum: '', - totalChunks: 2, - fromId: 'peer-1', - ); - - assembler.addChunk(0, 'AAA'); - assembler.addChunk(1, 'BBB'); - - expect(assembler.missingChunks, []); - }); - - test('assemble combines chunks in order', () { - final data0 = Uint8List.fromList([10, 20, 30]); - final data1 = Uint8List.fromList([40, 50, 60]); - final data2 = Uint8List.fromList([70, 80, 90]); - - final assembler = ChunkAssembler( - taskId: 't1', - fileName: 'test.bin', - fileSize: 9, - checksum: '', - totalChunks: 3, - fromId: 'peer-1', - ); - - assembler.addChunk(0, base64Encode(data0)); - assembler.addChunk(1, base64Encode(data1)); - assembler.addChunk(2, base64Encode(data2)); - - final result = assembler.assemble(); - expect(result, Uint8List.fromList([10, 20, 30, 40, 50, 60, 70, 80, 90])); - }); - - test('assemble skips missing chunks', () { - final data0 = Uint8List.fromList([10, 20]); - final data2 = Uint8List.fromList([70, 80]); - - final assembler = ChunkAssembler( - taskId: 't1', - fileName: 'test.bin', - fileSize: 6, - checksum: '', - totalChunks: 3, - fromId: 'peer-1', - ); - - assembler.addChunk(0, base64Encode(data0)); - assembler.addChunk(2, base64Encode(data2)); - - final result = assembler.assemble(); - expect(result, Uint8List.fromList([10, 20, 70, 80])); - }); - - test('verifyChecksum succeeds with correct checksum', () { - final data = Uint8List.fromList([1, 2, 3, 4, 5]); - final correctChecksum = md5Checksum(data); - - final assembler = ChunkAssembler( - taskId: 't1', - fileName: 'test.bin', - fileSize: data.length, - checksum: correctChecksum, - totalChunks: 1, - fromId: 'peer-1', - ); - - assembler.addChunk(0, base64Encode(data)); - final assembled = assembler.assemble(); - expect(assembler.verifyChecksum(assembled), true); - }); - - test('verifyChecksum fails with wrong checksum', () { - final data = Uint8List.fromList([1, 2, 3, 4, 5]); - - final assembler = ChunkAssembler( - taskId: 't1', - fileName: 'test.bin', - fileSize: data.length, - checksum: 'wrong_checksum_value', - totalChunks: 1, - fromId: 'peer-1', - ); - - assembler.addChunk(0, base64Encode(data)); - final assembled = assembler.assemble(); - expect(assembler.verifyChecksum(assembled), false); - }); - - test('toResumeState/fromResumeState round-trip', () { - final assembler = ChunkAssembler( - taskId: 't1', - fileName: 'doc.pdf', - fileSize: 5000, - checksum: 'abc123', - totalChunks: 5, - fromId: 'peer-2', - ); - - assembler.addChunk(0, 'AAA'); - assembler.addChunk(2, 'BBB'); - assembler.addChunk(4, 'CCC'); - - final stateMap = assembler.toResumeState(); - final restored = ChunkAssembler.fromResumeState(stateMap); - - expect(restored.taskId, 't1'); - expect(restored.fileName, 'doc.pdf'); - expect(restored.fileSize, 5000); - expect(restored.checksum, 'abc123'); - expect(restored.totalChunks, 5); - expect(restored.fromId, 'peer-2'); - expect(restored.receivedChunks, 0); - expect(restored.missingChunks, [0, 1, 2, 3, 4]); - }); - - test('fromResumeState handles null/missing fields', () { - final restored = ChunkAssembler.fromResumeState({}); - expect(restored.taskId, ''); - expect(restored.fileName, 'unknown'); - expect(restored.fileSize, 0); - expect(restored.checksum, ''); - expect(restored.totalChunks, 0); - expect(restored.fromId, ''); - }); - - test('clear removes all chunks', () { - final assembler = ChunkAssembler( - taskId: 't1', - fileName: 'test.bin', - fileSize: 100, - checksum: '', - totalChunks: 2, - fromId: 'peer-1', - ); - - assembler.addChunk(0, 'AAA'); - assembler.addChunk(1, 'BBB'); - expect(assembler.receivedChunks, 2); - - assembler.clear(); - expect(assembler.receivedChunks, 0); - expect(assembler.isComplete, false); - }); - }); - - group('WsRelayResumeHandler', () { - late MockSignalingService mockSignaling; - late WsRelayResumeHandler handler; - - setUp(() { - mockSignaling = MockSignalingService(); - when(() => mockSignaling.deviceId).thenReturn('device-A'); - handler = WsRelayResumeHandler(signaling: mockSignaling); - }); - - tearDown(() { - handler.dispose(); - }); - - TransferResumeState createState({ - String taskId = 'task-001', - int totalChunks = 10, - List received = const [], - bool isPaused = false, - }) { - return TransferResumeState( - taskId: taskId, - fileName: 'file.bin', - fileSize: 1024, - totalChunks: totalChunks, - receivedChunkIndices: received, - lastActiveTime: DateTime.now(), - isPaused: isPaused, - ); - } - - test('registerResumeState and getResumeState', () { - final state = createState(received: [0, 1, 2]); - handler.registerResumeState(state); - - final retrieved = handler.getResumeState('task-001'); - expect(retrieved, isNotNull); - expect(retrieved!.taskId, 'task-001'); - expect(retrieved.receivedChunkIndices, [0, 1, 2]); - }); - - test('getResumeState returns null for unknown taskId', () { - expect(handler.getResumeState('unknown'), isNull); - }); - - test('updateResumeState updates received indices', () { - final state = createState(received: [0, 1]); - handler.registerResumeState(state); - - handler.updateResumeState('task-001', [0, 1, 2, 3]); - final updated = handler.getResumeState('task-001'); - expect(updated!.receivedChunkIndices, [0, 1, 2, 3]); - }); - - test('updateResumeState does nothing for unknown taskId', () { - handler.updateResumeState('unknown', [0, 1]); - expect(handler.getResumeState('unknown'), isNull); - }); - - test('removeResumeState cleans up', () { - final state = createState(); - handler.registerResumeState(state); - expect(handler.getResumeState('task-001'), isNotNull); - - handler.removeResumeState('task-001'); - expect(handler.getResumeState('task-001'), isNull); - }); - - test('isCancelled returns false after cancelTransfer cleans up', () { - final state = createState(); - handler.registerResumeState(state); - expect(handler.isCancelled('task-001'), false); - - handler.cancelTransfer('task-001'); - expect(handler.isCancelled('task-001'), false); - expect(handler.getResumeState('task-001'), isNull); - }); - - test('cancelTransfer invokes onTransferCancelled callback', () { - final state = createState(); - handler.registerResumeState(state); - - String? cancelledTaskId; - handler.onTransferCancelled = (taskId) { - cancelledTaskId = taskId; - }; - - handler.cancelTransfer('task-001'); - expect(cancelledTaskId, 'task-001'); - }); - - test('isPaused returns false for unknown taskId', () { - expect(handler.isPaused('unknown'), false); - }); - - test('pauseTransfer sets isPaused and creates completer', () async { - final state = createState(); - handler.registerResumeState(state); - - String? pausedTaskId; - handler.onTransferPaused = (taskId) { - pausedTaskId = taskId; - }; - - final pauseFuture = handler.pauseTransfer('task-001'); - expect(handler.isPaused('task-001'), true); - expect(pausedTaskId, 'task-001'); - - handler.resumeTransfer('task-001'); - await pauseFuture; - expect(handler.isPaused('task-001'), false); - }); - - test('resumeTransfer invokes onTransferResumed callback', () async { - final state = createState(); - handler.registerResumeState(state); - - String? resumedTaskId; - handler.onTransferResumed = (taskId) { - resumedTaskId = taskId; - }; - - final pauseFuture = handler.pauseTransfer('task-001'); - handler.resumeTransfer('task-001'); - await pauseFuture; - - expect(resumedTaskId, 'task-001'); - }); - - test('pauseTransfer does nothing if already paused', () async { - final state = createState(isPaused: true); - handler.registerResumeState(state); - - await handler.pauseTransfer('task-001'); - expect(handler.isPaused('task-001'), true); - }); - - test('pauseTransfer does nothing for unknown taskId', () async { - await handler.pauseTransfer('unknown'); - }); - - test('resumeTransfer does nothing for unknown taskId', () { - handler.resumeTransfer('unknown'); - }); - - test('resumeTransfer does nothing if not paused', () { - final state = createState(); - handler.registerResumeState(state); - handler.resumeTransfer('task-001'); - expect(handler.isPaused('task-001'), false); - }); - - test('waitForResumeIfPaused blocks until resumed', () async { - final state = createState(); - handler.registerResumeState(state); - - final pauseFuture = handler.pauseTransfer('task-001'); - - var resumed = false; - final waitFuture = handler.waitForResumeIfPaused('task-001').then((_) { - resumed = true; - }); - - await Future.delayed(const Duration(milliseconds: 10)); - expect(resumed, false); - - handler.resumeTransfer('task-001'); - await pauseFuture; - await waitFuture; - expect(resumed, true); - }); - - test('waitForResumeIfPaused returns immediately if not paused', () async { - final state = createState(); - handler.registerResumeState(state); - await handler.waitForResumeIfPaused('task-001'); - }); - - test('cancelTransfer unblocks paused transfer', () async { - final state = createState(); - handler.registerResumeState(state); - - final pauseFuture = handler.pauseTransfer('task-001'); - - var waitCompleted = false; - final waitFuture = handler.waitForResumeIfPaused('task-001').then((_) { - waitCompleted = true; - }); - - handler.cancelTransfer('task-001'); - await pauseFuture; - await waitFuture; - expect(waitCompleted, true); - }); - - test('sendChunkAck sends via signaling', () { - handler.sendChunkAck( - targetId: 'peer-B', - taskId: 'task-001', - chunkIndex: 3, - receivedCount: 4, - totalChunks: 10, - ); - - verify( - () => mockSignaling.sendCustomMessage(any( - that: isA() - .having((m) => m.type, 'type', SignalingMessageType.chunkAck) - .having((m) => m.to, 'to', 'peer-B') - .having((m) => m.payload!['taskId'], 'taskId', 'task-001') - .having((m) => m.payload!['chunkIndex'], 'chunkIndex', 3) - .having((m) => m.payload!['receivedCount'], 'receivedCount', 4) - .having((m) => m.payload!['totalChunks'], 'totalChunks', 10))), - ).called(1); - }); - - test('sendResumeRequest sends via signaling', () { - handler.sendResumeRequest( - targetId: 'peer-B', - taskId: 'task-001', - missingChunks: [2, 5, 7], - receivedCount: 7, - totalChunks: 10, - ); - - verify( - () => mockSignaling.sendCustomMessage(any( - that: isA() - .having( - (m) => m.type, 'type', SignalingMessageType.resumeRequest) - .having((m) => m.to, 'to', 'peer-B') - .having( - (m) => m.payload!['taskId'], 'taskId', 'task-001') - .having((m) => m.payload!['missingChunks'], 'missingChunks', - [2, 5, 7]) - .having((m) => m.payload!['receivedCount'], 'receivedCount', 7) - .having( - (m) => m.payload!['totalChunks'], 'totalChunks', 10))), - ).called(1); - }); - - test('handleChunkAck processes payload', () { - const message = SignalingMessage( - type: SignalingMessageType.chunkAck, - from: 'peer-B', - to: 'device-A', - payload: { - 'taskId': 'task-001', - 'chunkIndex': 5, - 'receivedCount': 6, - 'totalChunks': 10, - }, - ); - handler.handleChunkAck(message); - }); - - test('handleChunkAck does nothing with null payload', () { - const message = SignalingMessage( - type: SignalingMessageType.chunkAck, - from: 'peer-B', - ); - handler.handleChunkAck(message); - }); - - test('handleResumeRequest invokes callback', () { - String? fromId; - String? taskId; - List? missing; - - handler.onResumeRequested = (f, t, m) { - fromId = f; - taskId = t; - missing = m; - }; - - const message = SignalingMessage( - type: SignalingMessageType.resumeRequest, - from: 'peer-B', - to: 'device-A', - payload: { - 'taskId': 'task-002', - 'missingChunks': [1, 3, 5], - 'receivedCount': 7, - 'totalChunks': 10, - }, - ); - handler.handleResumeRequest(message); - - expect(fromId, 'peer-B'); - expect(taskId, 'task-002'); - expect(missing, [1, 3, 5]); - }); - - test('handleResumeRequest does nothing with null payload', () { - var callbackInvoked = false; - handler.onResumeRequested = (_, __, ___) { - callbackInvoked = true; - }; - - const message = SignalingMessage( - type: SignalingMessageType.resumeRequest, - from: 'peer-B', - ); - handler.handleResumeRequest(message); - expect(callbackInvoked, false); - }); - - test('decideResumeAction returns restartFull when no chunks received', () { - final assembler = ChunkAssembler( - taskId: 't1', - fileName: 'f.bin', - fileSize: 100, - checksum: '', - totalChunks: 5, - fromId: 'peer-1', - ); - - final decision = handler.decideResumeAction(assembler); - expect(decision.action, ResumeAction.restartFull); - }); - - test('decideResumeAction returns resumeFromBreak when missing <= 50%', () { - final assembler = ChunkAssembler( - taskId: 't1', - fileName: 'f.bin', - fileSize: 100, - checksum: '', - totalChunks: 10, - fromId: 'peer-1', - ); - - for (var i = 0; i < 6; i++) { - assembler.addChunk(i, base64Encode(Uint8List.fromList([i]))); - } - - final decision = handler.decideResumeAction(assembler); - expect(decision.action, ResumeAction.resumeFromBreak); - expect(decision.missingChunks, [6, 7, 8, 9]); - }); - - test('decideResumeAction returns restartFull when missing > 50%', () { - final assembler = ChunkAssembler( - taskId: 't1', - fileName: 'f.bin', - fileSize: 100, - checksum: '', - totalChunks: 10, - fromId: 'peer-1', - ); - - for (var i = 0; i < 4; i++) { - assembler.addChunk(i, base64Encode(Uint8List.fromList([i]))); - } - - final decision = handler.decideResumeAction(assembler); - expect(decision.action, ResumeAction.restartFull); - expect(decision.reason, isNotNull); - expect(decision.reason!, contains('50')); - }); - - test('decideResumeAction returns resumeFromBreak when all chunks received', - () { - final assembler = ChunkAssembler( - taskId: 't1', - fileName: 'f.bin', - fileSize: 100, - checksum: '', - totalChunks: 3, - fromId: 'peer-1', - ); - - for (var i = 0; i < 3; i++) { - assembler.addChunk(i, base64Encode(Uint8List.fromList([i]))); - } - - final decision = handler.decideResumeAction(assembler); - expect(decision.action, ResumeAction.resumeFromBreak); - expect(decision.missingChunks, []); - }); - - test('decideResumeAction returns resumeFromBreak at exactly 50% missing', - () { - final assembler = ChunkAssembler( - taskId: 't1', - fileName: 'f.bin', - fileSize: 100, - checksum: '', - totalChunks: 10, - fromId: 'peer-1', - ); - - for (var i = 0; i < 5; i++) { - assembler.addChunk(i, base64Encode(Uint8List.fromList([i]))); - } - - final decision = handler.decideResumeAction(assembler); - expect(decision.action, ResumeAction.resumeFromBreak); - }); - - test('dispose cleans up all state', () async { - final state1 = createState(taskId: 't1'); - final state2 = createState(taskId: 't2'); - handler.registerResumeState(state1); - handler.registerResumeState(state2); - - final pauseFuture = handler.pauseTransfer('t1'); - - handler.dispose(); - await pauseFuture; - expect(handler.getResumeState('t1'), isNull); - expect(handler.getResumeState('t2'), isNull); - }); - }); - - group('TransferTask resume', () { - TransferDevice testPeer() { - return TransferDevice( - id: 'peer-1', - alias: 'Test Device', - deviceType: DeviceType.mobile, - port: 53317, - pairingMethod: PairingMethod.lan, - preferredTransport: TransportType.wsRelay, - lastSeen: DateTime.now(), - isOnline: true, - isVerified: false, - ); - } - - test('canResume is true when resumable + paused + has fileId', () { - final task = TransferTask( - id: 't1', - sessionId: 's1', - peer: testPeer(), - transport: TransportType.wsRelay, - direction: TransferDirection.receive, - status: TransferTaskStatus.paused, - fileName: 'file.bin', - fileSize: 1024, - transferredBytes: 512, - speed: 0, - startTime: DateTime.now(), - fileId: 'file-001', - isResumable: true, - pausedAt: DateTime.now(), - ); - expect(task.canResume, true); - }); - - test('canResume is false when not resumable', () { - final task = TransferTask( - id: 't1', - sessionId: 's1', - peer: testPeer(), - transport: TransportType.wsRelay, - direction: TransferDirection.receive, - status: TransferTaskStatus.paused, - fileName: 'file.bin', - fileSize: 1024, - transferredBytes: 512, - speed: 0, - startTime: DateTime.now(), - fileId: 'file-001', - pausedAt: DateTime.now(), - ); - expect(task.canResume, false); - }); - - test('canResume is false when not paused', () { - final task = TransferTask( - id: 't1', - sessionId: 's1', - peer: testPeer(), - transport: TransportType.wsRelay, - direction: TransferDirection.receive, - status: TransferTaskStatus.transferring, - fileName: 'file.bin', - fileSize: 1024, - transferredBytes: 512, - speed: 0, - startTime: DateTime.now(), - fileId: 'file-001', - isResumable: true, - ); - expect(task.canResume, false); - }); - - test('canResume is false when fileId is null', () { - final task = TransferTask( - id: 't1', - sessionId: 's1', - peer: testPeer(), - transport: TransportType.wsRelay, - direction: TransferDirection.receive, - status: TransferTaskStatus.paused, - fileName: 'file.bin', - fileSize: 1024, - transferredBytes: 512, - speed: 0, - startTime: DateTime.now(), - isResumable: true, - pausedAt: DateTime.now(), - ); - expect(task.canResume, false); - }); - - test('canRetry is true when failed and retryCount < maxRetries', () { - final task = TransferTask( - id: 't1', - sessionId: 's1', - peer: testPeer(), - transport: TransportType.wsRelay, - direction: TransferDirection.receive, - status: TransferTaskStatus.failed, - fileName: 'file.bin', - fileSize: 1024, - transferredBytes: 0, - speed: 0, - startTime: DateTime.now(), - retryCount: 1, - ); - expect(task.canRetry, true); - }); - - test('canRetry is false when not failed', () { - final task = TransferTask( - id: 't1', - sessionId: 's1', - peer: testPeer(), - transport: TransportType.wsRelay, - direction: TransferDirection.receive, - status: TransferTaskStatus.transferring, - fileName: 'file.bin', - fileSize: 1024, - transferredBytes: 0, - speed: 0, - startTime: DateTime.now(), - ); - expect(task.canRetry, false); - }); - - test('canRetry is false when retryCount >= maxRetries', () { - final task = TransferTask( - id: 't1', - sessionId: 's1', - peer: testPeer(), - transport: TransportType.wsRelay, - direction: TransferDirection.receive, - status: TransferTaskStatus.failed, - fileName: 'file.bin', - fileSize: 1024, - transferredBytes: 0, - speed: 0, - startTime: DateTime.now(), - retryCount: 3, - ); - expect(task.canRetry, false); - }); - - test('chunkProgress calculates correctly', () { - final task = TransferTask( - id: 't1', - sessionId: 's1', - peer: testPeer(), - transport: TransportType.wsRelay, - direction: TransferDirection.receive, - status: TransferTaskStatus.transferring, - fileName: 'file.bin', - fileSize: 1024, - transferredBytes: 0, - speed: 0, - startTime: DateTime.now(), - totalChunks: 10, - receivedChunks: {0, 1, 2, 3, 4}, - ); - expect(task.chunkProgress, closeTo(0.5, 0.001)); - }); - - test('chunkProgress falls back to progressPercent when totalChunks is null', - () { - final task = TransferTask( - id: 't1', - sessionId: 's1', - peer: testPeer(), - transport: TransportType.wsRelay, - direction: TransferDirection.receive, - status: TransferTaskStatus.transferring, - fileName: 'file.bin', - fileSize: 1000, - transferredBytes: 500, - speed: 0, - startTime: DateTime.now(), - ); - expect(task.chunkProgress, closeTo(0.5, 0.001)); - }); - - test('chunkProgress falls back when totalChunks is 0', () { - final task = TransferTask( - id: 't1', - sessionId: 's1', - peer: testPeer(), - transport: TransportType.wsRelay, - direction: TransferDirection.receive, - status: TransferTaskStatus.transferring, - fileName: 'file.bin', - fileSize: 1000, - transferredBytes: 500, - speed: 0, - startTime: DateTime.now(), - totalChunks: 0, - ); - expect(task.chunkProgress, closeTo(0.5, 0.001)); - }); - - test('missingChunks calculates correctly', () { - final task = TransferTask( - id: 't1', - sessionId: 's1', - peer: testPeer(), - transport: TransportType.wsRelay, - direction: TransferDirection.receive, - status: TransferTaskStatus.transferring, - fileName: 'file.bin', - fileSize: 1024, - transferredBytes: 0, - speed: 0, - startTime: DateTime.now(), - totalChunks: 10, - receivedChunks: {0, 1, 2}, - ); - expect(task.missingChunks, 7); - }); - - test('missingChunks is null when totalChunks is null', () { - final task = TransferTask( - id: 't1', - sessionId: 's1', - peer: testPeer(), - transport: TransportType.wsRelay, - direction: TransferDirection.receive, - status: TransferTaskStatus.transferring, - fileName: 'file.bin', - fileSize: 1024, - transferredBytes: 0, - speed: 0, - startTime: DateTime.now(), - ); - expect(task.missingChunks, isNull); - }); - - test('missingChunks is 0 when all received', () { - final task = TransferTask( - id: 't1', - sessionId: 's1', - peer: testPeer(), - transport: TransportType.wsRelay, - direction: TransferDirection.receive, - status: TransferTaskStatus.transferring, - fileName: 'file.bin', - fileSize: 1024, - transferredBytes: 0, - speed: 0, - startTime: DateTime.now(), - totalChunks: 5, - receivedChunks: {0, 1, 2, 3, 4}, - ); - expect(task.missingChunks, 0); - }); - }); -} - -String md5Checksum(Uint8List data) { - final digest = md5.convert(data); - return digest.toString(); -}