diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index 1906216..cbba110 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../screens/auth/login_screen.dart'; +import '../../screens/auth/register_screen.dart'; import '../../screens/shell/app_shell.dart'; import '../../screens/stock_in/stock_in_list_screen.dart'; import '../../screens/stock_in/stock_in_form_screen.dart'; @@ -41,6 +42,7 @@ class _RouterNotifier extends ChangeNotifier { final isLoggedIn = authState.isLoggedIn; final loc = state.matchedLocation; final isPublicRoute = loc == '/login' || + loc == '/register' || loc.startsWith('/product/') || loc.startsWith('/shop/'); final result = !authState.initialized @@ -93,6 +95,10 @@ final appRouterProvider = Provider((ref) { path: '/login', builder: (context, state) => const LoginScreen(), ), + GoRoute( + path: '/register', + builder: (context, state) => const RegisterScreen(), + ), // 各栏目拆为独立分支:StatefulShellRoute.indexedStack 让每个分支的 Navigator // 及其页面 State 常驻,跨栏目切换不再销毁上一页(半填表单/内部 tab/滚动位置保活)。 // 分支顺序必须与 AppShell._navItems 一致(navigationShell.currentIndex 据此高亮)。 diff --git a/client/lib/repositories/auth_repository.dart b/client/lib/repositories/auth_repository.dart index 034bc1d..872910f 100644 --- a/client/lib/repositories/auth_repository.dart +++ b/client/lib/repositories/auth_repository.dart @@ -68,6 +68,38 @@ class AuthRepository { } } + /// POST /api/v1/register —— 自助注册新门店(公开,无需认证)。 + /// Response: { data: { shop_code, shop_name, username } } + static Future<({String shopCode, String shopName, String username})> + register({ + required String shopName, + required String phone, + required String address, + required String managerName, + required String username, + required String password, + }) async { + try { + final resp = await PublicApiClient.post('/register', data: { + 'shop_name': shopName, + 'phone': phone, + 'address': address, + 'manager_name': managerName, + 'username': username, + 'password': password, + }); + final data = resp.data['data'] as Map; + return ( + shopCode: data['shop_code'] as String? ?? '', + shopName: data['shop_name'] as String? ?? shopName, + username: data['username'] as String? ?? username, + ); + } on DioException catch (e) { + final msg = e.response?.data?['error'] as String?; + throw AuthException(msg ?? _networkError(e)); + } + } + static String _networkError(DioException e) { switch (e.type) { case DioExceptionType.connectionTimeout: diff --git a/client/lib/screens/auth/login_screen.dart b/client/lib/screens/auth/login_screen.dart index b424f8b..39caf3d 100644 --- a/client/lib/screens/auth/login_screen.dart +++ b/client/lib/screens/auth/login_screen.dart @@ -633,12 +633,9 @@ class _LoginScreenState extends ConsumerState { ), const SizedBox(height: 12), TextButton( - onPressed: () => launchUrl( - Uri.parse('${AppConfig.publicBaseUrl}/register/'), - mode: LaunchMode.externalApplication, - ), + onPressed: () => context.go('/register'), child: Text( - '还没有门店账号?前往官网注册', + '还没有门店账号?立即注册', style: TextStyle( fontSize: 15, color: context.tokens.muted, diff --git a/client/lib/screens/auth/register_screen.dart b/client/lib/screens/auth/register_screen.dart new file mode 100644 index 0000000..974ad76 --- /dev/null +++ b/client/lib/screens/auth/register_screen.dart @@ -0,0 +1,312 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/responsive/responsive.dart'; +import '../../core/theme/context_tokens.dart'; +import '../../core/theme/app_dims.g.dart'; +import '../../repositories/auth_repository.dart'; + +/// 应用内自助注册新门店(公开页)。提交后创建门店 + 管理员账号, +/// 返回门店编号引导去登录。对应后端 POST /api/v1/register。 +class RegisterScreen extends ConsumerStatefulWidget { + const RegisterScreen({super.key}); + + @override + ConsumerState createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _shopName = TextEditingController(); + final _phone = TextEditingController(); + final _address = TextEditingController(); + final _manager = TextEditingController(); + final _username = TextEditingController(); + final _password = TextEditingController(); + final _confirm = TextEditingController(); + bool _agree = false; + 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 _submit() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + if (!_agree) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请先阅读并同意服务条款与隐私政策')), + ); + 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( + 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: 13)), + const SizedBox(height: 4), + SelectableText(r.shopCode, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 18, + fontWeight: FontWeight.w700)), + ], + ), + actions: [ + ElevatedButton( + onPressed: () { + Navigator.of(ctx).pop(); + context.go('/login'); + }, + child: const Text('去登录'), + ), + ], + ), + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString().replaceFirst('AuthException: ', '')), + backgroundColor: context.tokens.danger), + ); + } + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + final t = context.tokens; + final wide = !context.isMobile; + return Scaffold( + backgroundColor: t.page, + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 920, maxHeight: 720), + child: Card( + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppDims.rXl)), + child: Row( + children: [ + if (wide) SizedBox(width: 320, child: _brandPanel(t)), + Expanded(child: _formPanel(t)), + ], + ), + ), + ), + ), + ); + } + + Widget _brandPanel(t) { + Widget step(int n, String title, String sub) => Padding( + padding: const EdgeInsets.only(bottom: 18), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: t.onPrimary.withValues(alpha: 0.18), + shape: BoxShape.circle), + child: Text('$n', + style: TextStyle( + color: t.onPrimary, + fontSize: 12, + fontWeight: FontWeight.w700)), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: TextStyle( + color: t.onPrimary, + fontSize: AppDims.fsBody, + fontWeight: FontWeight.w600)), + Text(sub, + style: TextStyle( + color: t.onPrimary.withValues(alpha: 0.72), + fontSize: AppDims.fsSm)), + ], + ), + ), + ], + ), + ); + return Container( + color: t.primary, + padding: const EdgeInsets.all(28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.local_bar, color: t.onPrimary, size: 32), + const SizedBox(height: 16), + Text('开通门店', + style: TextStyle( + color: t.onPrimary, + fontSize: AppDims.fsH1, + fontWeight: FontWeight.w700)), + const SizedBox(height: 8), + Text('几分钟创建你的酒水进销存门店,立即开始入库出库', + style: TextStyle( + color: t.onPrimary.withValues(alpha: 0.78), + fontSize: AppDims.fsSm, + height: 1.6)), + const SizedBox(height: 28), + step(1, '填写门店信息', '店名、编号与联系人'), + step(2, '创建管理员账号', '用于登录与管理成员'), + step(3, '开始使用', '入库录单即自动建档'), + ], + ), + ); + } + + Widget _formPanel(t) { + return SingleChildScrollView( + padding: const EdgeInsets.all(28), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('注册新门店', + style: TextStyle( + fontSize: AppDims.fsH2, fontWeight: FontWeight.w700)), + const Spacer(), + TextButton( + onPressed: () => context.go('/login'), + child: const Text('已有门店?直接登录 →'), + ), + ], + ), + const SizedBox(height: 16), + _field(_shopName, '门店名称 *', '例如 鼎昌酒行', required: true), + _row2( + _field(_phone, '联系电话 *', '11 位手机号', + required: true, keyboard: TextInputType.phone), + _field(_address, '门店地址 *', '省 / 市 / 区 详细地址', required: true), + ), + _field(_manager, '管理员姓名 *', '例如 王经理', required: true), + _row2( + _field(_username, '登录账号 *', '字母 / 数字', required: true), + const SizedBox.shrink(), + ), + _row2( + _field(_password, '登录密码 *', '至少 6 位', + required: true, obscure: _obscure, minLen: 6), + _field(_confirm, '确认密码 *', '再次输入', + required: true, + obscure: _obscure, + validator: (v) => + v != _password.text ? '两次密码不一致' : null), + ), + CheckboxListTile( + value: _agree, + onChanged: (v) => setState(() => _agree = v ?? false), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + dense: true, + title: Text('我已阅读并同意 服务条款 与 隐私政策', + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _submitting ? null : _submit, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14)), + child: _submitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Text('创建门店并进入'), + ), + ), + ], + ), + ), + ); + } + + Widget _row2(Widget a, Widget b) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: a), + const SizedBox(width: 12), + Expanded(child: b), + ], + ); + + Widget _field( + TextEditingController ctrl, + String label, + String hint, { + bool required = false, + bool obscure = false, + int? minLen, + TextInputType? keyboard, + String? Function(String?)? validator, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: TextFormField( + controller: ctrl, + obscureText: obscure, + keyboardType: keyboard, + decoration: InputDecoration( + labelText: label, + hintText: hint, + isDense: true, + border: const OutlineInputBorder(), + ), + validator: validator ?? + (v) { + if (required && (v == null || v.trim().isEmpty)) return '必填'; + if (minLen != null && (v?.length ?? 0) < minLen) { + return '至少 $minLen 位'; + } + return null; + }, + ), + ); + } +} diff --git a/client/test/golden/goldens/register_a.png b/client/test/golden/goldens/register_a.png new file mode 100644 index 0000000..3c69839 Binary files /dev/null and b/client/test/golden/goldens/register_a.png differ diff --git a/client/test/golden/goldens/register_b.png b/client/test/golden/goldens/register_b.png new file mode 100644 index 0000000..5ab82fc Binary files /dev/null and b/client/test/golden/goldens/register_b.png differ diff --git a/client/test/golden/goldens/register_c.png b/client/test/golden/goldens/register_c.png new file mode 100644 index 0000000..049fc01 Binary files /dev/null and b/client/test/golden/goldens/register_c.png differ diff --git a/client/test/golden/register_golden_test.dart b/client/test/golden/register_golden_test.dart new file mode 100644 index 0000000..b3fdddd --- /dev/null +++ b/client/test/golden/register_golden_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:jiu_client/screens/auth/register_screen.dart'; + +import '../support/golden_harness.dart'; + +/// design-distill 阶段4:应用内注册新门店 golden × 三主题(品牌面板 + 表单)。 +/// 更新基准:flutter test --update-goldens test/golden/register_golden_test.dart +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + goldenAcrossThemes( + 'register 注册新门店', + goldenPrefix: 'register', + child: () => const RegisterScreen(), + logical: const Size(1100, 760), + ); +}