feat: add flutter mobile console and refine login ui

This commit is contained in:
2026-05-15 00:24:58 +08:00
parent d69c7d015e
commit 50b207415a
87 changed files with 9822 additions and 157 deletions
@@ -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;
}
}
+89
View File
@@ -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;
}
+43
View File
@@ -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;
}
+188
View File
@@ -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;
}