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')}';
|
|||
|
|
}
|
|||
|
|
}
|