ops-console v1.0.2 (versionCode 8) 源码入库
This commit is contained in:
435
lib/screens/admin/channel/channel_version_manage_dialog.dart
Normal file
435
lib/screens/admin/channel/channel_version_manage_dialog.dart
Normal file
@@ -0,0 +1,435 @@
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user