Files
jiu/client/lib/screens/about/about_screen.dart
T
wangjia 47b3c89460 feat(web): 新增更新日志、服务条款、隐私政策页面;关于页更新日志链接修正
- 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>
2026-06-10 22:56:22 +08:00

523 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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:0018: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!,
],
),
);
}
}