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>
@@ -3,6 +3,9 @@
|
|||||||
// 授权信息(3 cell, licenseProvider 实数据) / 更新日志(timeline, /public/release
|
// 授权信息(3 cell, licenseProvider 实数据) / 更新日志(timeline, /public/release
|
||||||
// 与官网同源,默认 2 条可展开)。旧版多余卡(帮助文档/扫码防伪/系统信息)删除,
|
// 与官网同源,默认 2 条可展开)。旧版多余卡(帮助文档/扫码防伪/系统信息)删除,
|
||||||
// 构建号并入版本 cell 小字(差异记 design/CONTRACT.md)。
|
// 构建号并入版本 cell 小字(差异记 design/CONTRACT.md)。
|
||||||
|
// 窄屏(原型 m-about.html):品牌 hero(图标+名+版本+检查更新+反馈) + hub 链接组 +
|
||||||
|
// 产品信息卡(drow) + 更新日志时间线(pill 标签) + 反馈 sheet + 页脚备案;
|
||||||
|
// 授权信息窄屏不显示(已迁 /me/license)。
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/brand_mark.dart';
|
||||||
import '../../widgets/ds/ds_atoms.dart';
|
import '../../widgets/ds/ds_atoms.dart';
|
||||||
import '../../widgets/ds/ds_toast.dart';
|
import '../../widgets/ds/ds_toast.dart';
|
||||||
|
import '../../widgets/ds/m_hub.dart';
|
||||||
|
import '../../widgets/ds/m_sheet.dart';
|
||||||
|
|
||||||
class AboutScreen extends ConsumerStatefulWidget {
|
class AboutScreen extends ConsumerStatefulWidget {
|
||||||
const AboutScreen({super.key});
|
const AboutScreen({super.key});
|
||||||
@@ -38,12 +43,12 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.tokens;
|
final t = context.tokens;
|
||||||
|
// ── 窄屏(原型 m-about.html):hero + hub 链接组 + 产品信息 + 时间线 ──
|
||||||
|
if (context.isMobile) return _mobileBody(t);
|
||||||
return Container(
|
return Container(
|
||||||
color: t.bg,
|
color: t.bg,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: context.isMobile
|
padding: const EdgeInsets.fromLTRB(26, 22, 26, 22),
|
||||||
? const EdgeInsets.all(AppDims.sp4)
|
|
||||||
: const EdgeInsets.fromLTRB(26, 22, 26, 22),
|
|
||||||
child: Center(
|
child: Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 760),
|
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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 原型 .tlt:pill 标签(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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 原型 .card:surface / border / r-lg / pad 20 22 / mb18。
|
/// 原型 .card:surface / border / r-lg / pad 20 22 / mb18。
|
||||||
Widget _card(dynamic t, Widget child, {Gradient? gradient}) => Container(
|
Widget _card(dynamic t, Widget child, {Gradient? gradient}) => Container(
|
||||||
margin: const EdgeInsets.only(bottom: 18),
|
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-pill:h32 / 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 {
|
class _FeedbackDialog extends ConsumerStatefulWidget {
|
||||||
final String type; // bug / suggestion
|
final String type; // bug / suggestion
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
// 三区块:登录设备管理(会话表,后端 /sessions) + 外设设备(卡片网格,店级
|
// 三区块:登录设备管理(会话表,后端 /sessions) + 外设设备(卡片网格,店级
|
||||||
// custom_fields.peripherals 本地存档) + 打印模板(静态两卡)。无 KPI/toolbar/分页。
|
// custom_fields.peripherals 本地存档) + 打印模板(静态两卡)。无 KPI/toolbar/分页。
|
||||||
// 外设为登记式存档(用户口径):测试打印/配置为原型态占位,解绑=从清单删除。
|
// 外设为登记式存档(用户口径):测试打印/配置为原型态占位,解绑=从清单删除。
|
||||||
|
// 窄屏(原型 m-devices.html):无页头,m-section 两区块(会话卡流 + 外设卡流,
|
||||||
|
// 「+ 添加设备」在区块标题行右侧,不用 FAB);详情/添加走底部 sheet;
|
||||||
|
// 打印模板窄屏不渲染(拍板:移动端无打印)。
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
@@ -17,7 +20,8 @@ import '../../providers/session_provider.dart';
|
|||||||
import '../../providers/shop_provider.dart';
|
import '../../providers/shop_provider.dart';
|
||||||
import '../../widgets/ds/ds_atoms.dart';
|
import '../../widgets/ds/ds_atoms.dart';
|
||||||
import '../../widgets/ds/ds_table.dart';
|
import '../../widgets/ds/ds_table.dart';
|
||||||
import '../../widgets/mobile_list_card.dart';
|
import '../../widgets/ds/m_sheet.dart';
|
||||||
|
import '../../widgets/ds/status_icon_map.dart';
|
||||||
import '../../widgets/write_guard.dart';
|
import '../../widgets/write_guard.dart';
|
||||||
import '../../core/theme/app_fonts.dart';
|
import '../../core/theme/app_fonts.dart';
|
||||||
import '../../widgets/ds/ds_toast.dart';
|
import '../../widgets/ds/ds_toast.dart';
|
||||||
@@ -100,18 +104,18 @@ class _DeviceManagementScreenState
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.tokens;
|
final t = context.tokens;
|
||||||
final mobile = context.isMobile;
|
|
||||||
final peripherals = peripheralsOf(
|
final peripherals = peripheralsOf(
|
||||||
ref.watch(shopInfoProvider).valueOrNull?.customFields ?? const {});
|
ref.watch(shopInfoProvider).valueOrNull?.customFields ?? const {});
|
||||||
final on = peripherals.where((p) => p.status == '在线').length;
|
final on = peripherals.where((p) => p.status == '在线').length;
|
||||||
|
|
||||||
|
// ── 窄屏(原型 m-devices.html):会话卡流 + 外设卡流,无页头/打印模板 ──
|
||||||
|
if (context.isMobile) return _mobileBody(t, peripherals);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
color: t.bg,
|
color: t.bg,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: mobile
|
// 原型 .main{padding:22px 26px}
|
||||||
? const EdgeInsets.all(AppDims.sp4)
|
padding: const EdgeInsets.fromLTRB(26, 22, 26, 22),
|
||||||
// 原型 .main{padding:22px 26px}
|
|
||||||
: const EdgeInsets.fromLTRB(26, 22, 26, 22),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
@@ -134,19 +138,17 @@ class _DeviceManagementScreenState
|
|||||||
TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (!mobile) ...[
|
DsButton('刷新', icon: LucideIcons.refreshCw, onPressed: () {
|
||||||
DsButton('刷新', icon: LucideIcons.refreshCw, onPressed: () {
|
_reload();
|
||||||
_reload();
|
_snack('设备状态已刷新 ✓');
|
||||||
_snack('设备状态已刷新 ✓');
|
}),
|
||||||
}),
|
const SizedBox(width: 10),
|
||||||
const SizedBox(width: 10),
|
WriteGuard(
|
||||||
WriteGuard(
|
child: DsButton('添加设备',
|
||||||
child: DsButton('添加设备',
|
icon: LucideIcons.plus,
|
||||||
icon: LucideIcons.plus,
|
variant: DsBtnVariant.primary,
|
||||||
variant: DsBtnVariant.primary,
|
onPressed: _openAdd),
|
||||||
onPressed: _openAdd),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -155,39 +157,482 @@ class _DeviceManagementScreenState
|
|||||||
_sessionSection(),
|
_sessionSection(),
|
||||||
// ── 区块 B:外设设备 ──
|
// ── 区块 B:外设设备 ──
|
||||||
_secTitle(t, '外设设备', '共 ${peripherals.length} 台 · 在线 $on'),
|
_secTitle(t, '外设设备', '共 ${peripherals.length} 台 · 在线 $on'),
|
||||||
if (mobile)
|
_devGrid(peripherals),
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
for (var i = 0; i < peripherals.length; i++) ...[
|
|
||||||
if (i > 0) const SizedBox(height: 10),
|
|
||||||
_peripheralMobileCard(peripherals, i),
|
|
||||||
],
|
|
||||||
if (peripherals.isEmpty) _emptyHint(t, '还没有外设 · 点「添加设备」登记'),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: WriteGuard(
|
|
||||||
child: DsButton('添加设备',
|
|
||||||
small: true,
|
|
||||||
icon: LucideIcons.plus,
|
|
||||||
variant: DsBtnVariant.primary,
|
|
||||||
onPressed: _openAdd),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
else
|
|
||||||
_devGrid(peripherals),
|
|
||||||
// ── 区块 C:打印模板 ──
|
// ── 区块 C:打印模板 ──
|
||||||
_secTitle(t, '打印模板', '标签与小票排版'),
|
_secTitle(t, '打印模板', '标签与小票排版'),
|
||||||
_tplGrid(t, mobile),
|
_tplGrid(t),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 窄屏整体(原型 m-devices.html)──────────────────────────
|
||||||
|
Widget _mobileBody(dynamic t, List<Peripheral> peripherals) {
|
||||||
|
final canKick = _canKick;
|
||||||
|
final async = ref.watch(sessionListProvider);
|
||||||
|
return Container(
|
||||||
|
color: t.bg,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
children: [
|
||||||
|
_mSection(t, '登录设备管理'),
|
||||||
|
async.when(
|
||||||
|
loading: () => const Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Center(child: CircularProgressIndicator())),
|
||||||
|
error: (e, _) => Row(children: [
|
||||||
|
_emptyHint(t, '会话加载失败'),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
DsButton('重试',
|
||||||
|
small: true,
|
||||||
|
onPressed: () =>
|
||||||
|
ref.read(sessionListProvider.notifier).reload()),
|
||||||
|
]),
|
||||||
|
data: (sessions) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (sessions.isEmpty) _emptyHint(t, '暂无登录设备'),
|
||||||
|
for (final s in sessions) _mSessionCard(t, s, canKick),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 区块标题行右侧「+ 添加设备」文字入口(原型形态,不用 FAB)
|
||||||
|
_mSection(t, '外设设备',
|
||||||
|
trailing: WriteGuard(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _openAddSheet,
|
||||||
|
child: Text('+ 添加设备',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppDims.fsSm,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: t.primary)),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
if (peripherals.isEmpty) _emptyHint(t, '还没有外设 · 点「添加设备」登记'),
|
||||||
|
for (var i = 0; i < peripherals.length; i++)
|
||||||
|
_mPeripheralCard(t, peripherals, i),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _canKick {
|
||||||
|
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
|
||||||
|
return role == 'admin' || role == 'superadmin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// .m-section(可带右侧动作,如「+ 添加设备」)。
|
||||||
|
Widget _mSection(dynamic t, String label, {Widget? trailing}) => Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(2, 8, 2, 8),
|
||||||
|
child: Row(children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppDims.fsSm,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: .4,
|
||||||
|
color: t.muted)),
|
||||||
|
),
|
||||||
|
if (trailing != null) trailing,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// .m-card 外壳(surface / border / r-lg / pad 13 14 / mb10)。
|
||||||
|
Widget _mCard(dynamic t, {required Widget child, VoidCallback? onTap}) =>
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: Material(
|
||||||
|
color: t.surface,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: BorderSide(color: t.border),
|
||||||
|
borderRadius: BorderRadius.circular(AppDims.rLg),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 状态徽章(图标变体:在线=wifi / 离线=wifi-off)。
|
||||||
|
DsBadge _statusBadgeIc(String status) => DsBadge(status,
|
||||||
|
tone: status == '在线' ? DsBadgeTone.ok : DsBadgeTone.muted,
|
||||||
|
icon: statusIcon(status));
|
||||||
|
|
||||||
|
/// 会话卡(原型:用户·平台+本机 / 设备·IP / 在线徽章+时间 / ›)。
|
||||||
|
Widget _mSessionCard(dynamic t, DeviceSession s, bool canKick) {
|
||||||
|
String fmt(DateTime? d) => d == null ? '—' : _fmt.format(d);
|
||||||
|
return _mCard(
|
||||||
|
t,
|
||||||
|
onTap: () => _openSessionSheet(s, canKick),
|
||||||
|
child: Row(children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text('${_sessionName(s)} · ${s.platformLabel}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppDims.fsBody,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: t.heading)),
|
||||||
|
),
|
||||||
|
if (s.isCurrent) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text('本机',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppDims.fsXs,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: t.primary)),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
'${s.deviceName.isEmpty ? s.platform : s.deviceName}'
|
||||||
|
'${s.ip.isEmpty ? '' : ' · ${s.ip}'}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppDims.fsXs,
|
||||||
|
color: t.muted,
|
||||||
|
fontFamily: AppFonts.mono,
|
||||||
|
fontFamilyFallback: AppFonts.monoFallback)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
_statusBadgeIc(s.online ? '在线' : '离线'),
|
||||||
|
const SizedBox(height: 5),
|
||||||
|
Text(fmt(s.lastSeenAt),
|
||||||
|
style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Icon(LucideIcons.chevronRight, size: 18, color: t.faint),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话详情 sheet(drow 键值 + 非本机「强制下线」danger)。
|
||||||
|
void _openSessionSheet(DeviceSession s, bool canKick) {
|
||||||
|
String fmt(DateTime? d) => d == null ? '—' : _fmt.format(d);
|
||||||
|
showMSheet<void>(
|
||||||
|
context,
|
||||||
|
title: '${_sessionName(s)} · ${s.platformLabel}',
|
||||||
|
builder: (ctx) {
|
||||||
|
final t = ctx.tokens;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_drow(t, '用户', Text(_sessionName(s), style: _drowValStyle(t))),
|
||||||
|
_drow(
|
||||||
|
t,
|
||||||
|
'设备',
|
||||||
|
Text(s.deviceName.isEmpty ? s.platform : s.deviceName,
|
||||||
|
style: _drowValStyle(t, mono: true))),
|
||||||
|
_drow(
|
||||||
|
t,
|
||||||
|
'IP 地址',
|
||||||
|
Text(s.ip.isEmpty ? '—' : s.ip,
|
||||||
|
style: _drowValStyle(t, mono: true))),
|
||||||
|
_drow(t, '登录时间',
|
||||||
|
Text(fmt(s.createdAt), style: _drowValStyle(t, mono: true))),
|
||||||
|
_drow(t, '最近活跃',
|
||||||
|
Text(fmt(s.lastSeenAt), style: _drowValStyle(t, mono: true))),
|
||||||
|
_drow(
|
||||||
|
t,
|
||||||
|
'状态',
|
||||||
|
Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
_statusBadgeIc(s.online ? '在线' : '离线'),
|
||||||
|
if (s.isCurrent) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text('本机会话',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppDims.fsXs,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: t.primary)),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
last: true),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(children: [
|
||||||
|
Expanded(
|
||||||
|
child: DsButton('关闭', onPressed: () => Navigator.of(ctx).pop()),
|
||||||
|
),
|
||||||
|
if (canKick && !s.isCurrent) ...[
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: DsButton('强制下线', variant: DsBtnVariant.danger,
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
_confirmKick(s);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 外设卡(原型:名称 / 型号·类型·连接 / 状态徽章 / mc-foot 最近活动)。
|
||||||
|
Widget _mPeripheralCard(dynamic t, List<Peripheral> items, int index) {
|
||||||
|
final d = items[index];
|
||||||
|
return _mCard(
|
||||||
|
t,
|
||||||
|
onTap: () => _openPeripheralSheet(items, index),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(d.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppDims.fsBody,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: t.heading)),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
'${d.model.isEmpty ? d.kind : d.model} · ${d.kind} · ${d.conn}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_statusBadgeIc(d.status),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Icon(LucideIcons.chevronRight, size: 18, color: t.faint),
|
||||||
|
]),
|
||||||
|
// .mc-foot:最近活动脚注
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(top: 10),
|
||||||
|
padding: const EdgeInsets.only(top: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(top: BorderSide(color: t.borderSubtle))),
|
||||||
|
child: Row(children: [
|
||||||
|
Icon(LucideIcons.info, size: 13, color: t.muted),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text('最近活动 ${d.last.isEmpty ? '—' : d.last}',
|
||||||
|
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 外设详情 sheet(drow 键值 + 测试/配置/解绑)。
|
||||||
|
void _openPeripheralSheet(List<Peripheral> items, int index) {
|
||||||
|
final d = items[index];
|
||||||
|
final online = d.status == '在线';
|
||||||
|
showMSheet<void>(
|
||||||
|
context,
|
||||||
|
title: d.name,
|
||||||
|
builder: (ctx) {
|
||||||
|
final t = ctx.tokens;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_drow(t, '型号',
|
||||||
|
Text(d.model.isEmpty ? '—' : d.model, style: _drowValStyle(t))),
|
||||||
|
_drow(t, '类型', Text(d.kind, style: _drowValStyle(t))),
|
||||||
|
_drow(t, '连接方式', Text(d.conn, style: _drowValStyle(t, mono: true))),
|
||||||
|
_drow(t, '最近活动',
|
||||||
|
Text(d.last.isEmpty ? '—' : d.last, style: _drowValStyle(t))),
|
||||||
|
_drow(t, '状态', _statusBadgeIc(d.status), last: true),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(children: [
|
||||||
|
Expanded(
|
||||||
|
child: DsButton('测试',
|
||||||
|
onPressed: () => online
|
||||||
|
? _snack('已发送测试打印 → ${d.name}')
|
||||||
|
: _snack('设备离线,无法测试 · ${d.name}', err: true)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: DsButton('配置', onPressed: () => _snack('外设配置即将上线')),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: WriteGuard(
|
||||||
|
child: DsButton('解绑', variant: DsBtnVariant.danger,
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
_unbind(items, index);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// atoms .drow:label muted + 右值 w600,行距 11、subtle 分隔。
|
||||||
|
Widget _drow(dynamic t, String label, Widget value, {bool last = false}) =>
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 11),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border:
|
||||||
|
last ? null : Border(bottom: BorderSide(color: t.borderSubtle)),
|
||||||
|
),
|
||||||
|
child: Row(children: [
|
||||||
|
Text(label,
|
||||||
|
style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
|
||||||
|
const Spacer(),
|
||||||
|
value,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
TextStyle _drowValStyle(dynamic t, {bool mono = false}) => TextStyle(
|
||||||
|
fontSize: AppDims.fsBody,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: t.text,
|
||||||
|
fontFamily: mono ? AppFonts.mono : null,
|
||||||
|
fontFamilyFallback: mono ? AppFonts.monoFallback : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 添加设备 sheet(原型 drawAdd:m-pill 选类型/连接 + 名称/型号 + 保存)。
|
||||||
|
void _openAddSheet() {
|
||||||
|
final nameCtrl = TextEditingController();
|
||||||
|
final modelCtrl = TextEditingController();
|
||||||
|
var kind = _kinds.first;
|
||||||
|
var conn = _conns.first;
|
||||||
|
showMSheet<void>(
|
||||||
|
context,
|
||||||
|
title: '添加设备',
|
||||||
|
builder: (ctx) => StatefulBuilder(builder: (ctx, setLocal) {
|
||||||
|
final t = ctx.tokens;
|
||||||
|
Widget pills(String label, List<String> opts, String sel,
|
||||||
|
ValueChanged<String> onSel) =>
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label,
|
||||||
|
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Wrap(spacing: 8, runSpacing: 8, children: [
|
||||||
|
for (final v in opts)
|
||||||
|
// 原型 .m-pill:h32 / r-pill / on=brand50+primary
|
||||||
|
InkWell(
|
||||||
|
onTap: () => setLocal(() => onSel(v)),
|
||||||
|
borderRadius: BorderRadius.circular(AppDims.rPill),
|
||||||
|
child: Container(
|
||||||
|
height: 32,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 13),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: v == sel ? t.brand50 : t.surface,
|
||||||
|
border: Border.all(
|
||||||
|
color: v == sel ? t.primary : t.border),
|
||||||
|
borderRadius: BorderRadius.circular(AppDims.rPill),
|
||||||
|
),
|
||||||
|
child: Text(v,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppDims.fsSm,
|
||||||
|
fontWeight: v == sel
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.w400,
|
||||||
|
color: v == sel ? t.primary : t.text)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
pills('设备类型', _kinds, kind, (v) => kind = v),
|
||||||
|
pills('连接方式', _conns, conn, (v) => conn = v),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('设备名称',
|
||||||
|
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
DsInput(controller: nameCtrl, hintText: '如:前台标签机'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('型号 / 地址',
|
||||||
|
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
DsInput(
|
||||||
|
controller: modelCtrl,
|
||||||
|
hintText: '如:Zebra GK888t 或 192.168.1.50'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(children: [
|
||||||
|
Expanded(
|
||||||
|
child: DsButton('取消', onPressed: () => Navigator.of(ctx).pop()),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: DsButton('保存并连接', variant: DsBtnVariant.primary,
|
||||||
|
onPressed: () async {
|
||||||
|
if (nameCtrl.text.trim().isEmpty) {
|
||||||
|
_snack('请填写设备名称', err: true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
final items = peripheralsOf(
|
||||||
|
ref.read(shopInfoProvider).valueOrNull?.customFields ??
|
||||||
|
const {});
|
||||||
|
try {
|
||||||
|
await _savePeripherals([
|
||||||
|
...items,
|
||||||
|
Peripheral(
|
||||||
|
name: nameCtrl.text.trim(),
|
||||||
|
kind: kind,
|
||||||
|
model: modelCtrl.text.trim(),
|
||||||
|
conn: conn,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
if (mounted) _snack('设备已添加并连接 ✓');
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) _snack('添加失败:$e', err: true);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 原型 .sec-title:17/700 heading + small 12 muted,margin 26 0 14(首个 0 顶距)。
|
/// 原型 .sec-title:17/700 heading + small 12 muted,margin 26 0 14(首个 0 顶距)。
|
||||||
Widget _secTitle(dynamic t, String title, String small,
|
Widget _secTitle(dynamic t, String title, String small,
|
||||||
{bool first = false}) =>
|
{bool first = false}) =>
|
||||||
@@ -241,8 +686,6 @@ class _DeviceManagementScreenState
|
|||||||
return DsTable(
|
return DsTable(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
emptyText: '暂无登录设备',
|
emptyText: '暂无登录设备',
|
||||||
mobileCards:
|
|
||||||
sessions.map((s) => _sessionMobileCard(s, canKick)).toList(),
|
|
||||||
columns: const [
|
columns: const [
|
||||||
DsColumn('user', '用户'),
|
DsColumn('user', '用户'),
|
||||||
DsColumn('platform', '平台'),
|
DsColumn('platform', '平台'),
|
||||||
@@ -337,33 +780,6 @@ class _DeviceManagementScreenState
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _sessionMobileCard(DeviceSession s, bool canKick) {
|
|
||||||
final t = context.tokens;
|
|
||||||
String fmt(DateTime? d) => d == null ? '—' : _fmt.format(d);
|
|
||||||
return MobileListCard(
|
|
||||||
title: Text(_sessionName(s)),
|
|
||||||
subtitle: Text('${s.platformLabel} · ${s.platformClassLabel}'),
|
|
||||||
trailing: DsBadge(s.online ? '在线' : '离线',
|
|
||||||
tone: s.online ? DsBadgeTone.ok : DsBadgeTone.muted),
|
|
||||||
fields: [
|
|
||||||
if (s.ip.isNotEmpty) MobileCardField('IP', s.ip),
|
|
||||||
MobileCardField('登录时间', fmt(s.createdAt)),
|
|
||||||
MobileCardField('最近活跃', fmt(s.lastSeenAt)),
|
|
||||||
if (s.isCurrent) const MobileCardField('备注', '当前设备'),
|
|
||||||
],
|
|
||||||
actions: (canKick && !s.isCurrent)
|
|
||||||
? [
|
|
||||||
TextButton(
|
|
||||||
key: Key('btn_kick_${s.id}'),
|
|
||||||
onPressed: () => _confirmKick(s),
|
|
||||||
child: Text('强制下线',
|
|
||||||
style: TextStyle(fontSize: 13, color: t.danger)),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _confirmKick(DeviceSession s) async {
|
Future<void> _confirmKick(DeviceSession s) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -513,50 +929,14 @@ class _DeviceManagementScreenState
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _peripheralMobileCard(List<Peripheral> items, int index) {
|
// ── 区块 C:打印模板(静态两卡,仅桌面;拍板移动端无打印)────
|
||||||
final d = items[index];
|
Widget _tplGrid(dynamic t) {
|
||||||
final online = d.status == '在线';
|
|
||||||
return MobileListCard(
|
|
||||||
title: Text(d.name),
|
|
||||||
subtitle: Text('${d.model.isEmpty ? d.kind : d.model} · ${d.kind}'),
|
|
||||||
trailing:
|
|
||||||
DsBadge(d.status, tone: online ? DsBadgeTone.ok : DsBadgeTone.muted),
|
|
||||||
fields: [
|
|
||||||
MobileCardField('连接方式', d.conn),
|
|
||||||
if (d.last.isNotEmpty) MobileCardField('最近活动', d.last),
|
|
||||||
],
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => online
|
|
||||||
? _snack('已发送测试打印 → ${d.name}')
|
|
||||||
: _snack('设备离线,无法测试 · ${d.name}', err: true),
|
|
||||||
child: const Text('测试打印', style: TextStyle(fontSize: 13)),
|
|
||||||
),
|
|
||||||
WriteGuard(
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: () => _unbind(items, index),
|
|
||||||
child: Text('解绑',
|
|
||||||
style: TextStyle(fontSize: 13, color: context.tokens.danger)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 区块 C:打印模板(静态两卡)─────────────────────────────
|
|
||||||
Widget _tplGrid(dynamic t, bool mobile) {
|
|
||||||
final cards = [
|
final cards = [
|
||||||
_tplCard(
|
_tplCard(
|
||||||
t, LucideIcons.tag, '标签模板 · 商品价签', '40×30mm · 品名 / 规格 / 条码 / 零售价'),
|
t, LucideIcons.tag, '标签模板 · 商品价签', '40×30mm · 品名 / 规格 / 条码 / 零售价'),
|
||||||
_tplCard(t, LucideIcons.receiptText, '小票模板 · 出库单据',
|
_tplCard(t, LucideIcons.receiptText, '小票模板 · 出库单据',
|
||||||
'58mm 热敏 · 抬头 / 明细 / 合计 / 经手人'),
|
'58mm 热敏 · 抬头 / 明细 / 合计 / 经手人'),
|
||||||
];
|
];
|
||||||
if (mobile) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [cards[0], const SizedBox(height: 14), cards[1]],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Row(children: [
|
return Row(children: [
|
||||||
Expanded(child: cards[0]),
|
Expanded(child: cards[0]),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
// 原型 4(门店信息/用户管理/授权管理/偏好设置)+ 保留真实功能「编号规则」。
|
// 原型 4(门店信息/用户管理/授权管理/偏好设置)+ 保留真实功能「编号规则」。
|
||||||
// 用户管理面板 = 预览表 + 「管理全部用户」→ /settings/users 独立页(原型 users.html)。
|
// 用户管理面板 = 预览表 + 「管理全部用户」→ /settings/users 独立页(原型 users.html)。
|
||||||
// 原「系统参数」假设置(本地 state 不落后端)已按用户口径删除。
|
// 原「系统参数」假设置(本地 state 不落后端)已按用户口径删除。
|
||||||
|
// 窄屏(原型 m-settings.html):hub 形态三组(门店/偏好/数据),标题在壳顶栏;
|
||||||
|
// 门店信息/编号规则/默认仓库走底部 sheet;?tab= 深链窄屏忽略(无面板可落)。
|
||||||
|
// 授权入口不在此(拍板:授权在「我的」系统组 /me/license)。
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
@@ -25,6 +28,9 @@ import '../../providers/warehouse_provider.dart';
|
|||||||
import '../../widgets/ds/ds_atoms.dart';
|
import '../../widgets/ds/ds_atoms.dart';
|
||||||
import '../../widgets/ds/ds_menu.dart';
|
import '../../widgets/ds/ds_menu.dart';
|
||||||
import '../../widgets/ds/ds_table.dart';
|
import '../../widgets/ds/ds_table.dart';
|
||||||
|
import '../../widgets/ds/m_hub.dart';
|
||||||
|
import '../../widgets/ds/m_sheet.dart';
|
||||||
|
import '../../widgets/theme_sheet.dart';
|
||||||
import '../../widgets/write_guard.dart';
|
import '../../widgets/write_guard.dart';
|
||||||
import 'license_panel.dart';
|
import 'license_panel.dart';
|
||||||
import 'settings_card.dart';
|
import 'settings_card.dart';
|
||||||
@@ -56,20 +62,17 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.tokens;
|
final t = context.tokens;
|
||||||
final mobile = context.isMobile;
|
// 窄屏:hub 形态(原型 m-settings.html);?tab= 深链在窄屏无面板可落,忽略
|
||||||
|
if (context.isMobile) return _mobileHub(t);
|
||||||
return Container(
|
return Container(
|
||||||
color: t.bg,
|
color: t.bg,
|
||||||
padding:
|
padding: const EdgeInsets.fromLTRB(26, 22, 26, 22),
|
||||||
mobile ? EdgeInsets.zero : const EdgeInsets.fromLTRB(26, 22, 26, 22),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
// ── 头部(原型 .head)──
|
// ── 头部(原型 .head)──
|
||||||
Padding(
|
Padding(
|
||||||
padding: mobile
|
padding: const EdgeInsets.only(bottom: 18),
|
||||||
? const EdgeInsets.fromLTRB(
|
|
||||||
AppDims.sp4, AppDims.sp4, AppDims.sp4, AppDims.sp2)
|
|
||||||
: const EdgeInsets.only(bottom: 18),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
@@ -87,42 +90,224 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (mobile)
|
|
||||||
Padding(
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.fromLTRB(AppDims.sp4, 0, AppDims.sp4, 12),
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: DsSeg(
|
|
||||||
items: [for (final n in _navs) n.$2],
|
|
||||||
index: _panel,
|
|
||||||
onChanged: (i) => setState(() => _panel = i),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: mobile
|
child: Row(
|
||||||
? SingleChildScrollView(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
padding: const EdgeInsets.fromLTRB(
|
children: [
|
||||||
AppDims.sp4, 0, AppDims.sp4, AppDims.sp4),
|
_subnav(t),
|
||||||
child: _panelBody(),
|
const SizedBox(width: 22),
|
||||||
)
|
Expanded(
|
||||||
: Row(
|
child: SingleChildScrollView(child: _panelBody()),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
),
|
||||||
children: [
|
],
|
||||||
_subnav(t),
|
),
|
||||||
const SizedBox(width: 22),
|
|
||||||
Expanded(
|
|
||||||
child: SingleChildScrollView(child: _panelBody()),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 窄屏 hub(原型 m-settings.html:门店/偏好/数据 三组)─────────
|
||||||
|
Widget _mobileHub(dynamic t) {
|
||||||
|
return Container(
|
||||||
|
color: t.bg,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
children: [
|
||||||
|
MHubGroup(label: '门店', items: [
|
||||||
|
// 原型 i-ic57(门店信息)→ house,与桌面 subnav 同源
|
||||||
|
MHubItem(
|
||||||
|
icon: LucideIcons.house, label: '门店信息', onTap: _openShopSheet),
|
||||||
|
MHubItem(
|
||||||
|
icon: LucideIcons.userPlus,
|
||||||
|
label: '用户与角色',
|
||||||
|
onTap: () => context.push('/settings/users')),
|
||||||
|
MHubItem(
|
||||||
|
icon: LucideIcons.hash, label: '编号规则', onTap: _openRulesSheet),
|
||||||
|
]),
|
||||||
|
MHubGroup(label: '偏好', items: [
|
||||||
|
MHubItem(
|
||||||
|
icon: LucideIcons.shirt,
|
||||||
|
label: '主题外观',
|
||||||
|
onTap: () => showThemeSheet(context)),
|
||||||
|
MHubItem(
|
||||||
|
icon: LucideIcons.warehouse,
|
||||||
|
label: '默认仓库',
|
||||||
|
onTap: _openWarehouseSheet),
|
||||||
|
MHubItem(
|
||||||
|
icon: LucideIcons.monitorSmartphone,
|
||||||
|
label: '设备管理',
|
||||||
|
onTap: () => context.go('/devices')),
|
||||||
|
]),
|
||||||
|
MHubGroup(label: '数据', items: [
|
||||||
|
MHubItem(
|
||||||
|
icon: LucideIcons.cloud,
|
||||||
|
label: '数据备份',
|
||||||
|
onTap: () => showDsToast(context, '云端每日自动备份,无需手动操作')),
|
||||||
|
MHubItem(
|
||||||
|
icon: LucideIcons.info,
|
||||||
|
label: '关于与版本',
|
||||||
|
onTap: () => context.go('/about')),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 门店信息 sheet(原型 openShopSheet:名称/编号只读/电话/微信/地址 + 保存)。
|
||||||
|
void _openShopSheet() {
|
||||||
|
showMSheet<void>(
|
||||||
|
context,
|
||||||
|
title: '门店信息',
|
||||||
|
builder: (_) => const _ShopInfoSheetBody(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编号规则 sheet(原型 openRulesSheet:notice + drow 行;编辑走既有 dialog)。
|
||||||
|
void _openRulesSheet() {
|
||||||
|
showMSheet<void>(
|
||||||
|
context,
|
||||||
|
title: '编号规则',
|
||||||
|
builder: (_) => Consumer(builder: (ctx, ref, _) {
|
||||||
|
final t = ctx.tokens;
|
||||||
|
final async = ref.watch(numberRuleListProvider);
|
||||||
|
return async.when(
|
||||||
|
loading: () => const Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Center(child: CircularProgressIndicator())),
|
||||||
|
error: (e, _) => Text('加载失败',
|
||||||
|
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)),
|
||||||
|
data: (rules) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// 原型 .notice.info
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 14),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: t.infoSoft,
|
||||||
|
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||||
|
),
|
||||||
|
child: Row(children: [
|
||||||
|
Icon(LucideIcons.info, size: 16, color: t.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text('单据编号自动生成,规则全店统一;序号按规则递增。',
|
||||||
|
style:
|
||||||
|
TextStyle(fontSize: AppDims.fsSm, color: t.text)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
if (rules.isEmpty)
|
||||||
|
Text('暂无编号规则',
|
||||||
|
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||||
|
for (var i = 0; i < rules.length; i++)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 11),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: i == rules.length - 1
|
||||||
|
? null
|
||||||
|
: Border(bottom: BorderSide(color: t.borderSubtle)),
|
||||||
|
),
|
||||||
|
child: Row(children: [
|
||||||
|
Text(rules[i].typeLabel,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppDims.fsBody, color: t.muted)),
|
||||||
|
const Spacer(),
|
||||||
|
Text(rules[i].exampleNo,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppDims.fsBody,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontFamily: AppFonts.mono,
|
||||||
|
fontFamilyFallback: AppFonts.monoFallback,
|
||||||
|
color: t.text)),
|
||||||
|
WriteGuard(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 10),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _editRule(rules[i]),
|
||||||
|
child: Icon(LucideIcons.pencil,
|
||||||
|
size: 15, color: t.muted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 默认仓库 sheet(原型 openWhSheet:m-opt 行选择,点选即保存)。
|
||||||
|
void _openWarehouseSheet() {
|
||||||
|
showMSheet<void>(
|
||||||
|
context,
|
||||||
|
title: '默认仓库',
|
||||||
|
builder: (_) => Consumer(builder: (ctx, ref, _) {
|
||||||
|
final t = ctx.tokens;
|
||||||
|
final readonly = WriteGuard.isReadonly(ref);
|
||||||
|
final warehouses =
|
||||||
|
ref.watch(warehouseListProvider).valueOrNull ?? const [];
|
||||||
|
if (warehouses.isEmpty) {
|
||||||
|
return Text('暂无仓库',
|
||||||
|
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted));
|
||||||
|
}
|
||||||
|
return Column(children: [
|
||||||
|
for (var i = 0; i < warehouses.length; i++)
|
||||||
|
Builder(builder: (_) {
|
||||||
|
final w = warehouses[i];
|
||||||
|
return InkWell(
|
||||||
|
// 只读角色不可改默认仓库(与桌面 WriteGuard 同口径)
|
||||||
|
onTap: readonly
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
if (w.isDefault) return;
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(warehouseListProvider.notifier)
|
||||||
|
.updateWarehouse(w.id, {'is_default': true});
|
||||||
|
if (mounted) {
|
||||||
|
showDsToast(context, '默认仓库:${w.name} ✓',
|
||||||
|
bg: context.tokens.success);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
showDsToast(context, '设置失败:$e',
|
||||||
|
bg: context.tokens.danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(4, 13, 4, 13),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: i == warehouses.length - 1
|
||||||
|
? null
|
||||||
|
: Border(bottom: BorderSide(color: t.borderSubtle)),
|
||||||
|
),
|
||||||
|
child: Row(children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(w.name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppDims.fsBody,
|
||||||
|
fontWeight: w.isDefault
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.w400,
|
||||||
|
color: w.isDefault ? t.primary : t.text)),
|
||||||
|
),
|
||||||
|
if (w.isDefault)
|
||||||
|
Icon(LucideIcons.check, size: 18, color: t.primary),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 原型 .subnav:200px surface r-lg pad8;项高 40 gap10;on=brand50/primary/600。
|
/// 原型 .subnav:200px surface r-lg pad8;项高 40 gap10;on=brand50/primary/600。
|
||||||
Widget _subnav(dynamic t) {
|
Widget _subnav(dynamic t) {
|
||||||
return Container(
|
return Container(
|
||||||
@@ -359,6 +544,148 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 窄屏:门店信息 sheet 表单(逻辑同 _StoreInfoPanel,logo 上传窄屏省略)──
|
||||||
|
class _ShopInfoSheetBody extends ConsumerStatefulWidget {
|
||||||
|
const _ShopInfoSheetBody();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_ShopInfoSheetBody> createState() => _ShopInfoSheetBodyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShopInfoSheetBodyState extends ConsumerState<_ShopInfoSheetBody> {
|
||||||
|
final _nameCtrl = TextEditingController();
|
||||||
|
final _phoneCtrl = TextEditingController();
|
||||||
|
final _wechatCtrl = TextEditingController();
|
||||||
|
final _addressCtrl = TextEditingController();
|
||||||
|
ShopInfo? _loaded;
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameCtrl.dispose();
|
||||||
|
_phoneCtrl.dispose();
|
||||||
|
_wechatCtrl.dispose();
|
||||||
|
_addressCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fill(ShopInfo shop) {
|
||||||
|
_loaded = shop;
|
||||||
|
_nameCtrl.text = shop.name;
|
||||||
|
_phoneCtrl.text = shop.phone;
|
||||||
|
_wechatCtrl.text = shop.wechatId;
|
||||||
|
_addressCtrl.text = shop.address;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
final shop = _loaded;
|
||||||
|
if (shop == null) return;
|
||||||
|
setState(() => _saving = true);
|
||||||
|
try {
|
||||||
|
await ref.read(shopRepositoryProvider).updateInfo({
|
||||||
|
'name': _nameCtrl.text.trim(),
|
||||||
|
'address': _addressCtrl.text.trim(),
|
||||||
|
'phone': _phoneCtrl.text.trim(),
|
||||||
|
'manager_name': shop.managerName,
|
||||||
|
'wechat_id': _wechatCtrl.text.trim(),
|
||||||
|
if (shop.logoUrl.isNotEmpty) 'logo_url': shop.logoUrl,
|
||||||
|
'custom_fields': shop.customFields,
|
||||||
|
});
|
||||||
|
ref.invalidate(shopInfoProvider);
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
showDsToast(context, '门店信息已保存 ✓', bg: context.tokens.success);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
showDsToast(context, '保存失败:$e', bg: context.tokens.danger);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _saving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = context.tokens;
|
||||||
|
final async = ref.watch(shopInfoProvider);
|
||||||
|
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
|
||||||
|
final canEdit = (role == 'admin' || role == 'superadmin') &&
|
||||||
|
!WriteGuard.isReadonly(ref) &&
|
||||||
|
!WriteGuard.licenseBlocked(ref);
|
||||||
|
|
||||||
|
return async.when(
|
||||||
|
loading: () => const Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Center(child: CircularProgressIndicator())),
|
||||||
|
error: (e, _) => Text('加载失败',
|
||||||
|
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)),
|
||||||
|
data: (shop) {
|
||||||
|
if (_loaded?.code != shop.code || _loaded?.name != shop.name) {
|
||||||
|
_fill(shop);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_field('门店名称', DsInput(controller: _nameCtrl, enabled: canEdit)),
|
||||||
|
_field(
|
||||||
|
'门店编号(不可改)',
|
||||||
|
DsInput(
|
||||||
|
controller: TextEditingController(text: shop.code),
|
||||||
|
enabled: false)),
|
||||||
|
_field('联系电话', DsInput(controller: _phoneCtrl, enabled: canEdit)),
|
||||||
|
_field(
|
||||||
|
'微信',
|
||||||
|
DsInput(
|
||||||
|
controller: _wechatCtrl,
|
||||||
|
enabled: canEdit,
|
||||||
|
hintText: '微信号 / 客服微信')),
|
||||||
|
_field(
|
||||||
|
'门店地址',
|
||||||
|
DsInput(
|
||||||
|
controller: _addressCtrl,
|
||||||
|
enabled: canEdit,
|
||||||
|
hintText: '省 / 市 / 详细地址')),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
// 原型底排:取消 ghost(1) + 保存 primary(2)
|
||||||
|
Row(children: [
|
||||||
|
Expanded(
|
||||||
|
child: DsButton(canEdit ? '取消' : '关闭',
|
||||||
|
onPressed: () => Navigator.of(context).pop()),
|
||||||
|
),
|
||||||
|
if (canEdit) ...[
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: DsButton(_saving ? '保存中…' : '保存',
|
||||||
|
variant: DsBtnVariant.primary,
|
||||||
|
onPressed: _saving ? null : _save),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 原型 .m-field:label(fs-sm muted) 上、间距 6、下距 14。
|
||||||
|
Widget _field(String label, Widget input) {
|
||||||
|
final t = context.tokens;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
input,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 面板 1:门店信息(内联表单)──────────────────────────────
|
// ── 面板 1:门店信息(内联表单)──────────────────────────────
|
||||||
class _StoreInfoPanel extends ConsumerStatefulWidget {
|
class _StoreInfoPanel extends ConsumerStatefulWidget {
|
||||||
const _StoreInfoPanel();
|
const _StoreInfoPanel();
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// head + KPI 4 卡(无图标)+ toolbar(搜索 + 角色筛选)+ 表格(头像/角色徽章/
|
// head + KPI 4 卡(无图标)+ toolbar(搜索 + 角色筛选)+ 表格(头像/角色徽章/
|
||||||
// 启停)+ 新增/编辑弹窗(角色 2×2 单选卡 + 状态开关 + 重置密码)。
|
// 启停)+ 新增/编辑弹窗(角色 2×2 单选卡 + 状态开关 + 重置密码)。
|
||||||
// 角色口径按后端四级:superadmin/admin/operator/readonly(原型三处不一致已拉平)。
|
// 角色口径按后端四级:superadmin/admin/operator/readonly(原型三处不一致已拉平)。
|
||||||
|
// 窄屏(原型 m-users.html):隐藏页头/KPI/toolbar,m-section + 头像卡片流 +
|
||||||
|
// FAB 新增;点卡开详情 sheet(drow + 重置密码/启停/编辑),表单走 sheet 形态。
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
@@ -18,7 +20,8 @@ import '../../widgets/ds/ds_atoms.dart';
|
|||||||
import '../../widgets/ds/ds_kpi.dart';
|
import '../../widgets/ds/ds_kpi.dart';
|
||||||
import '../../widgets/ds/ds_menu.dart';
|
import '../../widgets/ds/ds_menu.dart';
|
||||||
import '../../widgets/ds/ds_table.dart';
|
import '../../widgets/ds/ds_table.dart';
|
||||||
import '../../widgets/mobile_list_card.dart';
|
import '../../widgets/ds/m_sheet.dart';
|
||||||
|
import '../../widgets/ds/status_icon_map.dart';
|
||||||
import '../../widgets/write_guard.dart';
|
import '../../widgets/write_guard.dart';
|
||||||
import '../../core/theme/app_fonts.dart';
|
import '../../core/theme/app_fonts.dart';
|
||||||
import '../../widgets/ds/ds_toast.dart';
|
import '../../widgets/ds/ds_toast.dart';
|
||||||
@@ -108,6 +111,8 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
data: (users) {
|
data: (users) {
|
||||||
|
// ── 窄屏(原型 m-users.html):m-section + 头像卡片流 + FAB 新增 ──
|
||||||
|
if (mobile) return _mobileBody(t, users);
|
||||||
final kw = _searchCtrl.text.trim().toLowerCase();
|
final kw = _searchCtrl.text.trim().toLowerCase();
|
||||||
final filtered = users.where((u) {
|
final filtered = users.where((u) {
|
||||||
if (_roleFilter != '全部角色' && u.roleLabel != _roleFilter) {
|
if (_roleFilter != '全部角色' && u.roleLabel != _roleFilter) {
|
||||||
@@ -171,7 +176,6 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
|||||||
pagerInfoText: '共 ${filtered.length} 名成员',
|
pagerInfoText: '共 ${filtered.length} 名成员',
|
||||||
emptyText: '没有匹配的成员',
|
emptyText: '没有匹配的成员',
|
||||||
toolbar: _toolbar(mobile),
|
toolbar: _toolbar(mobile),
|
||||||
mobileCards: filtered.map(_userCard).toList(),
|
|
||||||
columns: const [
|
columns: const [
|
||||||
DsColumn('user', '用户'),
|
DsColumn('user', '用户'),
|
||||||
DsColumn('role', '角色'),
|
DsColumn('role', '角色'),
|
||||||
@@ -350,19 +354,260 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _userCard(AppUser u) {
|
// ── 窄屏(原型 m-users.html)────────────────────────────────
|
||||||
return MobileListCard(
|
Widget _mobileBody(dynamic t, List<AppUser> users) {
|
||||||
title: Text(u.realName?.isNotEmpty == true ? u.realName! : u.username),
|
return Scaffold(
|
||||||
subtitle: Text(u.username),
|
backgroundColor: t.bg,
|
||||||
trailing: userRoleBadge(u.role),
|
body: ListView(
|
||||||
fields: [
|
padding: const EdgeInsets.all(14),
|
||||||
MobileCardField('状态', u.isActive ? '启用' : '停用'),
|
children: [
|
||||||
MobileCardField('最近登录', _fmtLast(u.lastLoginAt)),
|
// .m-section
|
||||||
],
|
Padding(
|
||||||
onTap: () => _openUser(u),
|
padding: const EdgeInsets.fromLTRB(2, 2, 2, 8),
|
||||||
|
child: Text('成员 · 共 ${users.length}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppDims.fsSm,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: .4,
|
||||||
|
color: t.muted)),
|
||||||
|
),
|
||||||
|
if (users.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||||
|
child: Text('暂无成员',
|
||||||
|
style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
|
||||||
|
),
|
||||||
|
for (final u in users) _mUserCard(t, u),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// 原型 .m-fab:52px primary 圆形悬浮新增(不走 M3 FAB 主题壳)
|
||||||
|
floatingActionButton: WriteGuard(
|
||||||
|
child: Material(
|
||||||
|
color: t.primary,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _openUser(),
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
child: Icon(LucideIcons.plus, size: 24, color: t.onPrimary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 角色徽章(图标变体,原型 badge.bi:管理员=shield / 普通=user / 只读=eye)。
|
||||||
|
DsBadge _roleBadgeIc(String role) {
|
||||||
|
// 图标词表按原型三级(icons.js BADGE_ICON 登记词),四级角色归并映射
|
||||||
|
final protoWord = switch (role) {
|
||||||
|
'superadmin' || 'admin' => '管理员',
|
||||||
|
'readonly' => '只读',
|
||||||
|
_ => '普通',
|
||||||
|
};
|
||||||
|
final tone = switch (role) {
|
||||||
|
'superadmin' => DsBadgeTone.danger,
|
||||||
|
'admin' => DsBadgeTone.info,
|
||||||
|
'operator' => DsBadgeTone.ok,
|
||||||
|
'readonly' => DsBadgeTone.warn,
|
||||||
|
_ => DsBadgeTone.muted,
|
||||||
|
};
|
||||||
|
final label = switch (role) {
|
||||||
|
'superadmin' => '超级管理员',
|
||||||
|
'admin' => '管理员',
|
||||||
|
'operator' => '操作员',
|
||||||
|
'readonly' => '只读',
|
||||||
|
_ => role,
|
||||||
|
};
|
||||||
|
return DsBadge(label, tone: tone, icon: statusIcon(protoWord));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 原型 .m-card:40 圆头像(.uav) + 姓名 + 账号(mono) + 角色徽章。
|
||||||
|
Widget _mUserCard(dynamic t, AppUser u) {
|
||||||
|
final name = u.realName?.isNotEmpty == true ? u.realName! : u.username;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: Material(
|
||||||
|
color: t.surface,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
side: BorderSide(color: t.border),
|
||||||
|
borderRadius: BorderRadius.circular(AppDims.rLg),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _openUserSheet(u),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||||||
|
child: Row(children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration:
|
||||||
|
BoxDecoration(color: t.brand50, shape: BoxShape.circle),
|
||||||
|
child: Text(name.characters.first,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppDims.fsBody,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: t.primary)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppDims.fsBody,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: t.heading)),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(u.username,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppDims.fsXs,
|
||||||
|
color: t.muted,
|
||||||
|
fontFamily: AppFonts.mono,
|
||||||
|
fontFamilyFallback: AppFonts.monoFallback)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_roleBadgeIc(u.role),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 成员详情 sheet(原型 openU:drow 键值 + 重置密码/启停/编辑)。
|
||||||
|
void _openUserSheet(AppUser u) {
|
||||||
|
final name = u.realName?.isNotEmpty == true ? u.realName! : u.username;
|
||||||
|
showMSheet<void>(
|
||||||
|
context,
|
||||||
|
title: name,
|
||||||
|
builder: (ctx) {
|
||||||
|
final t = ctx.tokens;
|
||||||
|
Widget drow(String label, Widget value, {bool last = false}) =>
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 11),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: last
|
||||||
|
? null
|
||||||
|
: Border(bottom: BorderSide(color: t.borderSubtle)),
|
||||||
|
),
|
||||||
|
child: Row(children: [
|
||||||
|
Text(label,
|
||||||
|
style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
|
||||||
|
const Spacer(),
|
||||||
|
value,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
drow(
|
||||||
|
'账号',
|
||||||
|
Text(u.username,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppDims.fsBody,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontFamily: AppFonts.mono,
|
||||||
|
fontFamilyFallback: AppFonts.monoFallback,
|
||||||
|
color: t.text))),
|
||||||
|
drow('角色', _roleBadgeIc(u.role)),
|
||||||
|
drow(
|
||||||
|
'状态',
|
||||||
|
DsBadge(u.isActive ? '启用' : '停用',
|
||||||
|
tone: u.isActive ? DsBadgeTone.ok : DsBadgeTone.muted,
|
||||||
|
icon: statusIcon(u.isActive ? '启用' : '停用'))),
|
||||||
|
drow(
|
||||||
|
'最近登录',
|
||||||
|
Text(_fmtLast(u.lastLoginAt),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: AppDims.fsBody,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: t.text)),
|
||||||
|
last: true),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
WriteGuard(
|
||||||
|
child: Row(children: [
|
||||||
|
Expanded(
|
||||||
|
child: DsButton('重置密码', onPressed: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
_promptResetPassword(u);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: DsButton(u.isActive ? '停用' : '启用', onPressed: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
_toggleActive(u);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: DsButton('编辑', variant: DsBtnVariant.primary,
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
_openUser(u);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置密码(详情 sheet 入口;提示框与表单内「重置密码」同口径)。
|
||||||
|
Future<void> _promptResetPassword(AppUser u) async {
|
||||||
|
final ctrl = TextEditingController();
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text('重置密码 · ${u.username}'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: ctx.dialogWidth(320),
|
||||||
|
child: DsField('新密码',
|
||||||
|
input: TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(hintText: '至少 8 位'),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
DsButton('取消', onPressed: () => Navigator.of(ctx).pop(false)),
|
||||||
|
DsButton('重置',
|
||||||
|
variant: DsBtnVariant.primary,
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true || !mounted) return;
|
||||||
|
if (ctrl.text.length < 8) {
|
||||||
|
showDsToast(context, '密码至少 8 位');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ref.read(userListProvider.notifier).resetPassword(u.id, ctrl.text);
|
||||||
|
if (mounted) {
|
||||||
|
showDsToast(context, '密码已重置 ✓', bg: context.tokens.success);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
showDsToast(context, '重置失败:$e', bg: context.tokens.danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _toggleActive(AppUser u) async {
|
Future<void> _toggleActive(AppUser u) async {
|
||||||
try {
|
try {
|
||||||
await ref
|
await ref
|
||||||
@@ -376,6 +621,19 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _openUser([AppUser? user]) {
|
void _openUser([AppUser? user]) {
|
||||||
|
// 窄屏:表单走底部 sheet(原型 m-users FAB → 表单弹层)
|
||||||
|
if (context.isMobile) {
|
||||||
|
showMSheet<void>(
|
||||||
|
context,
|
||||||
|
title: user == null ? '新增用户' : '编辑用户',
|
||||||
|
builder: (_) => _UserFormDialog(
|
||||||
|
user: user,
|
||||||
|
sheet: true,
|
||||||
|
onSaved: () => ref.read(userListProvider.notifier).reload(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
showAppDialog(
|
showAppDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => _UserFormDialog(
|
builder: (ctx) => _UserFormDialog(
|
||||||
@@ -387,10 +645,12 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 新增/编辑用户弹窗(原型 #ov:mgrid 2 列 + 角色 2×2 rcard + 状态 switch)──
|
// ── 新增/编辑用户弹窗(原型 #ov:mgrid 2 列 + 角色 2×2 rcard + 状态 switch)──
|
||||||
|
// [sheet]=true 时以 sheet 内容形态渲染(无 Dialog 壳、标题由 showMSheet 提供)。
|
||||||
class _UserFormDialog extends ConsumerStatefulWidget {
|
class _UserFormDialog extends ConsumerStatefulWidget {
|
||||||
final AppUser? user;
|
final AppUser? user;
|
||||||
final VoidCallback onSaved;
|
final VoidCallback onSaved;
|
||||||
const _UserFormDialog({this.user, required this.onSaved});
|
final bool sheet;
|
||||||
|
const _UserFormDialog({this.user, required this.onSaved, this.sheet = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<_UserFormDialog> createState() => _UserFormDialogState();
|
ConsumerState<_UserFormDialog> createState() => _UserFormDialogState();
|
||||||
@@ -507,118 +767,120 @@ class _UserFormDialogState extends ConsumerState<_UserFormDialog> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.tokens;
|
final t = context.tokens;
|
||||||
final isEdit = widget.user != null;
|
final isEdit = widget.user != null;
|
||||||
|
final form = Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// sheet 形态标题由 showMSheet 头部提供
|
||||||
|
if (!widget.sheet) ...[
|
||||||
|
Text(isEdit ? '编辑用户' : '新增用户',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
],
|
||||||
|
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Expanded(
|
||||||
|
child: DsField('姓名',
|
||||||
|
required: true,
|
||||||
|
input: TextFormField(
|
||||||
|
controller: _nameCtrl,
|
||||||
|
decoration: const InputDecoration(hintText: '例如 李采购'),
|
||||||
|
validator: (v) =>
|
||||||
|
(v == null || v.trim().isEmpty) ? '不能为空' : null,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: DsField('登录账号',
|
||||||
|
required: true,
|
||||||
|
input: TextFormField(
|
||||||
|
controller: _userCtrl,
|
||||||
|
decoration: const InputDecoration(hintText: '字母 / 数字'),
|
||||||
|
validator: (v) =>
|
||||||
|
(v == null || v.trim().isEmpty) ? '不能为空' : null,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
if (!isEdit) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
DsField('初始密码',
|
||||||
|
required: true,
|
||||||
|
input: TextFormField(
|
||||||
|
controller: _passCtrl,
|
||||||
|
obscureText: true,
|
||||||
|
decoration:
|
||||||
|
const InputDecoration(hintText: '至少 8 位,成员首次登录可改'),
|
||||||
|
validator: (v) =>
|
||||||
|
(v == null || v.length < 8) ? '至少 8 位' : null,
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('角色',
|
||||||
|
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// 原型 .roles 2×2 单选卡
|
||||||
|
Column(children: [
|
||||||
|
for (var row = 0; row < 2; row++)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: row == 0 ? 10 : 0),
|
||||||
|
child: Row(children: [
|
||||||
|
for (var col = 0; col < 2; col++) ...[
|
||||||
|
if (col > 0) const SizedBox(width: 10),
|
||||||
|
Expanded(child: _roleCard(_roles[row * 2 + col])),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(children: [
|
||||||
|
Text('账号状态',
|
||||||
|
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Switch(
|
||||||
|
value: _active,
|
||||||
|
onChanged: (v) => setState(() => _active = v),
|
||||||
|
),
|
||||||
|
Text(_active ? '启用' : '停用',
|
||||||
|
style: TextStyle(fontSize: AppDims.fsBody, color: t.text)),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(children: [
|
||||||
|
if (isEdit)
|
||||||
|
TextButton(
|
||||||
|
onPressed: _resetPassword,
|
||||||
|
child: const Text('重置密码'),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _saving ? null : _save,
|
||||||
|
child: _saving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white)) // ds-ignore: primary 底白字加载圈
|
||||||
|
: const Text('保存'),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (widget.sheet) return form;
|
||||||
return Dialog(
|
return Dialog(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: context.dialogWidth(520),
|
width: context.dialogWidth(520),
|
||||||
padding: const EdgeInsets.all(22),
|
padding: const EdgeInsets.all(22),
|
||||||
child: Form(
|
child: form,
|
||||||
key: _formKey,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(isEdit ? '编辑用户' : '新增用户',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18, fontWeight: FontWeight.w600)),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Expanded(
|
|
||||||
child: DsField('姓名',
|
|
||||||
required: true,
|
|
||||||
input: TextFormField(
|
|
||||||
controller: _nameCtrl,
|
|
||||||
decoration: const InputDecoration(hintText: '例如 李采购'),
|
|
||||||
validator: (v) =>
|
|
||||||
(v == null || v.trim().isEmpty) ? '不能为空' : null,
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(
|
|
||||||
child: DsField('登录账号',
|
|
||||||
required: true,
|
|
||||||
input: TextFormField(
|
|
||||||
controller: _userCtrl,
|
|
||||||
decoration:
|
|
||||||
const InputDecoration(hintText: '字母 / 数字'),
|
|
||||||
validator: (v) =>
|
|
||||||
(v == null || v.trim().isEmpty) ? '不能为空' : null,
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
if (!isEdit) ...[
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
DsField('初始密码',
|
|
||||||
required: true,
|
|
||||||
input: TextFormField(
|
|
||||||
controller: _passCtrl,
|
|
||||||
obscureText: true,
|
|
||||||
decoration:
|
|
||||||
const InputDecoration(hintText: '至少 8 位,成员首次登录可改'),
|
|
||||||
validator: (v) =>
|
|
||||||
(v == null || v.length < 8) ? '至少 8 位' : null,
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text('角色',
|
|
||||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
// 原型 .roles 2×2 单选卡
|
|
||||||
Column(children: [
|
|
||||||
for (var row = 0; row < 2; row++)
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.only(bottom: row == 0 ? 10 : 0),
|
|
||||||
child: Row(children: [
|
|
||||||
for (var col = 0; col < 2; col++) ...[
|
|
||||||
if (col > 0) const SizedBox(width: 10),
|
|
||||||
Expanded(child: _roleCard(_roles[row * 2 + col])),
|
|
||||||
],
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(children: [
|
|
||||||
Text('账号状态',
|
|
||||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Switch(
|
|
||||||
value: _active,
|
|
||||||
onChanged: (v) => setState(() => _active = v),
|
|
||||||
),
|
|
||||||
Text(_active ? '启用' : '停用',
|
|
||||||
style:
|
|
||||||
TextStyle(fontSize: AppDims.fsBody, color: t.text)),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
Row(children: [
|
|
||||||
if (isEdit)
|
|
||||||
TextButton(
|
|
||||||
onPressed: _resetPassword,
|
|
||||||
child: const Text('重置密码'),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: const Text('取消'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: _saving ? null : _save,
|
|
||||||
child: _saving
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color:
|
|
||||||
Colors.white)) // ds-ignore: primary 底白字加载圈
|
|
||||||
: const Text('保存'),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import 'package:jiu_client/providers/changelog_provider.dart';
|
|||||||
import 'package:jiu_client/providers/license_provider.dart';
|
import 'package:jiu_client/providers/license_provider.dart';
|
||||||
import 'package:jiu_client/screens/about/about_screen.dart';
|
import 'package:jiu_client/screens/about/about_screen.dart';
|
||||||
|
|
||||||
import '../support/golden_harness.dart';
|
|
||||||
import '../support/shell_harness.dart';
|
import '../support/shell_harness.dart';
|
||||||
|
|
||||||
/// design-distill 阶段4:关于我们 golden × 三主题(回归闸 + 保真基准)。
|
/// design-distill 阶段4:关于我们 golden × 三主题(回归闸 + 保真基准)。
|
||||||
/// Hero/产品信息/授权信息/更新日志 数据照抄原型 screens/about.html
|
/// Hero/产品信息/授权信息/更新日志 数据照抄原型 screens/about.html
|
||||||
/// (授权 2027-03-15 · CHANGELOG 4 条,默认 2 条展示):
|
/// (授权 2027-03-15 · CHANGELOG 4 条,默认 2 条展示):
|
||||||
/// node tools/fidelity.mjs about
|
/// node tools/fidelity.mjs about
|
||||||
|
/// 窄屏 hero+hub 形态另见 about_mobile_golden_test.dart。
|
||||||
/// 更新基准:flutter test --update-goldens test/golden/about_golden_test.dart
|
/// 更新基准:flutter test --update-goldens test/golden/about_golden_test.dart
|
||||||
|
|
||||||
class _FakeLicense extends LicenseNotifier {
|
class _FakeLicense extends LicenseNotifier {
|
||||||
@@ -104,13 +104,4 @@ void main() {
|
|||||||
overrides: _overrides,
|
overrides: _overrides,
|
||||||
logical: const Size(1280, 900),
|
logical: const Size(1280, 900),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 移动:窄屏单列
|
|
||||||
goldenAcrossThemes(
|
|
||||||
'about 整屏(移动)',
|
|
||||||
goldenPrefix: 'about_mobile',
|
|
||||||
child: () => const Scaffold(body: AboutScreen()),
|
|
||||||
overrides: _overrides,
|
|
||||||
logical: const Size(390, 1600),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:jiu_client/core/utils/clock.dart';
|
||||||
|
import 'package:jiu_client/providers/changelog_provider.dart';
|
||||||
|
import 'package:jiu_client/screens/about/about_screen.dart';
|
||||||
|
|
||||||
|
import '../support/golden_harness.dart';
|
||||||
|
|
||||||
|
/// 关于我们窄屏 golden × 三主题(390×844 @2x):品牌 hero(图标+名+版本+
|
||||||
|
/// 检查更新+反馈双按钮)+ hub 链接组 + 产品信息卡 + 更新日志时间线 + 页脚,
|
||||||
|
/// 授权信息不显示(已迁 /me/license)——对齐原型 m-about.html。
|
||||||
|
/// 更新基准:flutter test --update-goldens test/golden/about_mobile_golden_test.dart
|
||||||
|
|
||||||
|
const _changelog = [
|
||||||
|
ChangelogEntry(version: '1.0.72', date: '2026-06-22', sections: [
|
||||||
|
(
|
||||||
|
type: 'feat',
|
||||||
|
items: [
|
||||||
|
'用户管理新增四级角色:超级管理员 / 管理员 / 操作员 / 只读',
|
||||||
|
'商品公开页编辑器:图片、品鉴笔记、适饮建议可视化编辑并实时预览',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
(
|
||||||
|
type: 'impr',
|
||||||
|
items: [
|
||||||
|
'顶栏 / 侧栏 / 状态栏统一为单一来源,全局一处生效',
|
||||||
|
'金额卡片改用「万」单位展示,更符合阅读习惯',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
ChangelogEntry(version: '1.0.66', date: '2026-06-18', sections: [
|
||||||
|
(
|
||||||
|
type: 'feat',
|
||||||
|
items: [
|
||||||
|
'入库 / 出库列表新增搜索框,支持单号、商品、往来单位关键字检索',
|
||||||
|
'入库 / 出库 / 库存页新增一键刷新',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
(
|
||||||
|
type: 'fix',
|
||||||
|
items: [
|
||||||
|
'入库审核库存沿用明细真实快照名',
|
||||||
|
'库存列表过滤软删商品,修复历史导入占位顶替',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<Override> _overrides() => [
|
||||||
|
changelogProvider.overrideWith((ref) async => _changelog),
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
PackageInfo.setMockInitialValues(
|
||||||
|
appName: 'jiu',
|
||||||
|
packageName: 'com.yanmei.jiu',
|
||||||
|
version: '1.0.72',
|
||||||
|
buildNumber: '72',
|
||||||
|
buildSignature: '',
|
||||||
|
);
|
||||||
|
appClock = () => DateTime(2026, 7, 13, 9, 30, 15);
|
||||||
|
|
||||||
|
goldenAcrossThemes(
|
||||||
|
'about 整屏(移动)',
|
||||||
|
goldenPrefix: 'about_mobile',
|
||||||
|
child: () => const Scaffold(body: AboutScreen()),
|
||||||
|
overrides: _overrides,
|
||||||
|
logical: const Size(390, 844),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,13 +9,13 @@ import 'package:jiu_client/providers/session_provider.dart';
|
|||||||
import 'package:jiu_client/providers/shop_provider.dart';
|
import 'package:jiu_client/providers/shop_provider.dart';
|
||||||
import 'package:jiu_client/screens/devices/device_management_screen.dart';
|
import 'package:jiu_client/screens/devices/device_management_screen.dart';
|
||||||
|
|
||||||
import '../support/golden_harness.dart';
|
|
||||||
import '../support/shell_harness.dart';
|
import '../support/shell_harness.dart';
|
||||||
|
|
||||||
/// design-distill 阶段4:设备管理 golden × 三主题(回归闸 + 保真基准)。
|
/// design-distill 阶段4:设备管理 golden × 三主题(回归闸 + 保真基准)。
|
||||||
/// 桌面版挂进真 AppShell,数据照抄原型 screens/devices.html 的
|
/// 桌面版挂进真 AppShell,数据照抄原型 screens/devices.html 的
|
||||||
/// SESSIONS(4 行会话)与 DEVICES(5 台外设 → 店级 custom_fields.peripherals):
|
/// SESSIONS(4 行会话)与 DEVICES(5 台外设 → 店级 custom_fields.peripherals):
|
||||||
/// node tools/fidelity.mjs devices
|
/// node tools/fidelity.mjs devices
|
||||||
|
/// 窄屏卡片流另见 device_management_mobile_golden_test.dart。
|
||||||
/// 更新基准:flutter test --update-goldens test/golden/device_management_golden_test.dart
|
/// 更新基准:flutter test --update-goldens test/golden/device_management_golden_test.dart
|
||||||
|
|
||||||
// 原型 SESSIONS 4 行(时间 MM-DD HH:mm)
|
// 原型 SESSIONS 4 行(时间 MM-DD HH:mm)
|
||||||
@@ -155,13 +155,4 @@ void main() {
|
|||||||
overrides: _overrides,
|
overrides: _overrides,
|
||||||
logical: const Size(1280, 900),
|
logical: const Size(1280, 900),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 移动:窄屏卡片流
|
|
||||||
goldenAcrossThemes(
|
|
||||||
'devices 整屏(移动)',
|
|
||||||
goldenPrefix: 'device_management_mobile',
|
|
||||||
child: () => const Scaffold(body: DeviceManagementScreen()),
|
|
||||||
overrides: _overrides,
|
|
||||||
logical: const Size(390, 1400),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:jiu_client/core/auth/auth_state.dart';
|
||||||
|
import 'package:jiu_client/models/session.dart';
|
||||||
|
import 'package:jiu_client/models/shop.dart';
|
||||||
|
import 'package:jiu_client/providers/session_provider.dart';
|
||||||
|
import 'package:jiu_client/providers/shop_provider.dart';
|
||||||
|
import 'package:jiu_client/screens/devices/device_management_screen.dart';
|
||||||
|
|
||||||
|
import '../support/golden_harness.dart';
|
||||||
|
|
||||||
|
/// 设备管理窄屏 golden × 三主题(390×844 @2x):m-section 两区块——
|
||||||
|
/// 登录设备会话卡流(用户·平台+本机 / 设备·IP / 在线徽章+时间 / ›)
|
||||||
|
/// + 外设卡流(名称 / 型号·类型·连接 / 状态徽章 / 最近活动脚注),
|
||||||
|
/// 「+ 添加设备」在区块标题行右侧,打印模板不渲染——对齐原型 m-devices.html。
|
||||||
|
/// 更新基准:flutter test --update-goldens test/golden/device_management_mobile_golden_test.dart
|
||||||
|
|
||||||
|
final _sessions = [
|
||||||
|
DeviceSession(
|
||||||
|
id: 1,
|
||||||
|
userId: 1,
|
||||||
|
username: '王经理',
|
||||||
|
realName: '王经理',
|
||||||
|
platform: 'macos',
|
||||||
|
platformClass: 'desktop',
|
||||||
|
deviceName: 'macos',
|
||||||
|
ip: '192.168.1.20',
|
||||||
|
createdAt: DateTime(2026, 6, 20, 11, 23),
|
||||||
|
lastSeenAt: DateTime(2026, 6, 22, 17, 40),
|
||||||
|
online: true,
|
||||||
|
isCurrent: true),
|
||||||
|
DeviceSession(
|
||||||
|
id: 2,
|
||||||
|
userId: 1,
|
||||||
|
username: '王经理',
|
||||||
|
realName: '王经理',
|
||||||
|
platform: 'windows',
|
||||||
|
platformClass: 'desktop',
|
||||||
|
deviceName: 'windows',
|
||||||
|
ip: '192.168.1.21',
|
||||||
|
createdAt: DateTime(2026, 6, 20, 10, 47),
|
||||||
|
lastSeenAt: DateTime(2026, 6, 22, 17, 40),
|
||||||
|
online: true,
|
||||||
|
isCurrent: false),
|
||||||
|
DeviceSession(
|
||||||
|
id: 3,
|
||||||
|
userId: 2,
|
||||||
|
username: '李采购',
|
||||||
|
realName: '李采购',
|
||||||
|
platform: 'windows',
|
||||||
|
platformClass: 'desktop',
|
||||||
|
deviceName: 'windows',
|
||||||
|
ip: '192.168.1.35',
|
||||||
|
createdAt: DateTime(2026, 6, 20, 9, 13),
|
||||||
|
lastSeenAt: DateTime(2026, 6, 22, 17, 11),
|
||||||
|
online: false,
|
||||||
|
isCurrent: false),
|
||||||
|
];
|
||||||
|
|
||||||
|
const _peripherals = [
|
||||||
|
{
|
||||||
|
'name': '前台标签打印机',
|
||||||
|
'kind': '标签打印机',
|
||||||
|
'status': '在线',
|
||||||
|
'model': 'Zebra GK888t',
|
||||||
|
'conn': 'USB',
|
||||||
|
'last': '2 分钟前'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '收银台小票机',
|
||||||
|
'kind': '小票打印机',
|
||||||
|
'status': '在线',
|
||||||
|
'model': '佳博 GP-58MB',
|
||||||
|
'conn': '蓝牙',
|
||||||
|
'last': '刚刚'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '入库扫码枪',
|
||||||
|
'kind': '扫码枪',
|
||||||
|
'status': '离线',
|
||||||
|
'model': 'Honeywell 1900',
|
||||||
|
'conn': 'USB',
|
||||||
|
'last': '5 分钟前'
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const _shopWithPeripherals = ShopInfo(
|
||||||
|
id: 1,
|
||||||
|
code: 'DSJH-001',
|
||||||
|
name: '鼎晟酒行',
|
||||||
|
address: '浙江省杭州市拱墅区运河路 168 号 1 幢',
|
||||||
|
phone: '0571-8888 6666',
|
||||||
|
managerName: '王经理',
|
||||||
|
wechatId: 'dingsheng-wine',
|
||||||
|
customFields: {'peripherals': _peripherals},
|
||||||
|
);
|
||||||
|
|
||||||
|
class _FakeSessionNotifier extends SessionListNotifier {
|
||||||
|
@override
|
||||||
|
Future<List<DeviceSession>> build() async => _sessions;
|
||||||
|
@override
|
||||||
|
Future<void> reload() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Override> _overrides() => [
|
||||||
|
sessionListProvider.overrideWith(() => _FakeSessionNotifier()),
|
||||||
|
shopInfoProvider.overrideWith((ref) => _shopWithPeripherals),
|
||||||
|
isReadonlyProvider.overrideWithValue(false),
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
|
||||||
|
goldenAcrossThemes(
|
||||||
|
'devices 卡片流 整屏(移动)',
|
||||||
|
goldenPrefix: 'device_management_mobile',
|
||||||
|
child: () => const Scaffold(body: DeviceManagementScreen()),
|
||||||
|
overrides: _overrides,
|
||||||
|
logical: const Size(390, 844),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 378 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 379 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 408 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 68 KiB |
@@ -7,13 +7,13 @@ import 'package:jiu_client/models/shop.dart';
|
|||||||
import 'package:jiu_client/providers/shop_provider.dart';
|
import 'package:jiu_client/providers/shop_provider.dart';
|
||||||
import 'package:jiu_client/screens/settings/settings_screen.dart';
|
import 'package:jiu_client/screens/settings/settings_screen.dart';
|
||||||
|
|
||||||
import '../support/golden_harness.dart';
|
|
||||||
import '../support/shell_harness.dart';
|
import '../support/shell_harness.dart';
|
||||||
|
|
||||||
/// design-distill 阶段4:系统设置 golden × 三主题(回归闸 + 保真基准)。
|
/// design-distill 阶段4:系统设置 golden × 三主题(回归闸 + 保真基准)。
|
||||||
/// 桌面版挂进真 AppShell,默认「门店信息」面板,门店数据照抄原型
|
/// 桌面版挂进真 AppShell,默认「门店信息」面板,门店数据照抄原型
|
||||||
/// screens/settings.html(鼎晟酒行 / DSJH-001 / 拱墅区运河路…):
|
/// screens/settings.html(鼎晟酒行 / DSJH-001 / 拱墅区运河路…):
|
||||||
/// node tools/fidelity.mjs settings
|
/// node tools/fidelity.mjs settings
|
||||||
|
/// 窄屏 hub 形态另见 settings_mobile_golden_test.dart。
|
||||||
/// 更新基准:flutter test --update-goldens test/golden/settings_golden_test.dart
|
/// 更新基准:flutter test --update-goldens test/golden/settings_golden_test.dart
|
||||||
|
|
||||||
const _shop = ShopInfo(
|
const _shop = ShopInfo(
|
||||||
@@ -44,13 +44,4 @@ void main() {
|
|||||||
overrides: _overrides,
|
overrides: _overrides,
|
||||||
logical: const Size(1280, 900),
|
logical: const Size(1280, 900),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 移动:seg 横滚 + 单列面板
|
|
||||||
goldenAcrossThemes(
|
|
||||||
'settings 门店信息 整屏(移动)',
|
|
||||||
goldenPrefix: 'settings_mobile',
|
|
||||||
child: () => const Scaffold(body: SettingsScreen()),
|
|
||||||
overrides: _overrides,
|
|
||||||
logical: const Size(390, 1200),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:jiu_client/core/auth/auth_state.dart';
|
||||||
|
import 'package:jiu_client/models/shop.dart';
|
||||||
|
import 'package:jiu_client/providers/shop_provider.dart';
|
||||||
|
import 'package:jiu_client/screens/settings/settings_screen.dart';
|
||||||
|
|
||||||
|
import '../support/golden_harness.dart';
|
||||||
|
|
||||||
|
/// 系统设置窄屏 golden × 三主题(390×844 @2x):hub 形态三组
|
||||||
|
/// 门店(门店信息/用户与角色/编号规则)+ 偏好(主题外观/默认仓库/设备管理)
|
||||||
|
/// + 数据(数据备份/关于与版本)——对齐原型 m-settings.html。
|
||||||
|
/// 更新基准:flutter test --update-goldens test/golden/settings_mobile_golden_test.dart
|
||||||
|
|
||||||
|
const _shop = ShopInfo(
|
||||||
|
id: 1,
|
||||||
|
code: 'DSJH-001',
|
||||||
|
name: '鼎晟酒行',
|
||||||
|
address: '浙江省杭州市拱墅区运河路 168 号 1 幢',
|
||||||
|
phone: '0571-8888 6666',
|
||||||
|
managerName: '王经理',
|
||||||
|
wechatId: 'dingsheng-wine',
|
||||||
|
);
|
||||||
|
|
||||||
|
List<Override> _overrides() => [
|
||||||
|
shopInfoProvider.overrideWith((ref) => _shop),
|
||||||
|
isReadonlyProvider.overrideWithValue(false),
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
|
||||||
|
goldenAcrossThemes(
|
||||||
|
'settings hub 整屏(移动)',
|
||||||
|
goldenPrefix: 'settings_mobile',
|
||||||
|
child: () => const Scaffold(body: SettingsScreen()),
|
||||||
|
overrides: _overrides,
|
||||||
|
logical: const Size(390, 844),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,13 +8,13 @@ import 'package:jiu_client/models/user.dart';
|
|||||||
import 'package:jiu_client/providers/user_provider.dart';
|
import 'package:jiu_client/providers/user_provider.dart';
|
||||||
import 'package:jiu_client/screens/settings/users_screen.dart';
|
import 'package:jiu_client/screens/settings/users_screen.dart';
|
||||||
|
|
||||||
import '../support/golden_harness.dart';
|
|
||||||
import '../support/shell_harness.dart';
|
import '../support/shell_harness.dart';
|
||||||
|
|
||||||
/// design-distill 阶段4:用户管理独立页 golden × 三主题(回归闸 + 保真基准)。
|
/// design-distill 阶段4:用户管理独立页 golden × 三主题(回归闸 + 保真基准)。
|
||||||
/// 数据照抄原型 screens/users.html 的 USERS(5 人,四级角色),
|
/// 数据照抄原型 screens/users.html 的 USERS(5 人,四级角色),
|
||||||
/// 相对时间(今天/昨天)用可注入时钟 appClock 冻结在 2026-07-13 09:30:
|
/// 相对时间(今天/昨天)用可注入时钟 appClock 冻结在 2026-07-13 09:30:
|
||||||
/// node tools/fidelity.mjs users
|
/// node tools/fidelity.mjs users
|
||||||
|
/// 窄屏卡片流另见 users_mobile_golden_test.dart。
|
||||||
/// 更新基准:flutter test --update-goldens test/golden/users_golden_test.dart
|
/// 更新基准:flutter test --update-goldens test/golden/users_golden_test.dart
|
||||||
|
|
||||||
// 原型 USERS 5 行;最近登录 今天09:12 / 今天08:50 / 昨天18:40 / 06-19 / 06-10
|
// 原型 USERS 5 行;最近登录 今天09:12 / 今天08:50 / 昨天18:40 / 06-19 / 06-10
|
||||||
@@ -83,13 +83,4 @@ void main() {
|
|||||||
overrides: _overrides,
|
overrides: _overrides,
|
||||||
logical: const Size(1280, 900),
|
logical: const Size(1280, 900),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 移动:窄屏卡片流
|
|
||||||
goldenAcrossThemes(
|
|
||||||
'users 整屏(移动)',
|
|
||||||
goldenPrefix: 'users_mobile',
|
|
||||||
child: () => const Scaffold(body: UsersScreen()),
|
|
||||||
overrides: _overrides,
|
|
||||||
logical: const Size(390, 1400),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:jiu_client/core/auth/auth_state.dart';
|
||||||
|
import 'package:jiu_client/core/utils/clock.dart';
|
||||||
|
import 'package:jiu_client/models/user.dart';
|
||||||
|
import 'package:jiu_client/providers/user_provider.dart';
|
||||||
|
import 'package:jiu_client/screens/settings/users_screen.dart';
|
||||||
|
|
||||||
|
import '../support/golden_harness.dart';
|
||||||
|
|
||||||
|
/// 用户管理窄屏 golden × 三主题(390×844 @2x):m-section「成员 · 共 N」+
|
||||||
|
/// 头像卡片流(40 圆标 + 姓名 + 账号 + 角色图标徽章)+ FAB 新增——
|
||||||
|
/// 对齐原型 m-users.html。数据与桌面 golden 同源(5 人四级角色)。
|
||||||
|
/// 更新基准:flutter test --update-goldens test/golden/users_mobile_golden_test.dart
|
||||||
|
|
||||||
|
const _users = [
|
||||||
|
AppUser(
|
||||||
|
id: 1,
|
||||||
|
username: 'wang',
|
||||||
|
realName: '王经理',
|
||||||
|
role: 'superadmin',
|
||||||
|
isActive: true,
|
||||||
|
lastLoginAt: '2026-07-13T09:12:00'),
|
||||||
|
AppUser(
|
||||||
|
id: 2,
|
||||||
|
username: 'zhang',
|
||||||
|
realName: '张主管',
|
||||||
|
role: 'admin',
|
||||||
|
isActive: true,
|
||||||
|
lastLoginAt: '2026-07-13T08:50:00'),
|
||||||
|
AppUser(
|
||||||
|
id: 3,
|
||||||
|
username: 'li',
|
||||||
|
realName: '李采购',
|
||||||
|
role: 'operator',
|
||||||
|
isActive: true,
|
||||||
|
lastLoginAt: '2026-07-12T18:40:00'),
|
||||||
|
AppUser(
|
||||||
|
id: 4,
|
||||||
|
username: 'zhao',
|
||||||
|
realName: '赵会计',
|
||||||
|
role: 'readonly',
|
||||||
|
isActive: true,
|
||||||
|
lastLoginAt: '2026-06-19T10:05:00'),
|
||||||
|
AppUser(
|
||||||
|
id: 5,
|
||||||
|
username: 'qian',
|
||||||
|
realName: '钱仓管',
|
||||||
|
role: 'operator',
|
||||||
|
isActive: false,
|
||||||
|
lastLoginAt: '2026-06-10T14:22:00'),
|
||||||
|
];
|
||||||
|
|
||||||
|
class _FakeUserList extends UserListNotifier {
|
||||||
|
@override
|
||||||
|
Future<List<AppUser>> build() async => _users;
|
||||||
|
@override
|
||||||
|
Future<void> reload() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Override> _overrides() => [
|
||||||
|
userListProvider.overrideWith(() => _FakeUserList()),
|
||||||
|
isReadonlyProvider.overrideWithValue(false),
|
||||||
|
];
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
// 冻结相对日期基准(详情 sheet 的最近登录)
|
||||||
|
appClock = () => DateTime(2026, 7, 13, 9, 30, 15);
|
||||||
|
|
||||||
|
goldenAcrossThemes(
|
||||||
|
'users 卡片流 整屏(移动)',
|
||||||
|
goldenPrefix: 'users_mobile',
|
||||||
|
child: () => const UsersScreen(),
|
||||||
|
overrides: _overrides,
|
||||||
|
logical: const Size(390, 844),
|
||||||
|
);
|
||||||
|
}
|
||||||