加入用户统计信息

This commit is contained in:
DelLevin-Home
2026-05-23 00:01:42 +08:00
parent af2ac16521
commit 39df4f9cbe
12 changed files with 497 additions and 15 deletions

View File

@@ -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<void> _initAutoBackup() async {
}
}
/// 初始化匿名用户统计
Future<void> _initUsageStats() async {
try {
await UsageStatsService.instance.start();
} catch (e) {
print('初始化用户统计失败: $e');
}
}
class MyApp extends StatelessWidget {
final AppProvider appProvider;

View File

@@ -49,7 +49,7 @@ class _ProfilePageState extends State<ProfilePage> {
_version = packageInfo.version;
});
}
/// 加载用户数据
Future<void> _loadUserData() async {
setState(() => _isLoading = true);
@@ -103,9 +103,9 @@ class _ProfilePageState extends State<ProfilePage> {
// 数据统计
_buildStatsSection(),
const SizedBox(height: 8),
// 功能菜单
_buildMenuSection(),
@@ -692,8 +692,6 @@ class _SettingsPageState extends State<SettingsPage> {
);
},
),
const Divider(height: 0.5, indent: 24, endIndent: 24),
// 使用说明
_buildSectionHeader('帮助'),
_buildLinkItem(

View File

@@ -12,6 +12,7 @@ class TagManagementPage extends StatefulWidget {
class _TagManagementPageState extends State<TagManagementPage> {
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<TagManagementPage> {
}
}
Future<void> _syncTags() async {
setState(() => _isSyncing = true);
try {
final provider = context.read<AppProvider>();
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<TagManagementPage> {
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<TagManagementPage> {
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<TagManagementPage> {
if (replacement == null) return;
}
Navigator.pop(ctx, {
'action': selectedAction,
'replacement': replacement,
});
},
@@ -595,11 +639,18 @@ class _TagManagementPageState extends State<TagManagementPage> {
),
).then((result) async {
if (result == null) return;
final action = result['action'] as String;
final replacement = result['replacement'] as String?;
await context
.read<AppProvider>()
.deleteTag(tagId, type, replacementName: replacement);
if (action == 'deleteOnly') {
await context
.read<AppProvider>()
.deleteTagOnly(tagId, type);
} else {
await context
.read<AppProvider>()
.deleteTag(tagId, type, replacementName: replacement);
}
if (mounted) {
ToastUtil.show(context, '删除成功');
}
@@ -612,6 +663,7 @@ class _TagManagementPageState extends State<TagManagementPage> {
required String? groupValue,
required ValueChanged<String?> onChanged,
required String title,
String? subtitle,
}) {
final selected = value == groupValue;
return GestureDetector(
@@ -642,13 +694,32 @@ class _TagManagementPageState extends State<TagManagementPage> {
),
),
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),
),
),
),
],
),
),
],

View File

@@ -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<void> deleteTagOnly(String tagId, String type) async {
await _tagDao.deleteTagOnly(tagId);
await _reloadByTagType(type);
}
/// 从影视/书籍/笔记数据中解析标签,同步到 tags 表
Future<int> syncTagsFromData() async {
final db = await DatabaseHelper.instance.database;
final now = DateTime.now().toIso8601String();
int counter = 0;
int added = 0;
Future<void> 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<void> _reloadByTagType(String type) async {
switch (type) {
case 'movie_genre':

View File

@@ -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 = <String>{};
@@ -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 = <String>{};
@@ -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<dynamic>;
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<dynamic>;
for (final tag in tags) {
await txn.insert('tags', _convertToDbMap(tag));
}
}
});
// 恢复用户个人信息

View File

@@ -102,6 +102,12 @@ class TagDao {
await db.delete('tags', where: 'id = ?', whereArgs: [tagId]);
}
/// 仅删除标签本身,不级联影响已有条目(标签名保留在条目上)
Future<void> deleteTagOnly(String tagId) async {
final db = await _dbHelper.database;
await db.delete('tags', where: 'id = ?', whereArgs: [tagId]);
}
/// 确保标签存在(用于替换操作)
Future<void> _ensureTagExists(String name, String type) async {
final db = await _dbHelper.database;

View File

@@ -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<void> start() async {
if (_started) return;
_started = true;
// 未配置服务器地址则直接跳过
if (serverUrl.isEmpty) return;
// 首次启动生成匿名设备ID
await _ensureDeviceId();
// 注册生命周期监听
WidgetsBinding.instance.addObserver(this);
// 立即发送一次心跳
await _sendHeartbeat();
// 启动定时心跳
_startTimer();
}
/// 停止统计服务
Future<void> stop() async {
if (!_started) return;
_started = false;
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
WidgetsBinding.instance.removeObserver(this);
}
/// 获取统计信息(总用户数 / 在线数)
static Future<Map<String, dynamic>?> 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<String, dynamic>;
}
} catch (_) {
// 静默失败
}
return null;
}
// ─── 内部方法 ──────────────────────────────────────────────────────────
/// 确保设备有匿名ID
Future<void> _ensureDeviceId() async {
if (_prefs.deviceId.isEmpty) {
final id = _generateDeviceId();
await _prefs.setDeviceId(id);
}
}
/// 生成匿名设备标识
String _generateDeviceId() {
final random = Random.secure();
final bytes = List<int>.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<void> _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;
}
}
}

View File

@@ -83,4 +83,10 @@ class UserPrefs {
/// 当前选中的应用图标名称(对应 assets/icon/ 下的文件名,不含扩展名)
String get appIconName => prefs.getString('appIconName') ?? 'app_icon';
Future<bool> setAppIconName(String value) => prefs.setString('appIconName', value);
// ========== 用户统计设置 ==========
/// 匿名设备标识(首次启动自动生成)
String get deviceId => prefs.getString('deviceId') ?? '';
Future<bool> setDeviceId(String value) => prefs.setString('deviceId', value);
}