279 lines
8.9 KiB
Dart
279 lines
8.9 KiB
Dart
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')}';
|
||
}
|
||
}
|