feat: add flutter mobile console and refine login ui
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
class ApiConfig {
|
||||
const ApiConfig({
|
||||
this.seedBaseUrl = '',
|
||||
});
|
||||
|
||||
final String seedBaseUrl;
|
||||
|
||||
static ApiConfig fromEnvironment() {
|
||||
const rawValue = String.fromEnvironment('LIVE_RECORDER_API_BASE_URL');
|
||||
return ApiConfig(seedBaseUrl: rawValue.trim());
|
||||
}
|
||||
|
||||
bool get hasSeedBaseUrl => seedBaseUrl.isNotEmpty;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'api_exception.dart';
|
||||
|
||||
typedef TokenProvider = String? Function();
|
||||
typedef UnauthorizedCallback = Future<void> Function();
|
||||
|
||||
class ApiClient {
|
||||
ApiClient({
|
||||
required String baseUrl,
|
||||
required TokenProvider tokenProvider,
|
||||
required UnauthorizedCallback onUnauthorized,
|
||||
http.Client? client,
|
||||
}) : _baseUri = Uri.parse(baseUrl),
|
||||
_tokenProvider = tokenProvider,
|
||||
_onUnauthorized = onUnauthorized,
|
||||
_client = client ?? http.Client();
|
||||
|
||||
final Uri _baseUri;
|
||||
final TokenProvider _tokenProvider;
|
||||
final UnauthorizedCallback _onUnauthorized;
|
||||
final http.Client _client;
|
||||
|
||||
Uri buildUri(
|
||||
String path, {
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return Uri.parse(path);
|
||||
}
|
||||
|
||||
final normalizedPath = path.startsWith('/') ? path.substring(1) : path;
|
||||
final basePath = _baseUri.path == '/' ? '' : _baseUri.path.replaceAll(RegExp(r'/+$'), '');
|
||||
final resolvedPath = basePath.isEmpty ? '/$normalizedPath' : '$basePath/$normalizedPath';
|
||||
final resolved = _baseUri.replace(path: resolvedPath);
|
||||
if (queryParameters == null || queryParameters.isEmpty) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
return resolved.replace(
|
||||
queryParameters: <String, String>{
|
||||
...resolved.queryParameters,
|
||||
...queryParameters,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> getJson(
|
||||
String path, {
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
return _sendJsonRequest(
|
||||
'GET',
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> postJson(
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
return _sendJsonRequest(
|
||||
'POST',
|
||||
path,
|
||||
body: body,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> putJson(
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
return _sendJsonRequest(
|
||||
'PUT',
|
||||
path,
|
||||
body: body,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> deleteJson(
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
return _sendJsonRequest(
|
||||
'DELETE',
|
||||
path,
|
||||
body: body,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> _sendJsonRequest(
|
||||
String method,
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? queryParameters,
|
||||
}) async {
|
||||
final uri = buildUri(path, queryParameters: queryParameters);
|
||||
final request = http.Request(method, uri);
|
||||
request.headers.addAll(_buildHeaders());
|
||||
|
||||
if (body != null) {
|
||||
request.body = jsonEncode(body);
|
||||
}
|
||||
|
||||
http.StreamedResponse streamedResponse;
|
||||
try {
|
||||
streamedResponse = await _client.send(request);
|
||||
} on Exception catch (error) {
|
||||
throw ApiException(message: '无法连接后端服务', detail: error.toString());
|
||||
}
|
||||
|
||||
final response = await http.Response.fromStream(streamedResponse);
|
||||
return _decodeJsonResponse(response);
|
||||
}
|
||||
|
||||
Future<void> postEmpty(
|
||||
String path, {
|
||||
Object? body,
|
||||
}) async {
|
||||
await postJson(path, body: body);
|
||||
}
|
||||
|
||||
Map<String, String> _buildHeaders() {
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
final token = _tokenProvider()?.trim();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
dynamic _decodeJsonResponse(http.Response response) {
|
||||
if (response.statusCode == 401) {
|
||||
_onUnauthorized();
|
||||
}
|
||||
|
||||
final bodyText = utf8.decode(response.bodyBytes);
|
||||
final jsonBody = bodyText.trim().isEmpty ? null : jsonDecode(bodyText);
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
return jsonBody;
|
||||
}
|
||||
|
||||
throw ApiException(
|
||||
message: _resolveErrorMessage(response.statusCode, jsonBody),
|
||||
statusCode: response.statusCode,
|
||||
detail: jsonBody is Map<String, dynamic>
|
||||
? (jsonBody['detail'] ?? jsonBody['error'])?.toString()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveErrorMessage(int statusCode, dynamic body) {
|
||||
if (body is Map<String, dynamic>) {
|
||||
final candidate = <dynamic>[
|
||||
body['message'],
|
||||
body['title'],
|
||||
body['detail'],
|
||||
body['error'],
|
||||
].firstWhere(
|
||||
(value) => value is String && value.trim().isNotEmpty,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (candidate is String) {
|
||||
return candidate;
|
||||
}
|
||||
} else if (body is String && body.trim().isNotEmpty) {
|
||||
return body;
|
||||
}
|
||||
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return '请求参数有误,请检查后重试';
|
||||
case 401:
|
||||
return '登录状态已失效,请重新登录';
|
||||
case 403:
|
||||
return '当前没有权限执行该操作';
|
||||
case 404:
|
||||
return '请求的接口不存在';
|
||||
case 409:
|
||||
return '请求发生冲突,请刷新后重试';
|
||||
case 422:
|
||||
return '提交的数据格式不正确,请检查后重试';
|
||||
case 500:
|
||||
return '后端服务发生内部错误';
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return '后端服务暂时不可用,请稍后重试';
|
||||
default:
|
||||
return '请求失败,请稍后重试';
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_client.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
class ApiException implements Exception {
|
||||
const ApiException({
|
||||
required this.message,
|
||||
this.statusCode,
|
||||
this.detail,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
final String? detail;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final buffer = StringBuffer('ApiException(message: $message');
|
||||
if (statusCode != null) {
|
||||
buffer.write(', statusCode: $statusCode');
|
||||
}
|
||||
if (detail != null && detail!.isNotEmpty) {
|
||||
buffer.write(', detail: $detail');
|
||||
}
|
||||
buffer.write(')');
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
abstract interface class BackendConfigStore {
|
||||
Future<String?> readBackendBaseUrl();
|
||||
Future<void> writeBackendBaseUrl(String baseUrl);
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
class AppConfigStorage implements BackendConfigStore {
|
||||
@override
|
||||
Future<String?> readBackendBaseUrl() async {
|
||||
final file = await _configFile();
|
||||
if (!await file.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final content = await file.readAsString();
|
||||
if (content.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final payload = jsonDecode(content);
|
||||
if (payload is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final value = payload['backendBaseUrl']?.toString().trim();
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> writeBackendBaseUrl(String baseUrl) async {
|
||||
final file = await _configFile();
|
||||
await file.create(recursive: true);
|
||||
await file.writeAsString(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'backendBaseUrl': baseUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
final file = await _configFile();
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<File> _configFile() async {
|
||||
final directory = await getApplicationSupportDirectory();
|
||||
return File('${directory.path}${Platform.pathSeparator}live_recorder_app_config.json');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class SessionStorage {
|
||||
Future<Map<String, dynamic>?> read() async {
|
||||
final file = await _sessionFile();
|
||||
if (!await file.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final content = await file.readAsString();
|
||||
if (content.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return jsonDecode(content) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<void> write(Map<String, dynamic> payload) async {
|
||||
final file = await _sessionFile();
|
||||
await file.create(recursive: true);
|
||||
await file.writeAsString(jsonEncode(payload));
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final file = await _sessionFile();
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<File> _sessionFile() async {
|
||||
final directory = await getApplicationSupportDirectory();
|
||||
return File('${directory.path}${Platform.pathSeparator}live_recorder_session.json');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'dart:async';
|
||||
|
||||
class PollingController {
|
||||
PollingController({
|
||||
required Duration interval,
|
||||
required Future<void> Function() onTick,
|
||||
}) : _interval = interval,
|
||||
_onTick = onTick;
|
||||
|
||||
final Duration _interval;
|
||||
final Future<void> Function() _onTick;
|
||||
|
||||
Timer? _timer;
|
||||
bool _active = false;
|
||||
bool _busy = false;
|
||||
|
||||
void setActive(bool active) {
|
||||
if (_active == active) {
|
||||
return;
|
||||
}
|
||||
|
||||
_active = active;
|
||||
if (_active) {
|
||||
_schedule();
|
||||
triggerNow();
|
||||
} else {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
void triggerNow() {
|
||||
if (!_active || _busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
_tick();
|
||||
}
|
||||
|
||||
Future<void> _tick() async {
|
||||
_busy = true;
|
||||
try {
|
||||
await _onTick();
|
||||
} finally {
|
||||
_busy = false;
|
||||
_schedule();
|
||||
}
|
||||
}
|
||||
|
||||
void _schedule() {
|
||||
_timer?.cancel();
|
||||
if (!_active) {
|
||||
return;
|
||||
}
|
||||
|
||||
_timer = Timer(_interval, () {
|
||||
if (_active && !_busy) {
|
||||
_tick();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppCard extends StatelessWidget {
|
||||
const AppCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(18),
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final card = Card(
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap == null) {
|
||||
return card;
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
|
||||
class AppEmptyState extends StatelessWidget {
|
||||
const AppEmptyState({
|
||||
super.key,
|
||||
this.title = '暂无数据',
|
||||
this.description = '当前没有可展示内容',
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String description;
|
||||
final String? actionLabel;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppCard(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Icon(Icons.inbox_rounded, color: Color(0xFF2563EB), size: 28),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
description,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
if (actionLabel != null && onAction != null) ...<Widget>[
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.tonal(
|
||||
onPressed: onAction,
|
||||
child: Text(actionLabel!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
|
||||
class AppErrorCard extends StatelessWidget {
|
||||
const AppErrorCard({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const Row(
|
||||
children: <Widget>[
|
||||
Icon(Icons.error_outline_rounded, color: Color(0xFFDC2626)),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'加载失败',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
label: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppSearchBar extends StatelessWidget {
|
||||
const AppSearchBar({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.hintText,
|
||||
this.onSubmitted,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String hintText;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final ValueChanged<String>? onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 44,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
onSubmitted: onSubmitted,
|
||||
onChanged: onChanged,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
prefixIcon: const Icon(Icons.search_rounded),
|
||||
suffixIcon: controller.text.isEmpty
|
||||
? null
|
||||
: IconButton(
|
||||
onPressed: () {
|
||||
controller.clear();
|
||||
onChanged?.call('');
|
||||
},
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
|
||||
class MetricCard extends StatelessWidget {
|
||||
const MetricCard({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.description,
|
||||
this.color = const Color(0xFF2563EB),
|
||||
this.trendValue,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final String description;
|
||||
final Color color;
|
||||
final double? trendValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = (trendValue ?? 0).clamp(0.05, 1.0);
|
||||
|
||||
return AppCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: Text(
|
||||
description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 6,
|
||||
value: progress,
|
||||
color: color,
|
||||
backgroundColor: color.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MobileHeader extends StatelessWidget {
|
||||
const MobileHeader({
|
||||
super.key,
|
||||
required this.eyebrow,
|
||||
required this.title,
|
||||
this.trailing,
|
||||
this.userInitials = 'L',
|
||||
this.onNotificationsPressed,
|
||||
this.onProfilePressed,
|
||||
});
|
||||
|
||||
final String eyebrow;
|
||||
final String title;
|
||||
final Widget? trailing;
|
||||
final String userInitials;
|
||||
final VoidCallback? onNotificationsPressed;
|
||||
final VoidCallback? onProfilePressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
eyebrow,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
onPressed: onNotificationsPressed,
|
||||
icon: const Icon(Icons.notifications_none_rounded),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: onProfilePressed,
|
||||
child: CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: const Color(0xFFE0ECFF),
|
||||
foregroundColor: const Color(0xFF2563EB),
|
||||
child: Text(
|
||||
userInitials,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (trailing != null) ...<Widget>[
|
||||
const SizedBox(height: 16),
|
||||
trailing!,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
|
||||
|
||||
class SkeletonCard extends StatefulWidget {
|
||||
const SkeletonCard({
|
||||
super.key,
|
||||
this.height = 120,
|
||||
});
|
||||
|
||||
final double height;
|
||||
|
||||
@override
|
||||
State<SkeletonCard> createState() => _SkeletonCardState();
|
||||
}
|
||||
|
||||
class _SkeletonCardState extends State<SkeletonCard> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
final opacity = 0.35 + (_controller.value * 0.4);
|
||||
return AppCard(
|
||||
child: Opacity(
|
||||
opacity: opacity,
|
||||
child: Container(
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE2E8F0),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
|
||||
|
||||
class StatusBadge extends StatelessWidget {
|
||||
const StatusBadge({
|
||||
super.key,
|
||||
required this.status,
|
||||
this.label,
|
||||
this.context,
|
||||
});
|
||||
|
||||
final Object? status;
|
||||
final String? label;
|
||||
final String? context;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tone = toneForStatus(
|
||||
value: status is int ? status as int : null,
|
||||
keyword: status?.toString(),
|
||||
context: this.context,
|
||||
);
|
||||
final style = _styleFor(tone);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: style.background,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: style.border),
|
||||
),
|
||||
child: Text(
|
||||
label ?? status?.toString() ?? '--',
|
||||
style: TextStyle(
|
||||
color: style.foreground,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_BadgeStyle _styleFor(StatusTone tone) {
|
||||
switch (tone) {
|
||||
case StatusTone.green:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFECFDF5),
|
||||
foreground: Color(0xFF047857),
|
||||
border: Color(0xFFA7F3D0),
|
||||
);
|
||||
case StatusTone.blue:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFEFF6FF),
|
||||
foreground: Color(0xFF1D4ED8),
|
||||
border: Color(0xFFBFDBFE),
|
||||
);
|
||||
case StatusTone.yellow:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFFFFBEB),
|
||||
foreground: Color(0xFFB45309),
|
||||
border: Color(0xFFFDE68A),
|
||||
);
|
||||
case StatusTone.red:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFFEF2F2),
|
||||
foreground: Color(0xFFB91C1C),
|
||||
border: Color(0xFFFECACA),
|
||||
);
|
||||
case StatusTone.orange:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFFFF7ED),
|
||||
foreground: Color(0xFFC2410C),
|
||||
border: Color(0xFFFED7AA),
|
||||
);
|
||||
case StatusTone.indigo:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFEEF2FF),
|
||||
foreground: Color(0xFF4338CA),
|
||||
border: Color(0xFFC7D2FE),
|
||||
);
|
||||
case StatusTone.gray:
|
||||
return const _BadgeStyle(
|
||||
background: Color(0xFFF1F5F9),
|
||||
foreground: Color(0xFF475569),
|
||||
border: Color(0xFFE2E8F0),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _BadgeStyle {
|
||||
const _BadgeStyle({
|
||||
required this.background,
|
||||
required this.foreground,
|
||||
required this.border,
|
||||
});
|
||||
|
||||
final Color background;
|
||||
final Color foreground;
|
||||
final Color border;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user