feat: add flutter mobile console and refine login ui
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
String normalizeBackendBaseUrl(String rawValue) {
|
||||
final trimmed = rawValue.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw const FormatException('请输入后端地址');
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
if (uri == null ||
|
||||
!uri.hasScheme ||
|
||||
(uri.scheme != 'http' && uri.scheme != 'https') ||
|
||||
uri.host.isEmpty) {
|
||||
throw const FormatException('请输入以 http:// 或 https:// 开头的完整地址');
|
||||
}
|
||||
|
||||
if (uri.query.isNotEmpty || uri.fragment.isNotEmpty) {
|
||||
throw const FormatException('后端地址不能包含查询参数或片段');
|
||||
}
|
||||
|
||||
var normalizedPath = uri.path.replaceAll(RegExp(r'/+$'), '');
|
||||
if (normalizedPath == '/') {
|
||||
normalizedPath = '';
|
||||
}
|
||||
|
||||
return uri.replace(path: normalizedPath).toString();
|
||||
}
|
||||
|
||||
String? validateBackendBaseUrl(String rawValue) {
|
||||
try {
|
||||
normalizeBackendBaseUrl(rawValue);
|
||||
return null;
|
||||
} on FormatException catch (error) {
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
final DateFormat _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||
final DateFormat _timeFormat = DateFormat('HH:mm');
|
||||
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
|
||||
|
||||
String formatDateTime(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
final dateTime = DateTime.tryParse(value)?.toLocal();
|
||||
if (dateTime == null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return _dateTimeFormat.format(dateTime);
|
||||
}
|
||||
|
||||
String formatTime(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
final dateTime = DateTime.tryParse(value)?.toLocal();
|
||||
if (dateTime == null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return _timeFormat.format(dateTime);
|
||||
}
|
||||
|
||||
String formatDateOnly(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
final dateTime = DateTime.tryParse(value)?.toLocal();
|
||||
if (dateTime == null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return _dateFormat.format(dateTime);
|
||||
}
|
||||
|
||||
String formatDurationSeconds(num? seconds) {
|
||||
if (seconds == null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
final totalSeconds = seconds.round();
|
||||
final hours = totalSeconds ~/ 3600;
|
||||
final minutes = (totalSeconds % 3600) ~/ 60;
|
||||
final remainingSeconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return '${minutes}m ${remainingSeconds}s';
|
||||
}
|
||||
return '${remainingSeconds}s';
|
||||
}
|
||||
|
||||
String formatBytes(num? bytes) {
|
||||
if (bytes == null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const units = <String>['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
var value = bytes.toDouble();
|
||||
var index = 0;
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
final fractionDigits = index == 0 ? 0 : index == 1 ? 1 : 2;
|
||||
return '${value.toStringAsFixed(fractionDigits)} ${units[index]}';
|
||||
}
|
||||
|
||||
String valueOrDash(Object? value) {
|
||||
if (value == null) {
|
||||
return '--';
|
||||
}
|
||||
final text = value.toString().trim();
|
||||
return text.isEmpty ? '--' : text;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
Uri? resolveLiveRoomWatchUri(LiveRoom room) {
|
||||
for (final String candidate in <String>[
|
||||
room.normalizedUrl,
|
||||
room.sourceUrl,
|
||||
room.originalLiveRoomUrl,
|
||||
]) {
|
||||
final uri = _parseHttpUri(candidate);
|
||||
if (uri != null) {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool hasLiveRoomWatchSource(LiveRoom room) {
|
||||
return room.normalizedUrl.trim().isNotEmpty ||
|
||||
room.sourceUrl.trim().isNotEmpty ||
|
||||
room.originalLiveRoomUrl.trim().isNotEmpty;
|
||||
}
|
||||
|
||||
int compareMonitorRooms(LiveRoom a, LiveRoom b) {
|
||||
final liveA = a.availabilityStatus == 2 ? 1 : 0;
|
||||
final liveB = b.availabilityStatus == 2 ? 1 : 0;
|
||||
if (liveA != liveB) {
|
||||
return liveB.compareTo(liveA);
|
||||
}
|
||||
|
||||
final recordingA = a.currentRecordingState == 2 ? 1 : 0;
|
||||
final recordingB = b.currentRecordingState == 2 ? 1 : 0;
|
||||
if (recordingA != recordingB) {
|
||||
return recordingB.compareTo(recordingA);
|
||||
}
|
||||
|
||||
final priorityA = (a.isPinned || a.isPriority) ? 1 : 0;
|
||||
final priorityB = (b.isPinned || b.isPriority) ? 1 : 0;
|
||||
if (priorityA != priorityB) {
|
||||
return priorityB.compareTo(priorityA);
|
||||
}
|
||||
|
||||
return b.updatedAt.compareTo(a.updatedAt);
|
||||
}
|
||||
|
||||
Uri? _parseHttpUri(String? rawValue) {
|
||||
final trimmed = rawValue?.trim() ?? '';
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (uri.scheme != 'http' && uri.scheme != 'https') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return uri;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
String? deriveRelativeMediaPath({
|
||||
required String? outputRoot,
|
||||
required String? outputFilePath,
|
||||
}) {
|
||||
final rawPath = outputFilePath?.trim();
|
||||
if (rawPath == null || rawPath.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final normalizedPath = rawPath.replaceAll('\\', '/');
|
||||
if (_containsUnsafeTraversal(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final root = outputRoot?.trim();
|
||||
if (root == null || root.isEmpty) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
final normalizedRoot = root.replaceAll('\\', '/').replaceAll(RegExp(r'/+$'), '');
|
||||
final normalizedPathLower = normalizedPath.toLowerCase();
|
||||
final normalizedRootLower = normalizedRoot.toLowerCase();
|
||||
|
||||
if (normalizedPathLower == normalizedRootLower) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (normalizedPathLower.startsWith('$normalizedRootLower/')) {
|
||||
final relative = normalizedPath.substring(normalizedRoot.length + 1);
|
||||
return _containsUnsafeTraversal(relative) ? null : relative;
|
||||
}
|
||||
|
||||
if (!normalizedPath.contains(':') && !normalizedPath.startsWith('/')) {
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _containsUnsafeTraversal(String value) {
|
||||
return value.split('/').any((segment) => segment == '..');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:live_recorder_mobile/core/utils/formatters.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
String formatStorageHealthLabel(StorageGuardStatus? storage) {
|
||||
if (storage == null) {
|
||||
return '暂无集群数据';
|
||||
}
|
||||
|
||||
if (!storage.isEnabled) {
|
||||
return '存储守护未启用';
|
||||
}
|
||||
|
||||
final mappedMessage = _mapStorageMessage(storage.message);
|
||||
if (mappedMessage != null) {
|
||||
return mappedMessage;
|
||||
}
|
||||
|
||||
return storage.hasEnoughSpace ? '空间充足' : '空间不足';
|
||||
}
|
||||
|
||||
String formatStorageUsageLabel(StorageGuardStatus? storage) {
|
||||
if (storage == null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
final hasAvailableBytes = storage.availableBytes > 0;
|
||||
final hasRequiredBytes = storage.requiredBytes > 0;
|
||||
if (hasAvailableBytes || hasRequiredBytes) {
|
||||
final availableLabel = hasAvailableBytes ? formatBytes(storage.availableBytes) : '--';
|
||||
final requiredLabel = hasRequiredBytes ? formatBytes(storage.requiredBytes) : '--';
|
||||
return '可用 $availableLabel / 需保留 $requiredLabel';
|
||||
}
|
||||
|
||||
return _mapStorageMessage(storage.message) ?? '--';
|
||||
}
|
||||
|
||||
String? _mapStorageMessage(String rawMessage) {
|
||||
final normalized = rawMessage.trim().toLowerCase();
|
||||
if (normalized.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (normalized == 'storage is available' || normalized.contains('enough space')) {
|
||||
return '空间充足';
|
||||
}
|
||||
|
||||
if (normalized.contains('insufficient') ||
|
||||
normalized.contains('not enough') ||
|
||||
normalized.contains('low disk') ||
|
||||
normalized.contains('space is low')) {
|
||||
return '空间不足';
|
||||
}
|
||||
|
||||
if (normalized.contains('disabled')) {
|
||||
return '存储守护未启用';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
enum StatusTone {
|
||||
gray,
|
||||
green,
|
||||
blue,
|
||||
yellow,
|
||||
red,
|
||||
orange,
|
||||
indigo,
|
||||
}
|
||||
|
||||
const Map<int, String> availabilityLabelMap = <int, String>{
|
||||
0: '未知',
|
||||
1: '已下播',
|
||||
2: '直播中',
|
||||
};
|
||||
|
||||
const Map<int, String> recordingStateLabelMap = <int, String>{
|
||||
0: '已下播',
|
||||
1: '直播中',
|
||||
2: '录制中',
|
||||
};
|
||||
|
||||
const Map<int, String> taskStatusLabelMap = <int, String>{
|
||||
0: '待处理',
|
||||
1: '启动中',
|
||||
2: '录制中',
|
||||
3: '停止中',
|
||||
4: '已完成',
|
||||
5: '失败',
|
||||
6: '已停止',
|
||||
7: '处理中',
|
||||
};
|
||||
|
||||
const Map<int, String> logLevelLabelMap = <int, String>{
|
||||
0: '跟踪',
|
||||
1: '信息',
|
||||
2: '警告',
|
||||
3: '错误',
|
||||
};
|
||||
|
||||
const Map<int, String> outputFormatLabelMap = <int, String>{
|
||||
0: 'MP4',
|
||||
1: 'TS',
|
||||
};
|
||||
|
||||
const Map<int, String> saveModeLabelMap = <int, String>{
|
||||
0: '单文件',
|
||||
1: '分段',
|
||||
};
|
||||
|
||||
const Map<int, String> recordingTemplateLabelMap = <int, String>{
|
||||
0: '直接封装',
|
||||
1: '均衡 MP4',
|
||||
2: '归档 TS',
|
||||
};
|
||||
|
||||
const Map<String, String> qualityLabelMap = <String, String>{
|
||||
'origin': '原画',
|
||||
'FULL_HD': '超清',
|
||||
'HD': '高清',
|
||||
'SD': '标清',
|
||||
};
|
||||
|
||||
const Map<int, String> platformLabelMap = <int, String>{
|
||||
0: '未知',
|
||||
1: 'Douyin',
|
||||
2: 'Bilibili',
|
||||
3: 'Huya',
|
||||
4: 'Douyu',
|
||||
5: 'Kuaishou',
|
||||
6: 'TikTok',
|
||||
7: 'Xiaohongshu',
|
||||
8: 'YouTube',
|
||||
9: 'Twitch',
|
||||
10: 'PandaTV',
|
||||
11: 'Migu',
|
||||
};
|
||||
|
||||
const Map<int, String> uploadStatusLabelMap = <int, String>{
|
||||
0: '未上传',
|
||||
1: '已上传',
|
||||
2: '上传失败',
|
||||
};
|
||||
|
||||
const Map<String, String> autoStartDecisionLabelMap = <String, String>{
|
||||
'started': '已启动',
|
||||
'skipped_disabled': '已禁用',
|
||||
'skipped_storage': '存储不足',
|
||||
'skipped_active_session': '已有活动会话',
|
||||
'skipped_offline': '房间未开播',
|
||||
'skipped_debounce': '触发防抖中',
|
||||
'failed_startup': '启动失败',
|
||||
'poll_failed_transient': '轮询临时失败',
|
||||
'poll_failed': '轮询失败',
|
||||
};
|
||||
|
||||
String availabilityLabel(int? value) => availabilityLabelMap[value] ?? '未知';
|
||||
|
||||
String recordingStateLabel(int? value) => recordingStateLabelMap[value] ?? '未知';
|
||||
|
||||
String taskStatusLabel(int? value) => taskStatusLabelMap[value] ?? '未知';
|
||||
|
||||
String logLevelLabel(int? value) => logLevelLabelMap[value] ?? '未知';
|
||||
|
||||
String outputFormatLabel(int? value) => outputFormatLabelMap[value] ?? '--';
|
||||
|
||||
String saveModeLabel(int? value) => saveModeLabelMap[value] ?? '--';
|
||||
|
||||
String recordingTemplateLabel(int? value) => recordingTemplateLabelMap[value] ?? '--';
|
||||
|
||||
String qualityLabel(String? value) => qualityLabelMap[value] ?? (value == null || value.isEmpty ? '--' : value);
|
||||
|
||||
String platformLabel(int? value) => platformLabelMap[value] ?? '未知';
|
||||
|
||||
String uploadStatusLabel(int? value) => uploadStatusLabelMap[value] ?? '未知';
|
||||
|
||||
String autoStartDecisionLabel(String? value) =>
|
||||
autoStartDecisionLabelMap[value] ?? (value == null || value.isEmpty ? '暂无事件' : value);
|
||||
|
||||
bool isTaskActive(int? value) => value == 1 || value == 2 || value == 3 || value == 7;
|
||||
|
||||
bool isTaskFailed(int? value) => value == 5;
|
||||
|
||||
StatusTone toneForStatus({String? keyword, int? value, String? context}) {
|
||||
if (context == 'availability') {
|
||||
return value == 2 ? StatusTone.green : StatusTone.gray;
|
||||
}
|
||||
|
||||
if (context == 'recording') {
|
||||
if (value == 2) {
|
||||
return StatusTone.blue;
|
||||
}
|
||||
if (value == 1) {
|
||||
return StatusTone.green;
|
||||
}
|
||||
return StatusTone.gray;
|
||||
}
|
||||
|
||||
if (context == 'task' || context == 'session') {
|
||||
switch (value) {
|
||||
case 0:
|
||||
return StatusTone.yellow;
|
||||
case 1:
|
||||
case 7:
|
||||
return StatusTone.indigo;
|
||||
case 2:
|
||||
return StatusTone.blue;
|
||||
case 3:
|
||||
return StatusTone.orange;
|
||||
case 4:
|
||||
return StatusTone.green;
|
||||
case 5:
|
||||
return StatusTone.red;
|
||||
default:
|
||||
return StatusTone.gray;
|
||||
}
|
||||
}
|
||||
|
||||
if (context == 'upload') {
|
||||
if (value == 1) {
|
||||
return StatusTone.green;
|
||||
}
|
||||
if (value == 2) {
|
||||
return StatusTone.red;
|
||||
}
|
||||
}
|
||||
|
||||
final normalized = keyword?.toLowerCase() ?? '';
|
||||
if (<String>['live', 'online', 'living', '直播中', 'completed', 'archived'].any(normalized.contains)) {
|
||||
return StatusTone.green;
|
||||
}
|
||||
if (<String>['recording', '录制中'].any(normalized.contains)) {
|
||||
return StatusTone.blue;
|
||||
}
|
||||
if (<String>['pending', 'queued', '待'].any(normalized.contains)) {
|
||||
return StatusTone.yellow;
|
||||
}
|
||||
if (<String>['retry', 'stopping', '停止中'].any(normalized.contains)) {
|
||||
return StatusTone.orange;
|
||||
}
|
||||
if (<String>['process', 'transcod'].any(normalized.contains)) {
|
||||
return StatusTone.indigo;
|
||||
}
|
||||
if (<String>['error', 'fail', '异常', '错误'].any(normalized.contains)) {
|
||||
return StatusTone.red;
|
||||
}
|
||||
return StatusTone.gray;
|
||||
}
|
||||
Reference in New Issue
Block a user