Files
CloudHome-Ops/lib/screens/admin/channel/channel_version_manage_dialog.dart

436 lines
15 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import '../../../models/org_node.dart';
import '../../../services/api_client.dart';
import '../../../services/app_service.dart';
import '../../../services/upload_service.dart';
import '../../../utils/i10n.dart';
/// 渠道 / 代理节点的 App 版本管理对话框。
///
/// 对齐 Web 后台 `app-version-manage-dialog.vue`:每个组织(scope=ORG)
/// 至多维护一条版本配置,进入时按 [OrgNode.id] 拉取当前版本——
/// 有则进入「编辑」流程(含删除按钮),无则进入「新增」流程。
///
/// 字段与 Web 完全一致:版本名 / 版本号 / 下载地址 / 强制更新 / 弹窗提示;
/// 下载地址支持手动录入,或点击「上传文件」按钮直传 APK(对齐 Web 后台的 el-upload,
/// 经 /theme/upload/credential 取 STS 凭证直传 COS),上传成功后自动回填 URL。
///
/// 用法:
/// ```dart
/// showChannelVersionManageDialog(context, node: node);
/// ```
Future<void> showChannelVersionManageDialog(
BuildContext context, {
required OrgNode node,
}) {
return showModalBottomSheet<void>(
context: context,
useSafeArea: true,
isScrollControlled: true,
builder: (_) => _ChannelVersionManageSheet(node: node),
);
}
class _ChannelVersionManageSheet extends StatefulWidget {
const _ChannelVersionManageSheet({required this.node});
final OrgNode node;
@override
State<_ChannelVersionManageSheet> createState() =>
_ChannelVersionManageSheetState();
}
class _ChannelVersionManageSheetState
extends State<_ChannelVersionManageSheet> {
final _nameCtrl = TextEditingController();
final _codeCtrl = TextEditingController();
final _urlCtrl = TextEditingController();
/// 当前版本 ID(非空表示编辑态)。
String? _versionId;
bool _forceUpdate = false;
bool _showPopup = true;
bool _loading = true;
bool _submitting = false;
/// APK 上传中标记,上传期间禁用 URL 手动输入并禁用上传按钮。
bool _uploading = false;
/// APK 上传进度(0~100),仅在 [_uploading] 为 true 时展示。
int _uploadProgress = 0;
AppLocalizations get t => AppLocalizations.of(context);
@override
void initState() {
super.initState();
_loadCurrent();
}
@override
void dispose() {
_nameCtrl.dispose();
_codeCtrl.dispose();
_urlCtrl.dispose();
super.dispose();
}
/// 进入时拉取该组织的当前版本配置,有则回填表单(编辑态),无则空表单(新增态)。
Future<void> _loadCurrent() async {
try {
final data = await AppService().searchAppVersion(orgId: widget.node.id);
if (!mounted) return;
if (data != null) {
_versionId = data['id']?.toString();
_nameCtrl.text = data['versionName']?.toString() ?? '';
final rawCode = data['versionCode'] ?? data['version_code'];
_codeCtrl.text = rawCode?.toString() ?? '';
_urlCtrl.text = (data['downloadUrl'] ?? data['download_url'] ?? data['apkUrl'])
?.toString() ??
'';
_forceUpdate = _toBool(data['forceUpdate'] ?? data['force_update']);
_showPopup = _toBool(data['showPopup'] ?? data['show_popup'], fallback: true);
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_errText(e))),
);
} finally {
if (mounted) setState(() => _loading = false);
}
}
bool _toBool(dynamic v, {bool fallback = false}) {
if (v == null) return fallback;
final n = int.tryParse(v.toString()) ?? 0;
return n == 1;
}
String _errText(Object? e) =>
e is ApiException ? e.message : (t.loadFailed);
String get _title {
final name = widget.node.name.trim();
final action = _versionId != null ? t.editVersion : t.addVersion;
return name.isNotEmpty ? '$action - $name' : action;
}
Future<void> _submit() async {
final name = _nameCtrl.text.trim();
final codeText = _codeCtrl.text.trim();
final url = _urlCtrl.text.trim();
if (name.isEmpty) {
_snack(t.versionNameRequired);
return;
}
final code = int.tryParse(codeText);
if (code == null || code <= 0) {
_snack(t.versionCodeRequired);
return;
}
if (url.isEmpty) {
_snack(t.versionUrlRequired);
return;
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
_snack(t.versionUrlInvalid);
return;
}
setState(() => _submitting = true);
final payload = <String, dynamic>{
'scope': 'ORG',
'orgId': widget.node.id,
'versionName': name,
'versionCode': code,
'downloadUrl': url,
'forceUpdate': _forceUpdate ? 1 : 0,
'showPopup': _showPopup ? 1 : 0,
if (_versionId != null) 'id': _versionId,
};
try {
if (_versionId != null) {
await AppService().updateAppVersion(payload);
} else {
await AppService().saveAppVersion(payload);
}
if (!mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.saved)),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_errText(e))),
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
Future<void> _delete() async {
final id = _versionId;
if (id == null) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(t.confirmDelete),
content: Text(t.versionDeleteConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(t.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () => Navigator.pop(ctx, true),
child: Text(t.delete),
),
],
),
);
if (confirmed != true) return;
setState(() => _submitting = true);
try {
await AppService().deleteAppVersion([int.parse(id)]);
if (!mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(t.versionDeleted)),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_errText(e))),
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
void _snack(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
/// 选择 .apk 文件并直传腾讯云 COS,成功后把返回 URL 填回下载地址输入框。
///
/// 对齐 Web 后台 `app-version-manage-dialog.vue` 的「上传文件」按钮:
/// 点击 → 系统文件选择器(仅 .apk)→ 选完即传 → 回填 downloadUrl。
Future<void> _pickAndUpload() async {
// 1. 打开文件选择器,仅允许 .apk;用户取消返回 null。
final picked = await UploadService.pickApkFile();
if (!mounted) return;
if (picked == null) return;
// 2. 校验扩展名(选择器已限制,这里兜底)。
if (!picked.name.toLowerCase().endsWith('.apk')) {
_snack(t.pleaseSelectApk);
return;
}
// 3. 进入上传态,统一在 finally 复位,避免 async 后 setState 崩溃。
setState(() => _uploading = true);
try {
final url = await UploadService().uploadApkByOriginalName(
picked,
onProgress: (p) {
if (mounted) setState(() => _uploadProgress = p);
},
);
if (!mounted) return;
// 4. 成功:回填下载地址并提示。
_urlCtrl.text = url;
_snack(t.uploadSuccess);
} catch (e) {
if (!mounted) return;
_snack('${t.uploadFailed}:${_errText(e)}');
} finally {
if (mounted) {
setState(() {
_uploading = false;
_uploadProgress = 0;
});
}
}
}
@override
Widget build(BuildContext context) {
final isEdit = _versionId != null;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
_title,
style: Theme.of(context).textTheme.titleMedium,
),
),
const Divider(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: _loading
? const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: CircularProgressIndicator(),
),
)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _nameCtrl,
decoration: InputDecoration(
labelText: t.versionName,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _codeCtrl,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.versionCode,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _urlCtrl,
keyboardType: TextInputType.url,
enabled: !_uploading,
decoration: InputDecoration(
labelText: t.downloadUrlHint,
border: const OutlineInputBorder(),
suffixIcon: TextButton.icon(
onPressed: _uploading ? null : _pickAndUpload,
icon: const Icon(Icons.upload),
label: _uploading
? Text('${t.uploading} $_uploadProgress%')
: Text(t.uploadFile),
),
),
),
const SizedBox(height: 16),
_SwitchRow(
label: t.forceUpdate,
value: _forceUpdate,
onChanged: (v) => setState(() => _forceUpdate = v),
),
const SizedBox(height: 8),
_SwitchRow(
label: t.showPopup,
value: _showPopup,
onChanged: (v) => setState(() => _showPopup = v),
),
const SizedBox(height: 20),
Row(
children: [
if (isEdit)
Expanded(
child: OutlinedButton.icon(
onPressed: _submitting ? null : _delete,
icon: const Icon(Icons.delete),
style: OutlinedButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.error,
),
label: Text(t.delete),
),
),
if (isEdit) const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: _submitting
? null
: () => Navigator.pop(context),
child: Text(t.cancel),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Text(isEdit ? t.save : t.add),
),
),
],
),
const SizedBox(height: 16),
],
),
),
],
),
),
);
}
}
/// 简易「是/否」开关行(对齐 Web 的 radio 否/是)。
class _SwitchRow extends StatelessWidget {
const _SwitchRow({
required this.label,
required this.value,
required this.onChanged,
});
final String label;
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context);
return Row(
children: [
SizedBox(
width: 84,
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
SegmentedButton<bool>(
segments: [
ButtonSegment(value: false, label: Text(t.forceUpdateNo)),
ButtonSegment(value: true, label: Text(t.forceUpdateYes)),
],
selected: {value},
onSelectionChanged: (set) => onChanged(set.first),
),
],
);
}
}