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 showChannelVersionManageDialog( BuildContext context, { required OrgNode node, }) { return showModalBottomSheet( 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 _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 _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 = { '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 _delete() async { final id = _versionId; if (id == null) return; final confirmed = await showDialog( 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 _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 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( segments: [ ButtonSegment(value: false, label: Text(t.forceUpdateNo)), ButtonSegment(value: true, label: Text(t.forceUpdateYes)), ], selected: {value}, onSelectionChanged: (set) => onChanged(set.first), ), ], ); } }