初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
@@ -0,0 +1,104 @@
import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart';
/// 浏览历史本地存储基类(sqflite 持久化)
/// 子类需声明 [dbName] 和 [tableName],每种业务类型独占一个 db 文件
abstract class BaseHistoryRecordStore {
/// 子类提供:db 文件名(如 video_history.db
String get dbName;
/// 子类提供:表名(如 video_history
String get tableName;
/// 同一类型保留最大条数(超过后插入时自动删最旧)
static const int maxRows = 1000;
Database? _db;
Future<Database> _openDB() async {
if (_db != null) return _db!;
final dir = await getDatabasesPath();
final path = p.join(dir, dbName);
_db = await openDatabase(
path,
version: 1,
onCreate: (db, v) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS $tableName(
model_id TEXT PRIMARY KEY,
create_time INTEGER NOT NULL,
model_data TEXT NOT NULL
);
''');
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_${tableName}_time ON $tableName(create_time DESC)',
);
},
);
return _db!;
}
/// 保存记录(model_id 主键冲突时 REPLACE 实现去重,自动刷新 create_time = "插到最前"
Future<bool> save({
required String modelId,
required String modelDataJson,
}) async {
if (modelId.isEmpty) return false;
final db = await _openDB();
await db.insert(
tableName,
{
'model_id': modelId,
'create_time': DateTime.now().millisecondsSinceEpoch,
'model_data': modelDataJson,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
await _trimOverflow(db);
return true;
}
/// 分页查询(按创建时间倒序),返回 model_data JSON 字符串列表
Future<List<String>> fetch({
int page = 1,
int pageSize = maxRows,
}) async {
final db = await _openDB();
final offset = ((page - 1) < 0 ? 0 : (page - 1)) * pageSize;
final rows = await db.query(
tableName,
columns: ['model_data'],
orderBy: 'create_time DESC',
limit: pageSize,
offset: offset,
);
return rows.map((r) => r['model_data'] as String).toList();
}
/// 删除单条
Future<bool> remove(String modelId) async {
if (modelId.isEmpty) return false;
final db = await _openDB();
await db.delete(tableName, where: 'model_id = ?', whereArgs: [modelId]);
return true;
}
/// 清空当前类型的所有记录
Future<void> clean() async {
final db = await _openDB();
await db.delete(tableName);
}
/// 超过 maxRows 时删除最旧的若干条
Future<void> _trimOverflow(Database db) async {
final cntRow = await db.rawQuery('SELECT COUNT(*) AS cnt FROM $tableName');
final cnt = Sqflite.firstIntValue(cntRow) ?? 0;
if (cnt <= maxRows) return;
final excess = cnt - maxRows;
await db.execute(
'DELETE FROM $tableName WHERE rowid IN ('
'SELECT rowid FROM $tableName ORDER BY create_time ASC LIMIT ?)',
[excess],
);
}
}
+14
View File
@@ -0,0 +1,14 @@
import 'base_history_record_store.dart';
/// 卡通浏览历史(MediaStyle.Cartoon
class CartoonHistoryStore extends BaseHistoryRecordStore {
static CartoonHistoryStore? _instance;
factory CartoonHistoryStore() => _instance ??= CartoonHistoryStore._();
CartoonHistoryStore._();
@override
String get dbName => 'cartoon_history.db';
@override
String get tableName => 'cartoon_history';
}
+14
View File
@@ -0,0 +1,14 @@
import 'base_history_record_store.dart';
/// 漫画浏览历史(MediaStyle.Comics
class ComicsHistoryStore extends BaseHistoryRecordStore {
static ComicsHistoryStore? _instance;
factory ComicsHistoryStore() => _instance ??= ComicsHistoryStore._();
ComicsHistoryStore._();
@override
String get dbName => 'comics_history.db';
@override
String get tableName => 'comics_history';
}
@@ -0,0 +1,14 @@
import 'base_history_record_store.dart';
/// 社区帖子浏览历史(MediaStyle.Community
class CommunityHistoryStore extends BaseHistoryRecordStore {
static CommunityHistoryStore? _instance;
factory CommunityHistoryStore() => _instance ??= CommunityHistoryStore._();
CommunityHistoryStore._();
@override
String get dbName => 'community_history.db';
@override
String get tableName => 'community_history';
}
+157
View File
@@ -0,0 +1,157 @@
import 'dart:convert';
import 'package:hgdj/hj_model/drama/drama_models.dart';
import 'package:hgdj/hj_model/drama_media_info.dart';
import 'package:hgdj/tools_base/cache/history/base_history_record_store.dart';
import 'package:hgdj/tools_base/debug_log.dart';
/// 短剧观看记录:纯本地,不依赖后端。一部剧一行(看到哪一集、第几秒 + 剧信息快照),
/// 最多留 [BaseHistoryRecordStore.maxRows] 条,超了先淘汰最久没看的那部。
/// 它同时是「续播位置」和「历史记录」两件事的数据源——每次写入都会刷新 create_time
/// 所以 [history] 按 create_time 倒序拿到的就是最近观看列表,不用再建第二张表
class DramaResumeStore extends BaseHistoryRecordStore {
static final DramaResumeStore _instance = DramaResumeStore._();
factory DramaResumeStore() => _instance;
DramaResumeStore._();
@override
String get dbName => 'drama_resume.db';
@override
String get tableName => 'drama_resume';
//读盘一次,之后 of() 都走内存(它是同步的,定位续播集时没法等 IO)
Map<String, DramaResume>? _cache;
//并发挡板:Feed 和二级页会同时调 load(),没这层的话后到的直接返回,
//拿着还没填好的表去定位,续播位置就白丢了
Future<void>? _loading;
/// 全表读进内存。进短剧频道/二级页前调一次即可,重复调只读一次
Future<void> load() => _loading ??= _load();
Future<void> _load() async {
final disk = <String, DramaResume>{};
try {
//反着插:Map 保留插入顺序,最旧的排在队头,超量从队头砍,队尾就是最近看的
for (final item in (await _readAllDesc()).reversed) {
disk[item.mediaId] = item;
}
} catch (e) {
debugLog('短剧续播记录读取失败', e.toString()); // 坏数据直接当没有,别把页面带崩
}
//读盘期间已经看起来的那几条比盘上的新:后放,既覆盖旧值又排到队尾
final live = _cache;
_cache = disk;
live?.forEach(_put);
}
/// 写内存表:先删再插把它挪到队尾(=最近观看),超 [BaseHistoryRecordStore.maxRows] 从队头砍。
/// 淘汰口径要和 db 的 create_time 对齐,否则内存里会留着盘上已经没有的剧
void _put(String mediaId, DramaResume item) {
final cache = _cache ??= {};
cache
..remove(mediaId)
..[mediaId] = item;
while (cache.length > BaseHistoryRecordStore.maxRows) {
cache.remove(cache.keys.first);
}
}
/// 这部剧上次看到哪:没看过返回 null
DramaResume? of(String? mediaId) => mediaId == null ? null : _cache?[mediaId];
/// 读全表并按「最后观看时间」倒序。老数据没有 watchedAt(全是 0),同值时退回 db 给的
/// create_time 顺序,别让升级前的那批互相乱掉——List.sort 不保证稳定,所以拿下标当次序键
Future<List<DramaResume>> _readAllDesc() async {
final rows = await fetch(); // db 那层给的就是 create_time 倒序
final indexed = <(int, DramaResume)>[];
for (var i = 0; i < rows.length; i++) {
final item = DramaResume.fromJson(jsonDecode(rows[i]));
if (item.mediaId.isNotEmpty) indexed.add((i, item));
}
indexed.sort((a, b) {
final byTime = b.$2.watchedAt.compareTo(a.$2.watchedAt);
return byTime != 0 ? byTime : a.$1.compareTo(b.$1);
});
return indexed.map((e) => e.$2).toList();
}
/// 观看历史:按最后观看时间倒序分页,不走 db 分页——排序键是记录里的 watchedAt,
/// 本地表统共上限 1000 条,整表读出来再切页就够,也免得把内存表里的剧信息快照交出去被改
Future<List<DramaResume>> history({int page = 1, int pageSize = 20}) async {
try {
final all = (await _readAllDesc()).where((e) => e.drama != null).toList();
final start = (page < 1 ? 0 : page - 1) * pageSize;
if (start >= all.length) return [];
final end = start + pageSize;
return all.sublist(start, end > all.length ? all.length : end);
} catch (e) {
debugLog('短剧观看记录读取失败', e.toString());
return [];
}
}
/// 删一条(历史记录页编辑态用)。内存表要一起删,否则 [of] 还会把它当续播位置返回
Future<void> erase(String? mediaId) async {
if (mediaId == null || mediaId.isEmpty) return;
_cache?.remove(mediaId);
await remove(mediaId);
}
/// 刷剧模式划到这部剧:每次都把观看时间刷成现在(历史记录按它倒序,最后刷到的排最前),
/// 但进度一律不动——已有记录就原样沿用二级页记下的集数+秒数,没有才新建一条 0 秒的
Future<void> markWatched({
required String? mediaId,
required String? contentId,
DramaMediaInfo? drama,
}) async {
if (mediaId == null || mediaId.isEmpty) return;
await load(); // 盘还没读完就取,会把已有进度当成没有,反手写一条 0 秒的盖掉
final last = _cache?[mediaId];
record(
mediaId: mediaId,
//空 contentId 会被 record 挡掉,那样这次刷新就白记了,退回当前这一集
contentId:
last?.contentId.isNotEmpty == true ? last!.contentId : contentId,
seconds: last?.progressSeconds ?? 0,
drama: drama ?? last?.drama,
);
}
/// 记一次进度。[seconds] 传 0 表示从头看(本集刚播完时用)。
/// [drama] 是列表展示用的剧信息快照,不传就沿用这部剧上一条记录里的,别把它写没了
void record({
required String? mediaId,
required String? contentId,
required int seconds,
DramaMediaInfo? drama,
}) {
//空串和 null 一样要挡:contentId 为空的记录谁都匹配不上,
//却会把这部剧上一条好记录顶掉,还会让 _resumeIndex 白翻完整部剧的分页
if (mediaId == null ||
mediaId.isEmpty ||
contentId == null ||
contentId.isEmpty) return;
final item = DramaResume(
mediaId: mediaId,
contentId: contentId,
progressSeconds: seconds < 0 ? 0 : seconds,
drama: drama ?? _cache?[mediaId]?.drama,
watchedAt: DateTime.now().millisecondsSinceEpoch,
);
_put(mediaId, item);
//不等落盘:主键冲突走 REPLACE,顺带把 create_time 刷成本次时间,超量时最久没看的先淘汰。
//必须接住异常,否则 db 出错会变成没人管的 async error
save(modelId: mediaId, modelDataJson: jsonEncode(item.toJson()))
.catchError((e) {
debugLog('短剧续播记录写入失败', e.toString());
return false;
});
}
}
/// 全站唯一实例
final dramaResume = DramaResumeStore();
+14
View File
@@ -0,0 +1,14 @@
import 'base_history_record_store.dart';
/// 黄游浏览历史(MediaStyle.Game
class GameHistoryStore extends BaseHistoryRecordStore {
static GameHistoryStore? _instance;
factory GameHistoryStore() => _instance ??= GameHistoryStore._();
GameHistoryStore._();
@override
String get dbName => 'game_history.db';
@override
String get tableName => 'game_history';
}
+14
View File
@@ -0,0 +1,14 @@
import 'base_history_record_store.dart';
/// 小说浏览历史(MediaStyle.Novel
class NovelHistoryStore extends BaseHistoryRecordStore {
static NovelHistoryStore? _instance;
factory NovelHistoryStore() => _instance ??= NovelHistoryStore._();
NovelHistoryStore._();
@override
String get dbName => 'novel_history.db';
@override
String get tableName => 'novel_history';
}
+14
View File
@@ -0,0 +1,14 @@
import 'base_history_record_store.dart';
/// 图集浏览历史(MediaStyle.Pic
class PicHistoryStore extends BaseHistoryRecordStore {
static PicHistoryStore? _instance;
factory PicHistoryStore() => _instance ??= PicHistoryStore._();
PicHistoryStore._();
@override
String get dbName => 'pic_history.db';
@override
String get tableName => 'pic_history';
}
+81
View File
@@ -0,0 +1,81 @@
import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart';
/// 搜索历史本地存储(sqflite 持久化)
/// keyword 作为主键去重:重复搜索 REPLACE 刷新时间,即「提到最前」
class SearchHistoryStore {
static SearchHistoryStore? _instance;
factory SearchHistoryStore() => _instance ??= SearchHistoryStore._();
SearchHistoryStore._();
static const String _dbName = 'search_history.db';
static const String _tableName = 'search_history';
/// 最多保留条数,超过后插入时自动删最旧
static const int maxRows = 10;
Database? _db;
Future<Database> _openDB() async {
if (_db != null) return _db!;
final dir = await getDatabasesPath();
final path = p.join(dir, _dbName);
_db = await openDatabase(
path,
version: 1,
onCreate: (db, v) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS $_tableName(
keyword TEXT PRIMARY KEY,
create_time INTEGER NOT NULL
);
''');
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_${_tableName}_time ON $_tableName(create_time DESC)',
);
},
);
return _db!;
}
/// 新增一条搜索词(主键冲突 REPLACE 实现去重并刷新时间 = 提到最前)
Future<void> save(String keyword) async {
if (keyword.isEmpty) return;
final db = await _openDB();
await db.insert(
_tableName,
{'keyword': keyword, 'create_time': DateTime.now().millisecondsSinceEpoch},
conflictAlgorithm: ConflictAlgorithm.replace,
);
await _trimOverflow(db);
}
/// 查询全部搜索词(按时间倒序)
Future<List<String>> fetchAll() async {
final db = await _openDB();
final rows = await db.query(
_tableName,
columns: ['keyword'],
orderBy: 'create_time DESC',
);
return rows.map((r) => r['keyword'] as String).toList();
}
/// 清空全部搜索历史
Future<void> clean() async {
final db = await _openDB();
await db.delete(_tableName);
}
/// 超过 [maxRows] 时删除最旧的若干条
Future<void> _trimOverflow(Database db) async {
final cntRow = await db.rawQuery('SELECT COUNT(*) AS cnt FROM $_tableName');
final cnt = Sqflite.firstIntValue(cntRow) ?? 0;
if (cnt <= maxRows) return;
await db.execute(
'DELETE FROM $_tableName WHERE rowid IN ('
'SELECT rowid FROM $_tableName ORDER BY create_time ASC LIMIT ?)',
[cnt - maxRows],
);
}
}
@@ -0,0 +1,14 @@
import 'base_history_record_store.dart';
/// 短视频浏览历史(MediaStyle.ShortVideo
class ShortVideoHistoryStore extends BaseHistoryRecordStore {
static ShortVideoHistoryStore? _instance;
factory ShortVideoHistoryStore() => _instance ??= ShortVideoHistoryStore._();
ShortVideoHistoryStore._();
@override
String get dbName => 'short_video_history.db';
@override
String get tableName => 'short_video_history';
}
+14
View File
@@ -0,0 +1,14 @@
import 'base_history_record_store.dart';
/// 视频浏览历史(MediaStyle.Video
class VideoHistoryStore extends BaseHistoryRecordStore {
static VideoHistoryStore? _instance;
factory VideoHistoryStore() => _instance ??= VideoHistoryStore._();
VideoHistoryStore._();
@override
String get dbName => 'video_history.db';
@override
String get tableName => 'video_history';
}