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),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|