diff --git a/lib/main.dart b/lib/main.dart index 70ceafc..54b5f60 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'utils/theme/app_theme.dart'; import 'utils/app_router.dart'; import 'utils/user_prefs.dart'; import 'utils/sync/auto_backup_service.dart'; +import 'utils/usage_stats_service.dart'; import 'providers/app_provider.dart'; import 'package:flutter/widget_previews.dart'; @@ -30,6 +31,8 @@ void main() async { await appProvider.initDatabase(); // 检查并恢复本地自动备份 await _initAutoBackup(); + // 启动匿名用户统计(需配置服务器地址后生效) + await _initUsageStats(); runApp(MyApp(appProvider: appProvider)); } @@ -46,6 +49,15 @@ Future _initAutoBackup() async { } } +/// 初始化匿名用户统计 +Future _initUsageStats() async { + try { + await UsageStatsService.instance.start(); + } catch (e) { + print('初始化用户统计失败: $e'); + } +} + class MyApp extends StatelessWidget { final AppProvider appProvider; diff --git a/lib/pages/profile_page.dart b/lib/pages/profile_page.dart index b8e011f..ba5652a 100644 --- a/lib/pages/profile_page.dart +++ b/lib/pages/profile_page.dart @@ -49,7 +49,7 @@ class _ProfilePageState extends State { _version = packageInfo.version; }); } - + /// 加载用户数据 Future _loadUserData() async { setState(() => _isLoading = true); @@ -103,9 +103,9 @@ class _ProfilePageState extends State { // 数据统计 _buildStatsSection(), - + const SizedBox(height: 8), - + // 功能菜单 _buildMenuSection(), @@ -692,8 +692,6 @@ class _SettingsPageState extends State { ); }, ), - const Divider(height: 0.5, indent: 24, endIndent: 24), - // 使用说明 _buildSectionHeader('帮助'), _buildLinkItem( diff --git a/lib/pages/tag_management_page.dart b/lib/pages/tag_management_page.dart index e66af87..b7e2e14 100644 --- a/lib/pages/tag_management_page.dart +++ b/lib/pages/tag_management_page.dart @@ -12,6 +12,7 @@ class TagManagementPage extends StatefulWidget { class _TagManagementPageState extends State { int _currentIndex = 0; + bool _isSyncing = false; static const _tabTypes = ['movie_genre', 'book_genre', 'note_tag']; static const _typeLabels = ['影视类型', '书籍类型', '笔记标签']; @@ -32,6 +33,20 @@ class _TagManagementPageState extends State { } } + Future _syncTags() async { + setState(() => _isSyncing = true); + try { + final provider = context.read(); + final count = await provider.syncTagsFromData(); + if (mounted) { + ToastUtil.show(context, count > 0 ? '已同步 $count 个新标签' : '标签已是最新'); + await _loadTags(_currentType); + } + } finally { + if (mounted) setState(() => _isSyncing = false); + } + } + String get _currentType => _tabTypes[_currentIndex]; @override @@ -40,6 +55,25 @@ class _TagManagementPageState extends State { backgroundColor: Colors.white, appBar: AppBar( title: const Text('标签管理'), + actions: [ + _isSyncing + ? const Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFF1A1A1A), + ), + ), + ) + : IconButton( + icon: const Icon(Icons.sync, size: 20), + tooltip: '从数据中同步标签', + onPressed: _syncTags, + ), + ], ), body: Column( children: [ @@ -477,6 +511,15 @@ class _TagManagementPageState extends State { groupValue: selectedAction, onChanged: (v) => setDialogState(() => selectedAction = v), title: '从所有条目中移除该标签', + subtitle: '标签将从影视/书籍/笔记中清除', + ), + const SizedBox(height: 4), + _buildDeleteOption( + value: 'deleteOnly', + groupValue: selectedAction, + onChanged: (v) => setDialogState(() => selectedAction = v), + title: '仅删除标签', + subtitle: '保留已有条目上的标签名', ), const SizedBox(height: 4), _buildDeleteOption( @@ -573,6 +616,7 @@ class _TagManagementPageState extends State { if (replacement == null) return; } Navigator.pop(ctx, { + 'action': selectedAction, 'replacement': replacement, }); }, @@ -595,11 +639,18 @@ class _TagManagementPageState extends State { ), ).then((result) async { if (result == null) return; + final action = result['action'] as String; final replacement = result['replacement'] as String?; - await context - .read() - .deleteTag(tagId, type, replacementName: replacement); + if (action == 'deleteOnly') { + await context + .read() + .deleteTagOnly(tagId, type); + } else { + await context + .read() + .deleteTag(tagId, type, replacementName: replacement); + } if (mounted) { ToastUtil.show(context, '删除成功'); } @@ -612,6 +663,7 @@ class _TagManagementPageState extends State { required String? groupValue, required ValueChanged onChanged, required String title, + String? subtitle, }) { final selected = value == groupValue; return GestureDetector( @@ -642,13 +694,32 @@ class _TagManagementPageState extends State { ), ), const SizedBox(width: 12), - Text( - title, - style: TextStyle( - fontSize: 14, - fontWeight: selected ? FontWeight.w500 : FontWeight.normal, - color: - selected ? const Color(0xFF1A1A1A) : const Color(0xFF666666), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: selected ? FontWeight.w500 : FontWeight.normal, + color: + selected ? const Color(0xFF1A1A1A) : const Color(0xFF666666), + ), + ), + if (subtitle != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + subtitle, + style: const TextStyle( + fontSize: 11, + color: Color(0xFFAAAAAA), + ), + ), + ), + ], ), ), ], diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 2384dfa..eed20aa 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -8,6 +8,7 @@ import '../utils/movie/movie_poster_dao.dart'; import '../utils/book/book_review_dao.dart'; import '../utils/book/book_excerpt_dao.dart'; import '../utils/tag/tag_dao.dart'; +import '../utils/database_helper.dart'; import '../utils/image_path_helper.dart'; /// 应用全局状态管理 @@ -409,6 +410,64 @@ class AppProvider extends ChangeNotifier { await _reloadByTagType(type); } + /// 仅删除标签本身,不级联影响已有条目 + Future deleteTagOnly(String tagId, String type) async { + await _tagDao.deleteTagOnly(tagId); + await _reloadByTagType(type); + } + + /// 从影视/书籍/笔记数据中解析标签,同步到 tags 表 + Future syncTagsFromData() async { + final db = await DatabaseHelper.instance.database; + final now = DateTime.now().toIso8601String(); + int counter = 0; + int added = 0; + + Future insertTag(String name, String type) async { + try { + await db.insert('tags', { + 'id': 'tag_${DateTime.now().millisecondsSinceEpoch}_${counter++}', + 'name': name, + 'type': type, + 'created_at': now, + }); + added++; + } catch (_) { + // 忽略 UNIQUE 约束冲突(标签已存在) + } + } + + // 影视类型 + final movies = await db.query('movies', + where: 'genres IS NOT NULL AND genres != ?', whereArgs: ['[]']); + for (final row in movies) { + for (final genre in Movie.parseStringList(row['genres'])) { + await insertTag(genre, 'movie_genre'); + } + } + + // 书籍类型 + final books = await db.query('books', + where: 'genres IS NOT NULL AND genres != ?', whereArgs: ['[]']); + for (final row in books) { + for (final genre in Movie.parseStringList(row['genres'])) { + await insertTag(genre, 'book_genre'); + } + } + + // 笔记标签 + final notes = await db.query('notes', + where: 'tags IS NOT NULL AND tags != ? AND tags != ?', + whereArgs: ['[]', '']); + for (final row in notes) { + for (final tag in Movie.parseStringList(row['tags'])) { + await insertTag(tag, 'note_tag'); + } + } + + return added; + } + Future _reloadByTagType(String type) async { switch (type) { case 'movie_genre': diff --git a/lib/utils/sync/backup_service.dart b/lib/utils/sync/backup_service.dart index 6f26817..c43c53f 100644 --- a/lib/utils/sync/backup_service.dart +++ b/lib/utils/sync/backup_service.dart @@ -27,6 +27,7 @@ class BackupService { final notes = await db.query('notes'); final movieReviews = await db.query('movie_reviews'); final moviePosters = await db.query('movie_posters'); + final tags = await db.query('tags'); // 收集所有图片路径 final imagePaths = {}; @@ -99,6 +100,7 @@ class BackupService { 'notes': notes, 'movie_reviews': movieReviews, 'movie_posters': moviePosters, + 'tags': tags, }, }; @@ -209,6 +211,7 @@ class BackupService { final notes = await db.query('notes'); final movieReviews = await db.query('movie_reviews'); final moviePosters = await db.query('movie_posters'); + final tags = await db.query('tags'); // 收集所有图片路径 final imagePaths = {}; @@ -281,6 +284,7 @@ class BackupService { 'notes': notes, 'movie_reviews': movieReviews, 'movie_posters': moviePosters, + 'tags': tags, }, }; @@ -424,6 +428,7 @@ class BackupService { await txn.delete('movies'); await txn.delete('books'); await txn.delete('notes'); + await txn.delete('tags'); // 导入影视数据(更新图片路径) if (data.containsKey('movies')) { @@ -472,6 +477,14 @@ class BackupService { await txn.insert('movie_posters', updatedMap); } } + + // 导入标签数据 + if (data.containsKey('tags')) { + final tags = data['tags'] as List; + for (final tag in tags) { + await txn.insert('tags', _convertToDbMap(tag)); + } + } }); // 恢复用户个人信息 @@ -514,6 +527,9 @@ class BackupService { if (data.containsKey('movie_posters')) { stats['海报'] = (data['movie_posters'] as List).length; } + if (data.containsKey('tags')) { + stats['标签'] = (data['tags'] as List).length; + } if (imageCount > 0) { stats['图片'] = imageCount; } @@ -623,6 +639,14 @@ class BackupService { await txn.insert('movie_posters', updatedMap); } } + + // 导入标签数据 + if (data.containsKey('tags')) { + final tags = data['tags'] as List; + for (final tag in tags) { + await txn.insert('tags', _convertToDbMap(tag)); + } + } }); // 恢复用户个人信息 diff --git a/lib/utils/tag/tag_dao.dart b/lib/utils/tag/tag_dao.dart index 8257355..ac1b44a 100644 --- a/lib/utils/tag/tag_dao.dart +++ b/lib/utils/tag/tag_dao.dart @@ -102,6 +102,12 @@ class TagDao { await db.delete('tags', where: 'id = ?', whereArgs: [tagId]); } + /// 仅删除标签本身,不级联影响已有条目(标签名保留在条目上) + Future deleteTagOnly(String tagId) async { + final db = await _dbHelper.database; + await db.delete('tags', where: 'id = ?', whereArgs: [tagId]); + } + /// 确保标签存在(用于替换操作) Future _ensureTagExists(String name, String type) async { final db = await _dbHelper.database; diff --git a/lib/utils/usage_stats_service.dart b/lib/utils/usage_stats_service.dart new file mode 100644 index 0000000..6216265 --- /dev/null +++ b/lib/utils/usage_stats_service.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'user_prefs.dart'; + +/// 匿名用户统计服务(静默运行,对用户不可见) +/// +/// App 启动后每 5 分钟向统计服务器发送匿名心跳。 +/// 统计数据在服务端管理后台查看,App 内无入口。 +class UsageStatsService with WidgetsBindingObserver { + static final UsageStatsService instance = UsageStatsService._(); + UsageStatsService._(); + + final UserPrefs _prefs = UserPrefs(); + + /// 统计服务器地址,发布前替换为实际地址,置空则禁用 + static String serverUrl = 'http://192.168.31.48:5000'; + + Timer? _heartbeatTimer; + bool _started = false; + + static const _heartbeatInterval = Duration(minutes: 5); + + /// 启动统计服务(App 启动时调用一次) + Future start() async { + if (_started) return; + _started = true; + + // 未配置服务器地址则直接跳过 + if (serverUrl.isEmpty) return; + + // 首次启动生成匿名设备ID + await _ensureDeviceId(); + + // 注册生命周期监听 + WidgetsBinding.instance.addObserver(this); + + // 立即发送一次心跳 + await _sendHeartbeat(); + + // 启动定时心跳 + _startTimer(); + } + + /// 停止统计服务 + Future stop() async { + if (!_started) return; + _started = false; + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + WidgetsBinding.instance.removeObserver(this); + } + + /// 获取统计信息(总用户数 / 在线数) + static Future?> fetchStats() async { + if (serverUrl.isEmpty) return null; + + try { + final response = await http + .get(Uri.parse('$serverUrl/api/stats')) + .timeout(const Duration(seconds: 5)); + if (response.statusCode == 200) { + return jsonDecode(response.body) as Map; + } + } catch (_) { + // 静默失败 + } + return null; + } + + // ─── 内部方法 ────────────────────────────────────────────────────────── + + /// 确保设备有匿名ID + Future _ensureDeviceId() async { + if (_prefs.deviceId.isEmpty) { + final id = _generateDeviceId(); + await _prefs.setDeviceId(id); + } + } + + /// 生成匿名设备标识 + String _generateDeviceId() { + final random = Random.secure(); + final bytes = List.generate(16, (_) => random.nextInt(256)); + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '${hex.substring(0, 8)}-' + '${hex.substring(8, 12)}-' + '${hex.substring(12, 16)}-' + '${hex.substring(16, 20)}-' + '${hex.substring(20, 32)}'; + } + + /// 发送心跳 + Future _sendHeartbeat() async { + if (serverUrl.isEmpty) return; + final deviceId = _prefs.deviceId; + if (deviceId.isEmpty) return; + + try { + await http + .post( + Uri.parse('$serverUrl/api/heartbeat'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'device_hash': deviceId}), + ) + .timeout(const Duration(seconds: 5)); + } catch (_) { + // 静默失败,不影响主流程 + } + } + + /// 启动定时心跳 + void _startTimer() { + _heartbeatTimer?.cancel(); + _heartbeatTimer = Timer.periodic(_heartbeatInterval, (_) { + _sendHeartbeat(); + }); + } + + // ─── 生命周期 ────────────────────────────────────────────────────────── + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + // 回到前台:立即发送心跳,恢复定时器 + _sendHeartbeat(); + _startTimer(); + } else if (state == AppLifecycleState.paused) { + // 进入后台:停止定时器 + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + } + } +} diff --git a/lib/utils/user_prefs.dart b/lib/utils/user_prefs.dart index b2ab9c1..2ca5ed7 100644 --- a/lib/utils/user_prefs.dart +++ b/lib/utils/user_prefs.dart @@ -83,4 +83,10 @@ class UserPrefs { /// 当前选中的应用图标名称(对应 assets/icon/ 下的文件名,不含扩展名) String get appIconName => prefs.getString('appIconName') ?? 'app_icon'; Future setAppIconName(String value) => prefs.setString('appIconName', value); + + // ========== 用户统计设置 ========== + + /// 匿名设备标识(首次启动自动生成) + String get deviceId => prefs.getString('deviceId') ?? ''; + Future setDeviceId(String value) => prefs.setString('deviceId', value); } diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..816046f --- /dev/null +++ b/server/README.md @@ -0,0 +1,65 @@ +# MookNote 用户统计服务 + +## 部署 + +### 1. 安装依赖 + +```bash +cd server +pip install -r requirements.txt +``` + +### 2. 启动服务 + +```bash +python app.py +``` + +默认监听 `0.0.0.0:5000`,可通过环境变量 `PORT` 修改端口。 + +### 3. 生产环境部署 + +推荐使用 gunicorn: + +```bash +pip install gunicorn +gunicorn -w 2 -b 0.0.0.0:5000 app:app +``` + +或使用 systemd 设为开机自启。 + +## API + +### POST /api/heartbeat +心跳上报,App 启动时和每 5 分钟调用一次。 + +请求体: +```json +{ "device_hash": "设备匿名标识" } +``` + +### GET /api/stats +获取统计数据。 + +响应: +```json +{ "total_users": 10, "online_users": 3 } +``` + +- `total_users`: 历史总设备数 +- `online_users`: 最近 5 分钟内有心跳的设备数 + +## 数据存储 + +SQLite 数据库 `stats.db`,结构: + +```sql +CREATE TABLE devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_hash TEXT UNIQUE NOT NULL, -- SHA256 哈希后的设备标识 + first_seen TEXT NOT NULL, -- 首次出现时间 + last_seen TEXT NOT NULL -- 最后心跳时间 +); +``` + +所有数据均为匿名,不包含任何设备原始信息。 diff --git a/server/app.py b/server/app.py new file mode 100644 index 0000000..acad8a8 --- /dev/null +++ b/server/app.py @@ -0,0 +1,104 @@ +""" +MookNote 用户统计服务 +Flask + SQLite,匿名统计设备数和在线数 +""" + +import sqlite3 +import hashlib +import os +from datetime import datetime, timezone, timedelta + +from flask import Flask, request, jsonify + +app = Flask(__name__) + +DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stats.db") +ONLINE_THRESHOLD_MINUTES = 5 # 超过此时间未心跳视为离线 + + +def get_db() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + with get_db() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_hash TEXT UNIQUE NOT NULL, + first_seen TEXT NOT NULL, + last_seen TEXT NOT NULL + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_last_seen ON devices(last_seen)" + ) + conn.commit() + + +# ─── API ──────────────────────────────────────────────────────────────────── + + +@app.route("/api/heartbeat", methods=["POST"]) +def heartbeat(): + """接收匿名心跳""" + data = request.get_json(silent=True) or {} + device_hash = data.get("device_hash", "").strip() + if not device_hash: + return jsonify({"error": "device_hash is required"}), 400 + + # 只存哈希,不存原始设备信息 + h = hashlib.sha256(device_hash.encode()).hexdigest() + now_iso = datetime.now(timezone.utc).isoformat() + + with get_db() as conn: + row = conn.execute( + "SELECT id FROM devices WHERE device_hash = ?", (h,) + ).fetchone() + + if row: + conn.execute( + "UPDATE devices SET last_seen = ? WHERE device_hash = ?", + (now_iso, h), + ) + else: + conn.execute( + "INSERT INTO devices (device_hash, first_seen, last_seen) VALUES (?, ?, ?)", + (h, now_iso, now_iso), + ) + conn.commit() + + return jsonify({"status": "ok"}) + + +@app.route("/api/stats", methods=["GET"]) +def stats(): + """获取统计:总用户数和当前在线数""" + threshold = ( + datetime.now(timezone.utc) - timedelta(minutes=ONLINE_THRESHOLD_MINUTES) + ).isoformat() + + with get_db() as conn: + total = conn.execute("SELECT COUNT(*) FROM devices").fetchone()[0] + online = conn.execute( + "SELECT COUNT(*) FROM devices WHERE last_seen >= ?", (threshold,) + ).fetchone()[0] + + return jsonify({"total_users": total, "online_users": online}) + + +@app.route("/", methods=["GET"]) +def index(): + return "MookNote Stats Server is running." + + +# ─── MAIN ─────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + init_db() + port = int(os.environ.get("PORT", 5000)) + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..dbcbaf7 --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1 @@ +flask==3.1.0 diff --git a/server/stats.db b/server/stats.db new file mode 100644 index 0000000..cf1335d Binary files /dev/null and b/server/stats.db differ