47b3c89460
- web/dist/changelog/ 版本历史页,收录 v1.0.0–v1.0.29 全部发版记录 - web/dist/terms/ 服务条款页(付费/授权/数据/免责等) - web/dist/privacy/ 隐私政策页(数据收集/使用/共享/用户权利等) - 全站页脚补充 更新日志、服务条款、隐私政策三个链接 - about_screen 「更新日志」按钮链接由 /downloads/ 改为 /changelog/ - 新增 backend/cmd/issue 工具,用于签发指定门店的授权码 token Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
523 lines
18 KiB
Dart
523 lines
18 KiB
Dart
import 'dart:io';
|
||
import 'package:file_picker/file_picker.dart';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
import '../../core/responsive/responsive.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:package_info_plus/package_info_plus.dart';
|
||
import 'package:url_launcher/url_launcher.dart';
|
||
import '../../core/config/app_config.dart';
|
||
import '../../core/config/app_info.dart';
|
||
import '../../core/theme/app_theme.dart';
|
||
import '../../core/update/app_updater.dart';
|
||
import '../../core/utils/dialog_util.dart';
|
||
import '../../providers/feedback_provider.dart';
|
||
import '../../providers/update_provider.dart';
|
||
|
||
/// 「关于我们」独立页面(左侧菜单项)。
|
||
class AboutScreen extends ConsumerStatefulWidget {
|
||
const AboutScreen({super.key});
|
||
|
||
@override
|
||
ConsumerState<AboutScreen> createState() => _AboutScreenState();
|
||
}
|
||
|
||
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final appVersion = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
|
||
final updateInfo = ref.watch(updateProvider).valueOrNull;
|
||
|
||
return SingleChildScrollView(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// ── 版本信息 ──
|
||
_AboutSection(
|
||
title: '版本信息',
|
||
children: [
|
||
_AboutRow(label: '当前版本', value: appVersion),
|
||
if (updateInfo != null && updateInfo.hasUpdate)
|
||
_AboutRow(
|
||
label: '最新版本',
|
||
value: 'v${updateInfo.latestVersion}',
|
||
valueColor: AppTheme.success,
|
||
trailing: TextButton(
|
||
onPressed: () => startInAppUpdate(context, updateInfo),
|
||
child: const Text('立即更新'),
|
||
),
|
||
)
|
||
else
|
||
_AboutRow(
|
||
label: '最新版本',
|
||
value: updateInfo != null ? '已是最新' : '检查中…',
|
||
valueColor: AppTheme.textSecondary,
|
||
trailing: TextButton(
|
||
onPressed: () async {
|
||
final messenger = ScaffoldMessenger.of(context);
|
||
messenger.showSnackBar(
|
||
const SnackBar(content: Text('正在检查更新…')));
|
||
await ref.read(updateProvider.notifier).forceCheck();
|
||
if (!context.mounted) return;
|
||
final r = ref.read(updateProvider).valueOrNull;
|
||
messenger.hideCurrentSnackBar();
|
||
final String msg;
|
||
if (r == null) {
|
||
msg = '检查更新失败,请检查网络';
|
||
} else if (r.hasUpdate) {
|
||
msg = '发现新版本 v${r.latestVersion}';
|
||
} else {
|
||
msg = '已是最新版本(v${r.latestVersion})';
|
||
}
|
||
messenger.showSnackBar(SnackBar(content: Text(msg)));
|
||
},
|
||
child: const Text('检查更新'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// ── 帮助与文档 ──
|
||
_AboutSection(
|
||
title: '帮助与文档',
|
||
children: [
|
||
const _AboutRow(label: '使用手册', value: '查看产品使用文档与操作指南'),
|
||
const SizedBox(height: 8),
|
||
Wrap(
|
||
spacing: 8,
|
||
children: [
|
||
OutlinedButton.icon(
|
||
onPressed: () async {
|
||
final url = Uri.parse(
|
||
AppInfo.docsUrl.isNotEmpty
|
||
? AppInfo.docsUrl
|
||
: '${AppInfo.website}/docs/');
|
||
if (await canLaunchUrl(url)) launchUrl(url);
|
||
},
|
||
icon: const Icon(Icons.menu_book_outlined, size: 16),
|
||
label: const Text('打开文档'),
|
||
),
|
||
OutlinedButton.icon(
|
||
onPressed: () async {
|
||
final url = Uri.parse('${AppInfo.website}/changelog/');
|
||
if (await canLaunchUrl(url)) launchUrl(url);
|
||
},
|
||
icon: const Icon(Icons.history_outlined, size: 16),
|
||
label: const Text('更新日志'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// ── 关于我们 ──
|
||
_AboutSection(
|
||
title: '关于我们',
|
||
children: [
|
||
_AboutRow(label: '服务商', value: AppInfo.provider),
|
||
_AboutRow(
|
||
label: '官方网站',
|
||
value: AppInfo.website,
|
||
trailing: AppInfo.website.isNotEmpty
|
||
? TextButton(
|
||
onPressed: () async {
|
||
final uri = Uri.parse(AppInfo.website);
|
||
if (await canLaunchUrl(uri)) launchUrl(uri);
|
||
},
|
||
child: const Text('访问', style: TextStyle(fontSize: 12)),
|
||
)
|
||
: null,
|
||
),
|
||
_AboutRow(label: '联系邮箱', value: AppInfo.email),
|
||
if (AppInfo.phone.isNotEmpty)
|
||
_AboutRow(label: '联系电话', value: AppInfo.phone),
|
||
if (AppInfo.wechat.isNotEmpty)
|
||
_AboutRow(label: '微信', value: AppInfo.wechat),
|
||
const _AboutRow(label: '服务时间', value: '周一至周五 9:00–18:00'),
|
||
const SizedBox(height: 8),
|
||
Wrap(
|
||
spacing: 8,
|
||
children: [
|
||
OutlinedButton.icon(
|
||
onPressed: () async {
|
||
final uri = Uri.parse(
|
||
'mailto:${AppInfo.email}?subject=酒库管理系统咨询');
|
||
if (await canLaunchUrl(uri)) launchUrl(uri);
|
||
},
|
||
icon: const Icon(Icons.email_outlined, size: 16),
|
||
label: const Text('发送邮件'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// ── 扫码防伪 ──
|
||
const _AboutSection(
|
||
title: '扫码防伪',
|
||
children: [
|
||
_AboutRow(
|
||
label: '功能说明',
|
||
value: '每件商品可生成专属二维码,顾客扫码即可查看商品名称、系列、规格及批次信息,帮助验证商品真实性。',
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// ── 法律信息 ──
|
||
_AboutSection(
|
||
title: '法律信息',
|
||
children: [
|
||
if (AppInfo.termsUrl.isNotEmpty)
|
||
_AboutRow(
|
||
label: '服务条款',
|
||
value: '查看服务条款',
|
||
trailing: TextButton(
|
||
onPressed: () async {
|
||
final uri = Uri.parse(AppInfo.termsUrl);
|
||
if (await canLaunchUrl(uri)) launchUrl(uri);
|
||
},
|
||
child: const Text('查看', style: TextStyle(fontSize: 12)),
|
||
),
|
||
),
|
||
if (AppInfo.privacyUrl.isNotEmpty)
|
||
_AboutRow(
|
||
label: '隐私政策',
|
||
value: '查看隐私政策',
|
||
trailing: TextButton(
|
||
onPressed: () async {
|
||
final uri = Uri.parse(AppInfo.privacyUrl);
|
||
if (await canLaunchUrl(uri)) launchUrl(uri);
|
||
},
|
||
child: const Text('查看', style: TextStyle(fontSize: 12)),
|
||
),
|
||
),
|
||
_AboutRow(label: '版权', value: '© ${DateTime.now().year} ${AppInfo.provider}'),
|
||
],
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// ── 系统信息 ──
|
||
_AboutSection(
|
||
title: '系统信息',
|
||
children: [
|
||
_AboutRow(
|
||
label: '运行平台',
|
||
value: kIsWeb
|
||
? 'Web'
|
||
: Platform.isAndroid
|
||
? 'Android'
|
||
: Platform.isIOS
|
||
? 'iOS'
|
||
: Platform.isMacOS
|
||
? 'macOS'
|
||
: Platform.isWindows
|
||
? 'Windows'
|
||
: Platform.operatingSystem,
|
||
),
|
||
_AboutRow(label: '应用版本', value: appVersion),
|
||
FutureBuilder<PackageInfo>(
|
||
future: PackageInfo.fromPlatform(),
|
||
builder: (ctx, snap) => _AboutRow(
|
||
label: '构建号',
|
||
value: snap.data?.buildNumber ?? '-',
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// ── 意见反馈 ──
|
||
_AboutSection(
|
||
title: '意见反馈',
|
||
children: [
|
||
const _AboutRow(
|
||
label: '问题反馈',
|
||
value: '遇到 Bug 或有功能建议,欢迎告知我们',
|
||
),
|
||
const SizedBox(height: 8),
|
||
Wrap(
|
||
spacing: 8,
|
||
children: [
|
||
ElevatedButton.icon(
|
||
onPressed: () => _showFeedbackDialog(isBug: true),
|
||
icon: const Icon(Icons.bug_report_outlined, size: 16),
|
||
label: const Text('反馈 Bug'),
|
||
),
|
||
OutlinedButton.icon(
|
||
onPressed: () => _showFeedbackDialog(isBug: false),
|
||
icon: const Icon(Icons.lightbulb_outline, size: 16),
|
||
label: const Text('功能建议'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showFeedbackDialog({required bool isBug}) {
|
||
showAppDialog(
|
||
context: context,
|
||
builder: (_) => _FeedbackDialog(type: isBug ? 'bug' : 'suggestion'),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 反馈表单弹窗:文字 + 附图,直接提交后台。
|
||
class _FeedbackDialog extends ConsumerStatefulWidget {
|
||
final String type; // bug / suggestion
|
||
const _FeedbackDialog({required this.type});
|
||
|
||
@override
|
||
ConsumerState<_FeedbackDialog> createState() => _FeedbackDialogState();
|
||
}
|
||
|
||
class _FeedbackDialogState extends ConsumerState<_FeedbackDialog> {
|
||
final _ctrl = TextEditingController();
|
||
final List<String> _images = []; // 已上传的图片 URL
|
||
bool _uploading = false;
|
||
bool _submitting = false;
|
||
|
||
static const _maxImages = 9;
|
||
|
||
@override
|
||
void dispose() {
|
||
_ctrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _addImages() async {
|
||
final result = await FilePicker.platform.pickFiles(
|
||
type: FileType.image,
|
||
allowMultiple: true,
|
||
withData: true,
|
||
);
|
||
if (result == null) return;
|
||
setState(() => _uploading = true);
|
||
try {
|
||
final repo = ref.read(feedbackRepositoryProvider);
|
||
for (final f in result.files) {
|
||
if (_images.length >= _maxImages) break;
|
||
if (f.bytes == null) continue;
|
||
final url = await repo.uploadImage(bytes: f.bytes!, filename: f.name);
|
||
if (!mounted) return;
|
||
setState(() => _images.add(url));
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context)
|
||
.showSnackBar(SnackBar(content: Text('图片上传失败:$e')));
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _uploading = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _submit() async {
|
||
if (_ctrl.text.trim().isEmpty && _images.isEmpty) {
|
||
ScaffoldMessenger.of(context)
|
||
.showSnackBar(const SnackBar(content: Text('请填写反馈内容或添加图片')));
|
||
return;
|
||
}
|
||
setState(() => _submitting = true);
|
||
try {
|
||
await ref.read(feedbackRepositoryProvider).submit(
|
||
type: widget.type,
|
||
content: _ctrl.text.trim(),
|
||
images: _images,
|
||
);
|
||
if (!mounted) return;
|
||
Navigator.pop(context);
|
||
ScaffoldMessenger.of(context)
|
||
.showSnackBar(const SnackBar(content: Text('反馈已提交,感谢您的反馈!')));
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() => _submitting = false);
|
||
ScaffoldMessenger.of(context)
|
||
.showSnackBar(SnackBar(content: Text('提交失败:$e')));
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final isBug = widget.type == 'bug';
|
||
return AlertDialog(
|
||
title: Text(isBug ? '反馈 Bug' : '功能建议'),
|
||
content: SizedBox(
|
||
width: context.dialogWidth(440),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
TextField(
|
||
controller: _ctrl,
|
||
maxLines: 6,
|
||
decoration: InputDecoration(
|
||
hintText: isBug ? '请描述问题的复现步骤和预期行为…' : '请描述您希望增加的功能…',
|
||
border: const OutlineInputBorder(),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
children: [
|
||
for (int i = 0; i < _images.length; i++) _thumb(i),
|
||
if (_images.length < _maxImages) _addButton(),
|
||
],
|
||
),
|
||
if (_uploading)
|
||
const Padding(
|
||
padding: EdgeInsets.only(top: 8),
|
||
child: Row(children: [
|
||
SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(strokeWidth: 2)),
|
||
SizedBox(width: 8),
|
||
Text('图片上传中…', style: TextStyle(fontSize: 12)),
|
||
]),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: _submitting ? null : () => Navigator.pop(context),
|
||
child: const Text('取消'),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: (_submitting || _uploading) ? null : _submit,
|
||
child: Text(_submitting ? '提交中…' : '提交'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _thumb(int i) {
|
||
return Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(6),
|
||
child: Image.network(
|
||
'${AppConfig.baseUrl}${_images[i]}',
|
||
width: 64,
|
||
height: 64,
|
||
fit: BoxFit.cover,
|
||
errorBuilder: (_, __, ___) => Container(
|
||
width: 64,
|
||
height: 64,
|
||
color: Colors.grey.shade200,
|
||
child: const Icon(Icons.broken_image, size: 20),
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
right: -8,
|
||
top: -8,
|
||
child: GestureDetector(
|
||
onTap: () => setState(() => _images.removeAt(i)),
|
||
child: Container(
|
||
decoration: const BoxDecoration(
|
||
color: Colors.black54, shape: BoxShape.circle),
|
||
padding: const EdgeInsets.all(2),
|
||
child: const Icon(Icons.close, size: 14, color: Colors.white),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _addButton() {
|
||
return InkWell(
|
||
onTap: _uploading ? null : _addImages,
|
||
child: Container(
|
||
width: 64,
|
||
height: 64,
|
||
decoration: BoxDecoration(
|
||
border: Border.all(color: Colors.grey.shade400),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: const Icon(Icons.add_a_photo_outlined, color: Colors.grey),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 关于页辅助 widgets ──────────────────────────────────────
|
||
|
||
class _AboutSection extends StatelessWidget {
|
||
final String title;
|
||
final List<Widget> children;
|
||
const _AboutSection({required this.title, required this.children});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(title,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppTheme.primaryDark)),
|
||
const Divider(height: 24),
|
||
...children,
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _AboutRow extends StatelessWidget {
|
||
final String label;
|
||
final String value;
|
||
final Color? valueColor;
|
||
final Widget? trailing;
|
||
|
||
const _AboutRow({
|
||
required this.label,
|
||
required this.value,
|
||
this.valueColor,
|
||
this.trailing,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 88,
|
||
child: Text(label,
|
||
style: const TextStyle(
|
||
fontSize: 13, color: AppTheme.textSecondary)),
|
||
),
|
||
Expanded(
|
||
child: Text(value,
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: valueColor ?? AppTheme.textPrimary,
|
||
fontWeight: FontWeight.w500)),
|
||
),
|
||
if (trailing != null) trailing!,
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|