feat(client): 设置/用户/设备/关于窄屏对齐移动原型(hub 形态/卡片流/sheet/无打印)

- 系统设置窄屏改 hub 三组(门店/偏好/数据):门店信息/编号规则/默认仓库走
  底部 sheet,主题外观接 showThemeSheet,?tab= 深链窄屏忽略;桌面 subnav 不动
- 用户管理窄屏:m-section + 头像卡片流 + 角色图标徽章 + FAB 新增;
  点卡开详情 sheet(drow + 重置密码/启停/编辑),表单复用为 sheet 形态
- 设备管理窄屏:会话卡流(本机标注/在线图标徽章)+ 外设卡流(mc-foot 脚注),
  「+ 添加设备」在区块标题行右侧(m-pill 选择 sheet),打印模板窄屏不渲染
- 关于我们窄屏:品牌 hero + hub 链接组 + 产品信息 drow 卡 + 时间线 pill 标签
  + 意见反馈 sheet(类型 pill/描述/联系方式);授权信息不显示(已迁 /me/license)
- golden:四屏窄屏基准独立成 *_mobile_golden_test.dart(390×844 @2x 三主题),
  桌面 golden 零漂移

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-04 12:19:15 +08:00
parent dde87a32b6
commit 9445042cb4
24 changed files with 1975 additions and 311 deletions
+411 -3
View File
@@ -3,6 +3,9 @@
// 授权信息(3 cell, licenseProvider 实数据) / 更新日志(timeline, /public/release
// 与官网同源,默认 2 条可展开)。旧版多余卡(帮助文档/扫码防伪/系统信息)删除,
// 构建号并入版本 cell 小字(差异记 design/CONTRACT.md)。
// 窄屏(原型 m-about.html):品牌 hero(图标+名+版本+检查更新+反馈) + hub 链接组 +
// 产品信息卡(drow) + 更新日志时间线(pill 标签) + 反馈 sheet + 页脚备案;
// 授权信息窄屏不显示(已迁 /me/license)。
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -23,6 +26,8 @@ import '../../core/theme/app_fonts.dart';
import '../../widgets/brand_mark.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_toast.dart';
import '../../widgets/ds/m_hub.dart';
import '../../widgets/ds/m_sheet.dart';
class AboutScreen extends ConsumerStatefulWidget {
const AboutScreen({super.key});
@@ -38,12 +43,12 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
@override
Widget build(BuildContext context) {
final t = context.tokens;
// ── 窄屏(原型 m-about.html):hero + hub 链接组 + 产品信息 + 时间线 ──
if (context.isMobile) return _mobileBody(t);
return Container(
color: t.bg,
child: SingleChildScrollView(
padding: context.isMobile
? const EdgeInsets.all(AppDims.sp4)
: const EdgeInsets.fromLTRB(26, 22, 26, 22),
padding: const EdgeInsets.fromLTRB(26, 22, 26, 22),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
@@ -80,6 +85,284 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
);
}
// ── 窄屏整体(原型 m-about.html)────────────────────────────
Widget _mobileBody(dynamic t) {
return Container(
color: t.bg,
child: ListView(
padding: const EdgeInsets.all(14),
children: [
_mHero(t),
// hub 链接组(原型 .m-hub margin-top:10
Padding(
padding: const EdgeInsets.only(top: 10),
child: MHubGroup(items: [
MHubItem(
icon: LucideIcons.globe,
label: '官方网站',
onTap: () => _open(AppInfo.website)),
MHubItem(
icon: LucideIcons.messageCircle,
label: '微信客服',
onTap: () => showDsToast(context,
'微信客服:${AppInfo.wechat.isNotEmpty ? AppInfo.wechat : '请见官网'}')),
MHubItem(
// 原型 m-about 用 i-shield-2
icon: LucideIcons.shield,
label: '隐私政策',
onTap: () => _open(AppInfo.privacyUrl.isNotEmpty
? AppInfo.privacyUrl
: '${AppInfo.website}/privacy/')),
MHubItem(
icon: LucideIcons.fileText,
label: '服务条款',
onTap: () => _open(AppInfo.termsUrl.isNotEmpty
? AppInfo.termsUrl
: '${AppInfo.website}/terms/')),
]),
),
_mSection(t, '产品信息'),
_mProductCard(t),
_mSection(t, '更新日志'),
_mChangelogCard(t),
// 页脚 © + 备案号
Padding(
padding: const EdgeInsets.fromLTRB(0, 18, 0, 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('© 2026 岩美科技 · 保留所有权利 · ',
style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)),
InkWell(
onTap: () =>
launchUrl(Uri.parse('https://beian.miit.gov.cn')),
child: Text('京ICP备2026039814号',
style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)),
),
],
),
),
],
),
);
}
/// .m-section
Widget _mSection(dynamic t, String label) => Padding(
padding: const EdgeInsets.fromLTRB(2, 16, 2, 8),
child: Text(label,
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w700,
letterSpacing: .4,
color: t.muted)),
);
/// 原型 .ab-hero:居中品牌图标 + 名称 + 版本 + 检查更新 + 反馈双按钮。
Widget _mHero(dynamic t) {
final appVersion = ref.watch(appVersionProvider).valueOrNull ?? '';
return Container(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 16),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
child: Column(children: [
const BrandMark(size: 72),
const SizedBox(height: 14),
Text('岩美酒库',
style: TextStyle(
fontSize: AppDims.fsH1,
fontWeight: FontWeight.w800,
color: t.heading)),
const SizedBox(height: 4),
// 版本号 mono、中文说明走默认字体(mono 无 CJK 字形)
Row(mainAxisSize: MainAxisSize.min, children: [
if (appVersion.isNotEmpty)
Text('$appVersion · ',
style: TextStyle(
fontSize: AppDims.fsSm,
color: t.muted,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
Text('酒水进销存管理系统',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
]),
const SizedBox(height: 10),
DsSmallGhostButton(
label: '检查更新', icon: LucideIcons.refreshCw, onTap: _checkUpdate),
const SizedBox(height: 18),
Row(children: [
Expanded(
child: DsButton('反馈 Bug',
icon: LucideIcons.bug,
variant: DsBtnVariant.primary,
onPressed: () => _openFeedbackSheet('Bug')),
),
const SizedBox(width: 10),
Expanded(
child: DsButton('功能建议',
icon: LucideIcons.lightbulb,
onPressed: () => _openFeedbackSheet('功能建议')),
),
]),
]),
);
}
/// 产品信息卡(原型 .pe-card + drow:出品方 / 多端覆盖 / 产品定位)。
Widget _mProductCard(dynamic t) {
Widget drow(String label, String value, {bool last = false}) => Container(
padding: const EdgeInsets.symmetric(vertical: 11),
decoration: BoxDecoration(
border:
last ? null : Border(bottom: BorderSide(color: t.borderSubtle)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
const SizedBox(width: 12),
Expanded(
child: Text(value,
textAlign: TextAlign.right,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
color: t.text)),
),
],
),
);
return Container(
padding: const EdgeInsets.fromLTRB(14, 2, 14, 2),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
child: Column(children: [
drow('出品方', '岩美科技 · YANMEI TECH'),
drow('多端覆盖', 'Web · macOS · Windows · iOS · 安卓'),
drow('产品定位', '酒水进销存 / 财务 / 防伪溯源', last: true),
]),
);
}
/// 更新日志卡(原型 .tl 时间线:竖线 + 圆点 + 版本/日期 + pill 分组标签)。
Widget _mChangelogCard(dynamic t) {
final appVersion = ref.watch(appVersionProvider).valueOrNull ?? '';
final entries = ref.watch(changelogProvider).valueOrNull ?? const [];
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
child: entries.isEmpty
? Text(appVersion.isEmpty ? '暂无更新记录' : '当前版本 $appVersion',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted))
: Column(children: [
for (var i = 0; i < entries.length; i++)
_mTlItem(t, entries[i], last: i == entries.length - 1),
]),
);
}
/// 原型 .tl-item:左 2px 竖线 + 8px primary 圆点 + 版本/日期 + 分组。
Widget _mTlItem(dynamic t, ChangelogEntry e, {required bool last}) {
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 16,
child: Column(children: [
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(top: 4),
decoration:
BoxDecoration(shape: BoxShape.circle, color: t.primary),
),
if (!last)
Expanded(child: Container(width: 2, color: t.borderSubtle)),
]),
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Text('v${e.version}',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: AppDims.fsBody,
color: t.heading)),
const SizedBox(width: 8),
Text(e.date,
style: TextStyle(
fontSize: AppDims.fsXs,
color: t.faint,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
]),
for (final s in e.sections) ...[
const SizedBox(height: 8),
_mTlTag(t, s.type),
for (final item in s.items)
Padding(
padding: const EdgeInsets.only(top: 5, left: 2),
child: Text('· $item',
style: TextStyle(
fontSize: AppDims.fsSm,
height: 1.5,
color: t.text)),
),
],
],
),
),
),
],
),
);
}
/// 原型 .tltpill 标签(feat=success-bg / impr=info-soft / fix=warn-bg)。
Widget _mTlTag(dynamic t, String type) {
final (label, bg, fg) = switch (type) {
'feat' || '新功能' => ('新功能', t.successBg, t.success),
'impr' || 'improve' || '改进' => ('改进', t.infoSoft, t.primary),
'fix' || '修复' => ('修复', t.warnBg, t.warn),
_ => (type, t.borderSubtle, t.muted),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(AppDims.rPill),
),
child: Text(label,
style: TextStyle(
fontSize: AppDims.fsXs, fontWeight: FontWeight.w600, color: fg)),
);
}
/// 意见反馈 sheet(原型 openFb:类型 pill Bug/功能建议/其他 + 描述 + 联系方式)。
void _openFeedbackSheet(String initialType) {
showMSheet<void>(
context,
title: '意见反馈',
builder: (_) => _FeedbackSheetBody(initialType: initialType),
);
}
/// 原型 .cardsurface / border / r-lg / pad 20 22 / mb18。
Widget _card(dynamic t, Widget child, {Gradient? gradient}) => Container(
margin: const EdgeInsets.only(bottom: 18),
@@ -560,6 +843,131 @@ class DsSmallGhostButton extends StatelessWidget {
}
}
/// 窄屏意见反馈 sheet 内容(原型 m-about drawFb:类型 pill + 描述 + 联系方式)。
/// 提交沿用 feedbackRepository.submit(联系方式并入 content,接口无独立字段)。
class _FeedbackSheetBody extends ConsumerStatefulWidget {
final String initialType; // Bug / 功能建议 / 其他
const _FeedbackSheetBody({required this.initialType});
@override
ConsumerState<_FeedbackSheetBody> createState() => _FeedbackSheetBodyState();
}
class _FeedbackSheetBodyState extends ConsumerState<_FeedbackSheetBody> {
static const _types = ['Bug', '功能建议', '其他'];
late String _type =
_types.contains(widget.initialType) ? widget.initialType : _types.first;
final _descCtrl = TextEditingController();
final _contactCtrl = TextEditingController();
bool _submitting = false;
@override
void dispose() {
_descCtrl.dispose();
_contactCtrl.dispose();
super.dispose();
}
Future<void> _submit() async {
if (_descCtrl.text.trim().isEmpty) {
showDsToast(context, '请先填写反馈内容');
return;
}
setState(() => _submitting = true);
final apiType = switch (_type) {
'Bug' => 'bug',
'功能建议' => 'suggestion',
_ => 'other',
};
final contact = _contactCtrl.text.trim();
final content = contact.isEmpty
? _descCtrl.text.trim()
: '${_descCtrl.text.trim()}\n联系方式:$contact';
try {
await ref.read(feedbackRepositoryProvider).submit(
type: apiType,
content: content,
images: const [],
);
if (!mounted) return;
Navigator.of(context).pop();
showDsToast(context, '感谢反馈,我们会尽快跟进 ✓');
} catch (e) {
if (!mounted) return;
setState(() => _submitting = false);
showDsToast(context, '提交失败:$e');
}
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
Widget label(String s) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child:
Text(s, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
label('反馈类型'),
Wrap(spacing: 8, children: [
for (final v in _types)
// 原型 .m-pillh32 / r-pill / on=brand50+primary
InkWell(
onTap: () => setState(() => _type = v),
borderRadius: BorderRadius.circular(AppDims.rPill),
child: Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 13),
alignment: Alignment.center,
decoration: BoxDecoration(
color: v == _type ? t.brand50 : t.surface,
border: Border.all(color: v == _type ? t.primary : t.border),
borderRadius: BorderRadius.circular(AppDims.rPill),
),
child: Text(v,
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight:
v == _type ? FontWeight.w600 : FontWeight.w400,
color: v == _type ? t.primary : t.text)),
),
),
]),
const SizedBox(height: 14),
label('问题描述'),
TextField(
controller: _descCtrl,
maxLines: 4,
decoration: const InputDecoration(
hintText: '请描述遇到的问题或建议…',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 14),
label('联系方式(选填)'),
DsInput(controller: _contactCtrl, hintText: '微信 / 手机号,便于我们回访'),
const SizedBox(height: 16),
Row(children: [
Expanded(
child: DsButton('取消',
onPressed:
_submitting ? null : () => Navigator.of(context).pop()),
),
const SizedBox(width: 10),
Expanded(
flex: 2,
child: DsButton(_submitting ? '提交中…' : '提交',
variant: DsBtnVariant.primary,
onPressed: _submitting ? null : _submit),
),
]),
],
);
}
}
/// 反馈表单弹窗:文字 + 附图,直接提交后台(沿用原实现)。
class _FeedbackDialog extends ConsumerStatefulWidget {
final String type; // bug / suggestion