Files
jiu/client/lib/screens/auth/register_screen.dart
T
wangjia 6238b86dcb feat(client): 登录/注册页照原型重建,ds 真相源组件族统一全部屏
- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服
  pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验;
  登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段,
  已记 CONTRACT,screens.mjs 留存根)
- ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/
  DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态
- 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、
  h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、
  BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast)
- 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后
  失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览
- 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/
  SelectProductDialog/tabStateProvider
- golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打;
  修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 09:58:14 +08:00

418 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// screens/auth/register_screen.dart — 自助注册新门店,照原型 register.html 1:1 重建。
// 结构:--page 底居中 .auth 两栏卡片(左 .brand 380pxlogo+开通门店+tag+三步时间线;
// 右 .formh1/lead + grid2 门店字段 + 分隔线 + grid2 管理员字段 + 协议 + 主按钮 + foot)
// + 右上角 A/B/C 主题切换器。窄屏折叠单列、隐藏步骤时间线。
// 与原型的差异(记 design/CONTRACT.md):
// · 无「门店编号」「授权兑换券」字段——后端 RegisterInput 暂不支持(编号自动分配、
// 兑换券注册后在 系统设置·授权兑换券 激活,与 step3 文案一致);
// · 门店地址为必填(后端 binding:required;原型为选填);密码下限 6 位(后端 min=6)。
// 校验为原型式:提交时逐项 toast。对应后端 POST /api/v1/register。
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_chrome.g.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/theme/app_fonts.dart';
import '../../core/theme/context_tokens.dart';
import '../../repositories/auth_repository.dart';
import '../../widgets/brand_mark.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_toast.dart';
import 'auth_shared.dart';
class RegisterScreen extends ConsumerStatefulWidget {
const RegisterScreen({super.key});
@override
ConsumerState<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
final _shopName = TextEditingController();
final _phone = TextEditingController();
final _address = TextEditingController();
final _manager = TextEditingController();
final _username = TextEditingController();
final _password = TextEditingController();
final _confirm = TextEditingController();
bool _agree = true;
bool _obscure = true;
bool _submitting = false;
@override
void dispose() {
for (final c in [
_shopName,
_phone,
_address,
_manager,
_username,
_password,
_confirm
]) {
c.dispose();
}
super.dispose();
}
Future<void> _submit() async {
// 原型 submitReg 式校验:逐项 toast(无内联错误文案)
if (_shopName.text.trim().isEmpty) {
showDsToast(context, '请填写门店名称');
return;
}
if (_phone.text.trim().isEmpty) {
showDsToast(context, '请填写联系电话');
return;
}
if (_address.text.trim().isEmpty) {
showDsToast(context, '请填写门店地址');
return;
}
if (_manager.text.trim().isEmpty || _username.text.trim().isEmpty) {
showDsToast(context, '请填写管理员姓名与登录账号');
return;
}
if (_password.text.length < 6) {
showDsToast(context, '密码至少 6 位');
return;
}
if (_password.text != _confirm.text) {
showDsToast(context, '两次密码不一致');
return;
}
if (!_agree) {
showDsToast(context, '请先同意服务条款与隐私政策');
return;
}
setState(() => _submitting = true);
try {
final r = await AuthRepository.register(
shopName: _shopName.text.trim(),
phone: _phone.text.trim(),
address: _address.text.trim(),
managerName: _manager.text.trim(),
username: _username.text.trim(),
password: _password.text,
);
if (!mounted) return;
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('注册成功'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('门店「${r.shopName}」已创建。'),
const SizedBox(height: 12),
Text('门店编号(登录时需用):',
style: TextStyle(
color: context.tokens.muted, fontSize: AppDims.fsBody)),
const SizedBox(height: 4),
SelectableText(r.shopCode,
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: 18,
fontWeight: FontWeight.w700)),
],
),
actions: [
DsButton('去登录', variant: DsBtnVariant.primary, onPressed: () {
Navigator.of(ctx).pop();
context.go('/login');
}),
],
),
);
} catch (e) {
if (mounted) {
showDsToast(context, e.toString().replaceFirst('AuthException: ', ''),
bg: context.tokens.danger);
}
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
return AuthPageScaffold(
maxWidth: 940,
narrowMaxWidth: 480,
brandWidth: 380, // 原型 gridminmax(0,380px) minmax(0,1fr)
brand: _brand(context),
form: _form(context),
);
}
// ── 左栏 .brandlogo + 开通门店 + tag + 三步时间线 ───────────────────────
Widget _brand(BuildContext context) {
final t = context.tokens;
final narrow = context.isMobile;
return AuthBrandPanel(
padding: narrow
? const EdgeInsets.symmetric(horizontal: 32, vertical: 34)
: const EdgeInsets.symmetric(horizontal: 38, vertical: 46),
minHeight: narrow ? null : 560,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const BrandMark(size: 60),
const SizedBox(height: 22),
Text('开通门店',
style: TextStyle(
fontSize: AppDims.fsDisplay,
fontWeight: FontWeight.w800,
letterSpacing: 1,
color: t.sideActiveFg)),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 260),
child: Text('几分钟创建你的酒水进销存门店,立即开始入库出库',
style: TextStyle(
fontSize: AppDims.fsBody, color: t.sideFg, height: 1.7)),
),
if (!narrow) ...[
const SizedBox(height: 36),
const _Step(
n: 1,
done: true,
title: '填写门店信息',
sub: '店名、编号与联系人',
last: false),
const _Step(
n: 2,
done: false,
title: '创建管理员账号',
sub: '用于登录与管理成员',
last: false),
const _Step(
n: 3,
done: false,
title: '激活授权',
sub: '输入兑换券,或先试用',
last: true),
],
],
),
);
}
// ── 右栏 .form ───────────────────────────────────────────────────────────
Widget _form(BuildContext context) {
final t = context.tokens;
final narrow = context.isMobile;
Widget input(TextEditingController c, String hint,
{bool obscure = false, TextInputType? keyboard}) =>
DsInput(
controller: c,
hintText: hint,
obscureText: obscure,
keyboardType: keyboard);
// grid2:宽屏两列 gap14,窄屏单列
Widget row2(Widget a, Widget b) => narrow
? Column(children: [a, const SizedBox(height: 14), b])
: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Expanded(child: a),
const SizedBox(width: 14),
Expanded(child: b),
]);
return Padding(
padding: narrow
? const EdgeInsets.symmetric(horizontal: 30, vertical: 32)
: const EdgeInsets.symmetric(horizontal: 42, vertical: 44),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('注册新门店',
style: TextStyle(
fontSize: AppDims.fsH1,
fontWeight: FontWeight.w700,
color: t.heading)),
const SizedBox(height: 6),
Text.rich(
TextSpan(
text: '已有门店账号?',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted),
children: [
TextSpan(
text: '直接登录 →',
style: TextStyle(
color: t.primary, fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer()
..onTap = () => context.go('/login'),
),
]),
),
const SizedBox(height: 24),
// 门店信息
DsField('门店名称', required: true, input: input(_shopName, '例如 鼎晟酒行')),
const SizedBox(height: 14),
row2(
DsField('联系电话',
required: true,
input: input(_phone, '11 位手机号', keyboard: TextInputType.phone)),
DsField('门店地址',
required: true, input: input(_address, '省 / 市 / 区 详细地址')),
),
const SizedBox(height: 6),
Container(height: 1, color: t.borderSubtle),
const SizedBox(height: 16),
// 管理员账号
row2(
DsField('管理员姓名', required: true, input: input(_manager, '例如 王经理')),
DsField('登录账号', required: true, input: input(_username, '字母 / 数字')),
),
const SizedBox(height: 14),
row2(
DsField('登录密码',
required: true,
input: DsInput(
controller: _password,
hintText: '至少 6 位',
obscureText: _obscure,
suffix: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => setState(() => _obscure = !_obscure),
child: Icon(
_obscure ? LucideIcons.eyeOff : LucideIcons.eye,
size: 16,
color: t.muted),
),
),
)),
DsField('确认密码',
required: true,
input: input(_confirm, '再次输入', obscure: _obscure)),
),
// .agree:协议勾选(margin 6 0 20
const SizedBox(height: 16),
DsCheck(
value: _agree,
alignTop: true,
onChanged: (v) => setState(() => _agree = v),
label: Text.rich(
TextSpan(
text: '我已阅读并同意 ',
style: TextStyle(fontSize: AppDims.fsSm, color: t.text),
children: [
TextSpan(
text: '服务条款',
style: TextStyle(color: t.primary),
recognizer: TapGestureRecognizer()
..onTap = () => showDsToast(context, '服务条款请见官网底部链接')),
const TextSpan(text: ''),
TextSpan(
text: '隐私政策',
style: TextStyle(color: t.primary),
recognizer: TapGestureRecognizer()
..onTap = () => showDsToast(context, '隐私政策请见官网底部链接')),
]),
),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: DsButton(_submitting ? '创建中…' : '创建门店并进入',
variant: DsBtnVariant.primary,
large: true,
onPressed: _submitting ? null : _submit),
),
const SizedBox(height: 20),
Center(
child: Text('创建即代表你将成为该门店的管理员',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
),
],
),
);
}
}
/// 原型 .step29px 序号圆(done=side-active-fg 底反色)+ 标题/副文案 +
/// 节点间 2px hero-glass 连接线(padding-bottom 22)。
class _Step extends StatelessWidget {
final int n;
final bool done;
final String title;
final String sub;
final bool last;
const _Step(
{required this.n,
required this.done,
required this.title,
required this.sub,
required this.last});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 29,
child: Column(children: [
Container(
width: 29,
height: 29,
alignment: Alignment.center,
decoration: BoxDecoration(
color: done ? t.sideActiveFg : AppChrome.heroGlass,
shape: BoxShape.circle,
),
child: Text('$n',
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w700,
color: done ? t.sideActiveBg : t.sideActiveFg)),
),
if (!last)
Expanded(
child: Center(
child: Container(width: 2, color: AppChrome.heroGlass)),
),
]),
),
const SizedBox(width: 13),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: last ? 0 : 22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
color: t.sideActiveFg,
height: 1.45)),
Text(sub,
style: TextStyle(
fontSize: AppDims.fsSm,
color: t.sideFg,
height: 1.45)),
],
),
),
),
],
),
);
}
}