436 lines
15 KiB
Dart
436 lines
15 KiB
Dart
|
|
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),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|