Files
CloudHome-Ops/lib/services/marquee_service.dart

279 lines
8.9 KiB
Dart
Raw Permalink Normal View History

import 'dart:convert';
import 'dart:math';
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/marquee_message.dart';
import '../models/store_info.dart';
import 'api_client.dart';
import 'marquee_web_source.dart';
import 'org_service.dart';
/// 滚动字幕管理服务。
///
/// 列表/新增/编辑/删除均优先对接 Web 后台 `/theme/scrollingText/*` 接口,
/// 同时保留本地 `SharedPreferences` 缓存作为离线兜底。
class MarqueeService {
MarqueeService({
Dio? dio,
OrgService? orgService,
MarqueeWebSource? webSource,
}) : _dio = dio ?? ApiClient.instance.dio,
_orgService = orgService ?? OrgService(),
_webSource = webSource ?? MarqueeWebSource(dio: ApiClient.instance.dio);
final Dio _dio;
final OrgService _orgService;
final MarqueeWebSource _webSource;
static const _storageKey = 'cloudhome_ops_marquee_messages';
Future<SharedPreferences> get _prefs => SharedPreferences.getInstance();
/// 获取滚动字幕列表。
///
/// 优先从 Web 端 `/theme/scrollingText/list` 获取;若失败则回退本地缓存。
Future<List<MarqueeMessage>> getMarqueeList({
String? storeId,
String? keyword,
String method = 'GET',
String path = MarqueeWebSource.defaultPath,
Map<String, dynamic>? queryParameters,
Map<String, dynamic>? body,
}) async {
final webResult = await _webSource.fetchMarquees(
method: method,
path: path,
queryParameters: queryParameters,
body: body,
storeId: storeId,
keyword: keyword,
);
if (webResult.isSuccess) {
await _saveAll(webResult.data);
return _applyFiltersAndSort(webResult.data, storeId: storeId, keyword: keyword);
}
final localList = await _loadAll();
return _applyFiltersAndSort(localList, storeId: storeId, keyword: keyword);
}
List<MarqueeMessage> _applyFiltersAndSort(
List<MarqueeMessage> list, {
String? storeId,
String? keyword,
}) {
var result = list;
if (storeId != null && storeId.isNotEmpty) {
result = result.where((m) => m.storeId == storeId).toList();
}
if (keyword != null && keyword.isNotEmpty) {
final lower = keyword.toLowerCase();
result = result.where((m) {
return m.content.toLowerCase().contains(lower) ||
m.storeName.toLowerCase().contains(lower);
}).toList();
}
result.sort((a, b) {
// 启用的排在前面,其次按更新时间倒序。
final statusCompare = b.status.compareTo(a.status);
if (statusCompare != 0) return statusCompare;
final bTime = b.updateTime;
final aTime = a.updateTime;
if (bTime == null || aTime == null) return 0;
return bTime.compareTo(aTime);
});
return result;
}
/// 新增或修改滚动字幕。
///
/// 编辑(data 含 id)调用 `/theme/scrollingText/update`,
/// 新增调用 `/theme/scrollingText/save`。
/// 成功后刷新本地缓存。
Future<void> saveMarquee(Map<String, dynamic> data) async {
final id = data['id']?.toString();
final payload = _buildWebPayload(data);
try {
if (id != null && id.isNotEmpty) {
await _dio.post<dynamic>('/theme/scrollingText/update', data: payload);
} else {
await _dio.post<dynamic>('/theme/scrollingText/save', data: payload);
}
} on DioException {
// 写操作失败时仅更新本地缓存,保证移动端仍可展示。
}
final list = await _loadAll();
final now = DateTime.now();
final message = MarqueeMessage(
id: id ?? _generateId(),
storeId: data['storeId']?.toString() ?? '',
storeName: data['storeName']?.toString() ?? '',
content: data['content']?.toString() ?? '',
status: data['status'] is int ? data['status'] as int : 0,
speed: int.tryParse(data['speed']?.toString() ?? '50') ?? 50,
fontColor: data['fontColor']?.toString(),
backgroundColor: data['backgroundColor']?.toString(),
fontSize: int.tryParse(data['fontSize']?.toString() ?? ''),
level: int.tryParse(data['level']?.toString() ?? ''),
startTime: _tryParseDateTime(data['startTime']),
endTime: _tryParseDateTime(data['endTime']),
createTime: now,
updateTime: now,
);
final index = list.indexWhere((m) => m.id == message.id);
if (index >= 0) {
list[index] = message;
} else {
list.add(message);
}
await _saveAll(list);
}
/// 删除滚动字幕。
///
/// 优先调用 Web 端 `/theme/scrollingText/delete`,失败也移除本地缓存。
Future<void> deleteMarquee(String id) async {
final numericId = int.tryParse(id);
if (numericId != null) {
try {
await _dio.post<dynamic>(
'/theme/scrollingText/delete',
data: {'ids': [numericId]},
);
} on DioException {
// 网络失败时继续移除本地记录。
}
}
final list = await _loadAll();
list.removeWhere((m) => m.id == id);
await _saveAll(list);
}
/// 启用/禁用单条滚动字幕。
///
/// 直接调用 Web 端 `/theme/scrollingText/update` 修改 status。
Future<void> setMarqueeStatus(String id, {required int status}) async {
final list = await _loadAll();
final index = list.indexWhere((m) => m.id == id);
if (index < 0) return;
final item = list[index];
final numericId = int.tryParse(id);
if (numericId != null) {
try {
await _dio.post<dynamic>(
'/theme/scrollingText/update',
data: item.copyWith(status: status).toWebJson(),
);
} on DioException {
// 接口失败时仅更新本地状态。
}
}
list[index] = item.copyWith(
status: status,
updateTime: DateTime.now(),
);
await _saveAll(list);
}
/// 获取当前用户可见的门店列表(用于新增/编辑时选择目标门店)。
Future<List<StoreInfo>> getStores() async {
return _orgService.getMyStores();
}
/// 构造 Web 后台可接收的请求体。
Map<String, dynamic> _buildWebPayload(Map<String, dynamic> data) {
final storeId = data['storeId']?.toString() ?? '';
final storeName = data['storeName']?.toString() ?? '';
final content = data['content']?.toString() ?? '';
final speed = int.tryParse(data['speed']?.toString() ?? '50') ?? 50;
final fontSize = int.tryParse(data['fontSize']?.toString() ?? '');
final level = int.tryParse(data['level']?.toString() ?? '');
final status = data['status'] is int ? data['status'] as int : 0;
final fontColor = data['fontColor']?.toString();
final backgroundColor = data['backgroundColor']?.toString();
final startTime = _tryParseDateTime(data['startTime']);
final endTime = _tryParseDateTime(data['endTime']);
final payload = <String, dynamic>{
'orgId': int.tryParse(storeId) ?? storeId,
'orgName': storeName,
'content': content,
'scrollSpeed': speed,
'status': status,
if (fontSize != null) 'fontSize': fontSize,
if (level != null) 'level': level,
if (fontColor != null && fontColor.isNotEmpty) 'fontColor': fontColor,
if (backgroundColor != null && backgroundColor.isNotEmpty)
'bgColor': backgroundColor,
if (startTime != null)
'startTime': _formatTime(startTime),
if (endTime != null)
'endTime': _formatTime(endTime),
};
final id = data['id']?.toString();
if (id != null && id.isNotEmpty) {
payload['id'] = int.tryParse(id) ?? id;
}
return payload;
}
Future<List<MarqueeMessage>> _loadAll() async {
final prefs = await _prefs;
final raw = prefs.getString(_storageKey);
if (raw == null || raw.isEmpty) return <MarqueeMessage>[];
try {
final list = jsonDecode(raw) as List<dynamic>;
return list
.whereType<Map<String, dynamic>>()
.map(MarqueeMessage.fromJson)
.toList();
} catch (_) {
return <MarqueeMessage>[];
}
}
Future<void> _saveAll(List<MarqueeMessage> list) async {
final prefs = await _prefs;
final raw = jsonEncode(list.map((m) => m.toJson()).toList());
await prefs.setString(_storageKey, raw);
}
String _generateId() {
final now = DateTime.now().millisecondsSinceEpoch;
final rand = Random().nextInt(10000).toString().padLeft(4, '0');
return '${now}_$rand';
}
DateTime? _tryParseDateTime(dynamic value) {
if (value == null) return null;
if (value is DateTime) return value;
final text = value.toString().trim();
if (text.isEmpty) return null;
// 兼容 Web 端纯时间字符串 `HH:mm:ss`。
if (RegExp(r'^\d{2}:\d{2}:\d{2}$').hasMatch(text)) {
return DateTime.tryParse('2000-01-01 $text');
}
return DateTime.tryParse(text) ??
DateTime.tryParse(text.replaceFirst(' ', 'T'));
}
String _formatTime(DateTime value) {
return '${value.hour.toString().padLeft(2, '0')}:'
'${value.minute.toString().padLeft(2, '0')}:'
'${value.second.toString().padLeft(2, '0')}';
}
}