已完成用户APP首期功能开发

交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
czl231
2026-09-13 00:57:32 +08:00
parent a0a4bb1218
commit 3ef33b531d
793 changed files with 40959 additions and 1204 deletions

View File

@@ -0,0 +1,132 @@
// 功能描述当前密码验证、新密码二次确认与服务端成功后重新登录版本1.0.0。
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../app/dependencies.dart';
import '../../../data/repositories/client_repository.dart';
import '../../../data/services/api_client.dart';
class LoginPasswordPage extends StatefulWidget {
const LoginPasswordPage({required this.repository, required this.session, super.key});
final ClientRepository repository;
final UserSession session;
@override
State<LoginPasswordPage> createState() => _LoginPasswordPageState();
}
/// 密码仅保留在当前表单内存;失败不清空草稿,成功后清空并退出本机登录。
class _LoginPasswordPageState extends State<LoginPasswordPage> {
final _form = GlobalKey<FormState>();
final _current = TextEditingController(),
_next = TextEditingController(),
_repeat = TextEditingController();
final _visible = [false, false, false];
bool _busy = false;
String? _error;
@override
void dispose() {
_current.dispose();
_next.dispose();
_repeat.dispose();
super.dispose();
}
Future<void> _save() async {
if (_busy || !(_form.currentState?.validate() ?? false)) return;
setState(() {
_busy = true;
_error = null;
});
try {
await widget.repository.changeLoginPassword(_current.text, _next.text);
if (!mounted) return;
_current.clear();
_next.clear();
_repeat.clear();
await widget.session.logout();
} on SessionExpiredException {
return;
} catch (error) {
if (mounted) setState(() => _error = error is ApiException ? error.message : '修改失败,请重试');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Widget _input(
String label,
TextEditingController controller,
int index,
String? Function(String?) validator,
) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: TextFormField(
key: ValueKey('password-$index'),
controller: controller,
enabled: !_busy,
obscureText: !_visible[index],
autocorrect: false,
enableSuggestions: false,
keyboardType: TextInputType.visiblePassword,
validator: validator,
decoration: InputDecoration(
labelText: label,
suffixIcon: IconButton(
tooltip: _visible[index] ? '隐藏$label' : '显示$label',
onPressed: _busy ? null : () => setState(() => _visible[index] = !_visible[index]),
icon: Icon(_visible[index] ? Icons.visibility_off_outlined : Icons.visibility_outlined),
),
),
),
);
@override
Widget build(BuildContext context) => PopScope(
canPop: !_busy,
child: Scaffold(
appBar: AppBar(
title: const Text('登录密码'),
centerTitle: true,
leading: IconButton(
tooltip: '返回',
icon: const Icon(Icons.arrow_back),
onPressed: _busy
? null
: () => context.canPop() ? context.pop() : context.go('/settings'),
),
),
body: Form(
key: _form,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_input('当前密码', _current, 0, (value) => (value ?? '').isEmpty ? '请输入当前密码' : null),
_input('新密码', _next, 1, (value) {
final password = value ?? '';
if (password.runes.length < 6) return '新密码至少6个字符';
if (utf8.encode(password).length > 72) return '新密码过长,请缩短后重试';
if (password == _current.text) return '新密码不能与当前密码相同';
return null;
}),
_input('确认新密码', _repeat, 2, (value) => value != _next.text ? '两次新密码不一致' : null),
if (_error != null)
Semantics(
liveRegion: true,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
),
FilledButton(
onPressed: _busy ? null : _save,
child: Text(_busy ? '正在修改…' : '确认修改并重新登录'),
),
],
),
),
),
);
}

View File

@@ -0,0 +1,251 @@
// 功能描述支付密码首次设置、旧密码修改及验证码找回版本1.0.0。
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import '../../../data/repositories/client_repository.dart';
import '../../../data/services/api_client.dart';
import '../../../domain/models/client_models.dart';
import '../../core/async_content.dart';
/// 只操作当前用户的钱包密码,余额和提现数据不允许由页面写入。
class PaymentPasswordPage extends StatefulWidget {
const PaymentPasswordPage({required this.repository, super.key});
final ClientRepository repository;
@override
State<PaymentPasswordPage> createState() => _PaymentPasswordPageState();
}
class _PaymentPasswordPageState extends State<PaymentPasswordPage> {
final _form = GlobalKey<FormState>();
final _old = TextEditingController(),
_next = TextEditingController(),
_repeat = TextEditingController(),
_code = TextEditingController();
bool _busy = false, _useCode = false;
bool? _hasPassword;
WalletSummary? _loaded;
PaymentCodeRequest? _request;
String? _error, _notice;
Timer? _timer;
DateTime? _resendAt;
int get _remaining =>
_resendAt == null ? 0 : _resendAt!.difference(DateTime.now()).inSeconds.clamp(0, 3600);
@override
void dispose() {
_timer?.cancel();
_old.dispose();
_next.dispose();
_repeat.dispose();
_code.dispose();
super.dispose();
}
/// 服务端状态缺失时进入可重试错误态,不能自行选择首次设置流程。
Future<WalletSummary> _load() async {
if (_busy && _loaded != null) return _loaded!;
final wallet = await widget.repository.wallet();
if (!mounted) return wallet;
if (_busy && _loaded != null) return _loaded!;
if (wallet.paymentPasswordSet == null) throw const ApiException(1714, '支付密码状态读取失败');
_loaded = wallet;
if (_hasPassword != wallet.paymentPasswordSet) {
_hasPassword = wallet.paymentPasswordSet;
_useCode = !_hasPassword!;
_clearCode();
_old.clear();
}
return wallet;
}
void _clearCode() {
_request = null;
_code.clear();
}
/// 不把Mock的验证码记录误报为短信已发送。
Future<void> _sendCode() async {
if (_busy || _remaining > 0) return;
setState(() {
_busy = true;
_error = null;
_notice = null;
});
try {
final request = await widget.repository.requestPaymentPasswordCode(
resetting: _hasPassword == true,
);
if (!mounted) return;
_request = request;
_code.clear();
_resendAt = DateTime.now().add(Duration(seconds: request.retryAfter + 1));
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted || _remaining == 0) timer.cancel();
if (mounted) setState(() {});
});
setState(
() =>
_notice = request.delivered ? '验证码已发送至 ${request.maskedPhone}' : '验证码请求已建立,但当前环境未发送短信',
);
} catch (error) {
if (mounted) setState(() => _error = error is ApiException ? error.message : '验证码请求失败,请重试');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _save() async {
if (_busy || !(_form.currentState?.validate() ?? false)) return;
if (_useCode && _request == null) {
setState(() => _error = '请先获取验证码');
return;
}
setState(() {
_busy = true;
_error = null;
_notice = null;
});
try {
final cleared = await widget.repository.setPaymentPassword(
newPassword: _next.text,
currentPassword: _useCode ? null : _old.text,
requestIdentity: _useCode ? _request!.identity : null,
code: _useCode ? _code.text : null,
);
if (!mounted) return;
_old.clear();
_next.clear();
_repeat.clear();
_clearCode();
_timer?.cancel();
_resendAt = null;
setState(() {
_hasPassword = true;
_useCode = false;
_notice = cleared ? '支付密码已更新' : '支付密码已更新,安全锁定可能仍在生效,请稍后再试';
});
} on SessionExpiredException {
return;
} catch (error) {
if (mounted) setState(() => _error = error is ApiException ? error.message : '修改结果未确认,请稍后重试');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Widget _pin(
String label,
TextEditingController controller,
String key, {
bool confirmation = false,
}) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: TextFormField(
key: ValueKey(key),
controller: controller,
enabled: !_busy,
obscureText: true,
enableSuggestions: false,
autocorrect: false,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
decoration: InputDecoration(labelText: label),
validator: (value) {
if (!RegExp(r'^\d{6}$').hasMatch(value ?? '')) return '请输入6位数字支付密码';
if (confirmation && value != _next.text) return '两次支付密码不一致';
return null;
},
),
);
@override
Widget build(BuildContext context) => PopScope(
canPop: !_busy,
child: Scaffold(
appBar: AppBar(
title: const Text('支付密码'),
centerTitle: true,
leading: IconButton(
tooltip: '返回',
icon: const Icon(Icons.arrow_back),
onPressed: _busy
? null
: () => context.canPop() ? context.pop() : context.go('/settings'),
),
),
body: AsyncContent<WalletSummary>(
load: _load,
builder: (context, wallet) => Form(
key: _form,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
_hasPassword == true ? (_useCode ? '找回支付密码' : '修改支付密码') : '设置支付密码',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 20),
if (_useCode) ...[
TextFormField(
key: const ValueKey('payment-code'),
controller: _code,
enabled: !_busy,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(8),
],
decoration: const InputDecoration(labelText: '手机验证码'),
validator: (v) => (v ?? '').isEmpty ? '请输入手机验证码' : null,
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: _busy || _remaining > 0 ? null : _sendCode,
child: Text(_remaining > 0 ? '$_remaining秒后重新获取' : '获取验证码'),
),
),
const SizedBox(height: 12),
] else
_pin('当前支付密码', _old, 'payment-old'),
_pin('新支付密码6位数字', _next, 'payment-next'),
_pin('确认支付密码', _repeat, 'payment-repeat', confirmation: true),
if (_error != null || _notice != null)
Semantics(
liveRegion: true,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
_error ?? _notice!,
style: TextStyle(
color: _error == null ? null : Theme.of(context).colorScheme.error,
),
),
),
),
FilledButton(onPressed: _busy ? null : _save, child: Text(_busy ? '正在处理…' : '确认')),
if (_hasPassword == true)
TextButton(
onPressed: _busy
? null
: () => setState(() {
_useCode = !_useCode;
_clearCode();
_old.clear();
_error = null;
_notice = null;
}),
child: Text(_useCode ? '使用当前支付密码修改' : '忘记支付密码?'),
),
],
),
),
),
),
);
}

View File

@@ -0,0 +1,277 @@
// 功能描述按图31组织账户设置、协议、版本、图片缓存和退出操作版本1.0.0。
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../app/dependencies.dart';
import '../../../data/repositories/client_repository.dart';
import '../../../data/services/app_settings_service.dart';
import '../../core/feature_entry.dart';
import '../auth/login_support.dart';
class SettingsPage extends StatefulWidget {
const SettingsPage({required this.repository, required this.session, this.service, super.key});
final ClientRepository repository;
final UserSession session;
final AppSettingsService? service;
@override
State<SettingsPage> createState() => _SettingsPageState();
}
/// 附属资料读取失败不阻塞清缓存或退出,未接入能力保持真实的未知状态。
class _SettingsPageState extends State<SettingsPage> {
late final AppSettingsService _service = widget.service ?? AppSettingsService();
String _phone = '读取中', _version = '读取中';
String? _notice;
bool _busy = false, _phoneFailed = false;
@override
void initState() {
super.initState();
_loadPhone();
_loadVersion();
}
Future<void> _loadPhone() async {
try {
final profile = await widget.repository.profile();
if (mounted) {
setState(() {
_phone = profile.phone.replaceFirstMapped(
RegExp(r'^(\d{3})\d{4}(\d{4})$'),
(m) => '${m[1]}****${m[2]}',
);
_phoneFailed = false;
});
}
} catch (_) {
if (mounted) {
setState(() {
_phone = '读取失败,点击重试';
_phoneFailed = true;
});
}
}
}
Future<void> _loadVersion() async {
try {
final version = await _service.version();
if (mounted) setState(() => _version = version);
} catch (error) {
// 仅诊断非敏感的构建元数据错误,不输出会话或账号资料。
debugPrint('构建版本读取失败:$error');
if (mounted) setState(() => _version = '读取失败,点击重试');
}
}
Future<void> _confirm(bool logout) async {
if (_busy) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(logout ? '退出登录?' : '清除图片缓存?'),
content: Text(logout ? '退出后再次办理业务需要登录。' : '清除可重新加载的图片缓存。账户资料、报修草稿和已下载文件会保留。'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('确认')),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _busy = true);
try {
if (logout) {
await widget.session.logout();
} else {
_service.clearImageCache();
if (mounted) setState(() => _notice = '图片缓存已清除');
}
} catch (_) {
if (mounted) setState(() => _notice = '操作失败,请重试');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Widget _row(IconData icon, String title, {String? value, VoidCallback? action, Color? color}) =>
InkWell(
onTap: _busy ? null : action ?? () => showUnavailableFeature(context, title),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 48),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: LayoutBuilder(
builder: (context, constraints) {
// 窄屏或放大文字时上下排列,避免手机号和设置名称拆成单字。
final stacked =
constraints.maxWidth < 290 || MediaQuery.textScalerOf(context).scale(14) > 16;
return Row(
children: [
Icon(icon, color: color ?? const Color(0xFF0064FF), size: 24),
const SizedBox(width: 10),
Expanded(
flex: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: TextStyle(fontSize: 14, color: color)),
if (stacked && value != null) ...[
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(fontSize: 12, color: Color(0xFF777F8D)),
),
],
],
),
),
if (value != null && !stacked) ...[
const SizedBox(width: 8),
Expanded(
flex: 4,
child: Text(
value,
textAlign: TextAlign.end,
style: const TextStyle(fontSize: 12, color: Color(0xFF777F8D)),
),
),
],
const SizedBox(width: 4),
const Icon(Icons.chevron_right, color: Color(0xFF9CA3AF), size: 20),
],
);
},
),
),
),
);
Widget _group(String title, List<Widget> rows) => Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.fromLTRB(14, 10, 14, 4),
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE5E7EB)),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
color: Color(0xFF777F8D),
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
for (var i = 0; i < rows.length; i++) ...[if (i > 0) const Divider(height: 1), rows[i]],
],
),
);
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('设置'),
centerTitle: true,
leading: IconButton(
tooltip: '返回',
icon: const Icon(Icons.arrow_back),
onPressed: () => context.canPop() ? context.pop() : context.go('/me'),
),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 24),
children: [
_group('账号与安全', [
_row(Icons.verified_user_outlined, '实名认证', value: '暂未开放'),
_row(Icons.lock_outline, '登录密码', action: () => context.push('/settings/password')),
_row(
Icons.lock_outline,
'支付密码',
action: () => context.push('/settings/payment-password'),
),
_row(
Icons.phone_android,
'手机号',
value: _phone,
action: () => _phoneFailed ? _loadPhone() : showUnavailableFeature(context, '更换手机号'),
),
_row(Icons.desktop_windows_outlined, '设备登录管理', value: '暂未开放'),
]),
_group('消息通知', [
// 尚无真实通知通道,不能用“不可关闭”暗示已经开启并正常送达。
_row(Icons.notifications_none, '安全告警通知', value: '暂未开放', color: Colors.red),
_row(Icons.sms_outlined, '订单通知', value: '暂未开放'),
_row(Icons.mail_outline, '服务通知', value: '暂未开放'),
_row(Icons.volume_up_outlined, '声音与振动', value: '暂未开放'),
]),
_group('设备与权限', [
_row(Icons.bluetooth, '蓝牙权限', value: '暂未开放'),
_row(Icons.location_on_outlined, '定位权限', value: '暂未开放'),
_row(Icons.lock_outline, '远程控制二次确认', value: '暂未开放'),
]),
_group('通用', [
_row(
Icons.language,
'语言',
value: '简体中文',
action: () => showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('语言'),
content: const Text('简体中文'),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('确定')),
],
),
),
),
_row(Icons.shield_outlined, '隐私设置', value: '暂未开放'),
_row(
Icons.delete_outline,
'清除缓存',
value: '${(_service.imageCacheBytes / 1024 / 1024).toStringAsFixed(1)}MB 图片缓存',
action: () => _confirm(false),
),
_row(Icons.info_outline, '版本', value: _version, action: _loadVersion),
]),
_group('帮助与协议', [
_row(
Icons.description_outlined,
'用户协议',
action: () => showPublishedAgreements(context, repository: widget.repository),
),
_row(
Icons.privacy_tip_outlined,
'隐私政策',
action: () => showPublishedAgreements(context, repository: widget.repository),
),
_row(Icons.menu_book_outlined, '安全内容', action: () => context.push('/contents')),
_row(Icons.person_off_outlined, '注销账号', value: '暂未开放', color: Colors.red),
]),
if (_notice != null)
Semantics(
liveRegion: true,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(_notice!),
),
),
OutlinedButton(
onPressed: _busy ? null : () => _confirm(true),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
side: const BorderSide(color: Colors.red),
),
child: const Text('退出登录'),
),
const SizedBox(height: 16),
Text(
'当前版本 $_version',
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF777F8D), fontSize: 12),
),
],
),
);
}