diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 42ba259..005abcd 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -22,6 +22,13 @@
+
+
+
+
+
+
+
用于在地图上展示设备位置
NSLocationAlwaysAndWhenInUseUsageDescription
用于在地图上展示设备位置
+ NSCameraUsageDescription
+ 用于扫描设备二维码完成绑定
+ NSPhotoLibraryUsageDescription
+ 用于从相册选取二维码图片完成设备绑定
io.flutter.embedded_views_preview
diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart
index 50601a4..8f4aa2f 100644
--- a/lib/app/router/app_router.dart
+++ b/lib/app/router/app_router.dart
@@ -16,9 +16,20 @@ import '../../features/profile/presentation/profile_tab.dart';
import '../../features/album/presentation/album_page.dart';
import '../../features/alarm/presentation/alarm_page.dart';
import '../../features/contacts/presentation/contacts_page.dart';
+import '../../features/contacts/presentation/contact_detail_page.dart';
+import '../../features/contacts/presentation/contact_edit_page.dart';
+import '../../features/contacts/domain/contact_models.dart';
import '../../features/apps/presentation/apps_page.dart';
+import '../../features/account/presentation/change_mobile_page.dart';
+import '../../features/account/presentation/change_password_page.dart';
+import '../../features/account/presentation/login_devices_page.dart';
+import '../../features/account/presentation/security_page.dart';
+import '../../features/account/presentation/wechat_binding_page.dart';
import '../../features/device/data/device_providers.dart';
import '../../features/device/domain/device_models.dart';
+import '../../features/device/presentation/device_bind_page.dart';
+import '../../features/device/presentation/device_manage_page.dart';
+import '../../features/device/presentation/device_scan_page.dart';
/// 应用路由定义(GoRouter)。
///
@@ -26,6 +37,8 @@ import '../../features/device/domain/device_models.dart';
/// - /login /register 认证流程
/// - /home Shell 路由,承载底部 Tab(首页 / 管理 / 我的)
/// - /album /alarm /contacts /apps 管理页横版菜单进入的子页面(无底部 Tab)
+/// - /device/manage /device/bind /device/scan 设备管理、绑定与扫码
+/// - /security/* 账号与安全(登录设备 / 修改密码 / 更换手机 / 微信绑定)
GoRouter buildAppRouter() {
return GoRouter(
initialLocation: '/splash',
@@ -63,10 +76,60 @@ GoRouter buildAppRouter() {
path: '/contacts',
builder: (context, state) => const ContactsPage(),
),
+ GoRoute(
+ path: '/contacts/detail',
+ builder: (context, state) => ContactDetailPage(
+ contact: state.extra is Contact
+ ? state.extra as Contact
+ : const Contact(id: 0, name: '', phoneNumber: ''),
+ ),
+ ),
+ GoRoute(
+ path: '/contacts/edit',
+ builder: (context, state) => ContactEditPage(
+ contact: state.extra is Contact ? state.extra as Contact : null,
+ ),
+ ),
GoRoute(
path: '/apps',
builder: (context, state) => const AppsPage(),
),
+ GoRoute(
+ path: '/device/manage',
+ builder: (context, state) => const DeviceManagePage(),
+ ),
+ GoRoute(
+ path: '/device/bind',
+ builder: (context, state) => DeviceBindPage(
+ initialSn: state.extra is String ? state.extra as String : null,
+ ),
+ ),
+ GoRoute(
+ path: '/device/scan',
+ builder: (context, state) => const DeviceScanPage(),
+ ),
+ GoRoute(
+ path: '/security',
+ builder: (context, state) => const SecurityPage(),
+ ),
+ GoRoute(
+ path: '/security/login-devices',
+ builder: (context, state) => const LoginDevicesPage(),
+ ),
+ GoRoute(
+ path: '/security/password',
+ builder: (context, state) => const ChangePasswordPage(),
+ ),
+ GoRoute(
+ path: '/security/mobile',
+ builder: (context, state) => ChangeMobilePage(
+ currentMobile: state.extra is String ? state.extra as String : null,
+ ),
+ ),
+ GoRoute(
+ path: '/security/wechat',
+ builder: (context, state) => const WechatBindingPage(),
+ ),
GoRoute(
path: '/map',
builder: (context, state) => _MapRoute(),
@@ -137,6 +200,6 @@ class _ScreenshotsRoute extends ConsumerWidget {
// 越界保护:初始索引落在列表内,否则回退到 0。
final index =
(list.isNotEmpty && initialIndex < list.length) ? initialIndex : 0;
- return ScreenshotPage(screenshots: list, initialIndex: index);
+ return ScreenshotPage(screenshots: list, sn: sn ?? '', initialIndex: index);
}
}
diff --git a/lib/core/widgets/settings_ui.dart b/lib/core/widgets/settings_ui.dart
new file mode 100644
index 0000000..059ee85
--- /dev/null
+++ b/lib/core/widgets/settings_ui.dart
@@ -0,0 +1,322 @@
+import 'package:cupertino_ui/cupertino_ui.dart';
+
+import 'feature_ui.dart';
+
+/// 设置类页面通用组件(设备管理、账号与安全等子页共用)。
+///
+/// 统一「圆角图标块 + 标题/副标题 + 尾部控件」的行样式,以及带标签的输入框、
+/// 主按钮与次按钮,避免各页面重复书写冗长的内联样式。
+
+/// 可点击的设置列表行。
+///
+/// [emoji] + [color] 组成左侧圆角图标块;[trailing] 为空时展示默认的 `›` 箭头。
+class SettingsRow extends StatelessWidget {
+ const SettingsRow({
+ required this.emoji,
+ required this.color,
+ required this.title,
+ this.subtitle,
+ this.value,
+ this.trailing,
+ this.onTap,
+ super.key,
+ });
+
+ final String emoji;
+ final Color color;
+ final String title;
+ final String? subtitle;
+
+ /// 右侧只读文案(如当前手机号、绑定状态)。
+ final String? value;
+
+ /// 自定义尾部控件,优先级高于 [value] 与默认箭头。
+ final Widget? trailing;
+
+ final VoidCallback? onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final row = Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
+ child: Row(
+ children: [
+ Container(
+ width: 36,
+ height: 36,
+ decoration: BoxDecoration(
+ color: color,
+ borderRadius: BorderRadius.circular(11),
+ ),
+ child: Center(child: Text(emoji, style: const TextStyle(fontSize: 17))),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: const TextStyle(
+ fontSize: 14.5,
+ fontWeight: FontWeight.w600,
+ color: AppColors.ink,
+ ),
+ ),
+ if (subtitle != null) ...[
+ const SizedBox(height: 2),
+ Text(
+ subtitle!,
+ style: const TextStyle(fontSize: 12, color: AppColors.sub),
+ ),
+ ],
+ ],
+ ),
+ ),
+ if (trailing != null)
+ trailing!
+ else ...[
+ if (value != null)
+ Text(
+ value!,
+ style: const TextStyle(fontSize: 13, color: AppColors.sub),
+ ),
+ const SizedBox(width: 6),
+ const Text('›', style: TextStyle(fontSize: 16, color: Color(0xFFCBD2DC))),
+ ],
+ ],
+ ),
+ );
+ if (onTap == null) return row;
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: onTap,
+ child: row,
+ );
+ }
+}
+
+/// 带标签的输入框(标签在上、输入框在下的表单样式)。
+class LabeledField extends StatelessWidget {
+ const LabeledField({
+ required this.label,
+ required this.controller,
+ this.placeholder,
+ this.obscureText = false,
+ this.keyboardType,
+ this.maxLength,
+ this.enabled = true,
+ this.suffix,
+ super.key,
+ });
+
+ final String label;
+ final TextEditingController controller;
+ final String? placeholder;
+ final bool obscureText;
+ final TextInputType? keyboardType;
+ final int? maxLength;
+ final bool enabled;
+
+ /// 输入框右侧控件(如「获取验证码」按钮)。
+ final Widget? suffix;
+
+ @override
+ Widget build(BuildContext context) => Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ label,
+ style: const TextStyle(
+ fontSize: 12.5,
+ fontWeight: FontWeight.w600,
+ color: AppColors.sub,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Row(
+ children: [
+ Expanded(
+ child: CupertinoTextField(
+ controller: controller,
+ placeholder: placeholder,
+ obscureText: obscureText,
+ keyboardType: keyboardType,
+ maxLength: maxLength,
+ enabled: enabled,
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
+ style: const TextStyle(fontSize: 15, color: AppColors.ink),
+ placeholderStyle: const TextStyle(fontSize: 14, color: AppColors.sub),
+ decoration: BoxDecoration(
+ color: enabled ? CupertinoColors.white : AppColors.track,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: const Color(0xFFE6EAF0)),
+ ),
+ ),
+ ),
+ if (suffix != null) ...[
+ const SizedBox(width: 10),
+ suffix!,
+ ],
+ ],
+ ),
+ ],
+ ),
+ );
+}
+
+/// 实心主按钮(品牌绿),[loading] 时展示指示器并禁用点击。
+class PrimaryButton extends StatelessWidget {
+ const PrimaryButton({
+ required this.text,
+ this.onPressed,
+ this.loading = false,
+ this.color = AppColors.green,
+ super.key,
+ });
+
+ final String text;
+ final VoidCallback? onPressed;
+ final bool loading;
+ final Color color;
+
+ @override
+ Widget build(BuildContext context) {
+ final disabled = loading || onPressed == null;
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: GestureDetector(
+ onTap: disabled ? null : onPressed,
+ child: Container(
+ height: 48,
+ decoration: BoxDecoration(
+ color: disabled ? color.withValues(alpha: 0.5) : color,
+ borderRadius: BorderRadius.circular(14),
+ ),
+ child: Center(
+ child: loading
+ ? const CupertinoActivityIndicator(color: CupertinoColors.white)
+ : Text(
+ text,
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w700,
+ color: CupertinoColors.white,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// 描边次按钮(用于「扫码绑定」「从相册选取」等辅助操作)。
+class SecondaryButton extends StatelessWidget {
+ const SecondaryButton({
+ required this.text,
+ this.onPressed,
+ this.color = AppColors.green,
+ this.icon,
+ super.key,
+ });
+
+ final String text;
+ final VoidCallback? onPressed;
+ final Color color;
+ final IconData? icon;
+
+ @override
+ Widget build(BuildContext context) => Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: GestureDetector(
+ onTap: onPressed,
+ child: Container(
+ height: 48,
+ decoration: BoxDecoration(
+ color: CupertinoColors.white,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: color, width: 1.2),
+ ),
+ child: Center(
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ if (icon != null) ...[
+ Icon(icon, size: 18, color: color),
+ const SizedBox(width: 8),
+ ],
+ Text(
+ text,
+ style: TextStyle(
+ fontSize: 15.5,
+ fontWeight: FontWeight.w700,
+ color: color,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+}
+
+/// 统一的提示弹窗与确认弹窗工具。
+class AppDialogs {
+ const AppDialogs._();
+
+ /// 单按钮提示弹窗。
+ static Future alert(
+ BuildContext context, {
+ String? title,
+ required String message,
+ required String confirmText,
+ }) =>
+ showCupertinoDialog(
+ context: context,
+ builder: (ctx) => CupertinoAlertDialog(
+ title: title == null ? null : Text(title),
+ content: Text(message),
+ actions: [
+ CupertinoDialogAction(
+ isDefaultAction: true,
+ onPressed: () => Navigator.of(ctx).pop(),
+ child: Text(confirmText),
+ ),
+ ],
+ ),
+ );
+
+ /// 二次确认弹窗,返回用户是否确认。
+ static Future confirm(
+ BuildContext context, {
+ String? title,
+ required String message,
+ required String confirmText,
+ required String cancelText,
+ bool destructive = true,
+ }) async {
+ final result = await showCupertinoDialog(
+ context: context,
+ builder: (ctx) => CupertinoAlertDialog(
+ title: title == null ? null : Text(title),
+ content: Text(message),
+ actions: [
+ CupertinoDialogAction(
+ onPressed: () => Navigator.of(ctx).pop(false),
+ child: Text(cancelText),
+ ),
+ CupertinoDialogAction(
+ isDestructiveAction: destructive,
+ onPressed: () => Navigator.of(ctx).pop(true),
+ child: Text(confirmText),
+ ),
+ ],
+ ),
+ );
+ return result ?? false;
+ }
+}
diff --git a/lib/features/account/data/account_api.dart b/lib/features/account/data/account_api.dart
new file mode 100644
index 0000000..d35b2f5
--- /dev/null
+++ b/lib/features/account/data/account_api.dart
@@ -0,0 +1,113 @@
+import 'package:cupertino_ui/cupertino_ui.dart';
+import 'package:ttstd_family_care/core/network/api_exception.dart';
+import 'package:ttstd_family_care/core/network/dio_client.dart';
+import 'package:ttstd_family_care/features/account/domain/account_models.dart';
+
+/// 账号与安全接口访问层。
+///
+/// 后端接口前缀为 `/api/v1/open/account`(与设备接口所在的 `/api/v1/client` 不同)。
+/// [DioClient] 已统一解析后台返回体的 `data` 字段,业务失败时抛出 [ApiException];
+/// 本层将查询类接口的异常降级为空数据,写操作则把错误提示回传给控制器。
+class AccountApi {
+ const AccountApi();
+
+ /// 登录设备(会话)列表。
+ Future> getLoginDevices() async {
+ try {
+ final data = await DioClient.get('account/login-devices');
+ return _parseList(data, LoginDevice.fromJson);
+ } on ApiException catch (e) {
+ debugPrint('[AccountApi] getLoginDevices failed: ${e.message}');
+ return const [];
+ }
+ }
+
+ /// 将指定登录设备下线。
+ Future revokeLoginDevice(String id) =>
+ _write(() => DioClient.post(
+ 'account/login-devices/revoke',
+ queryParameters: {'id': id},
+ ));
+
+ /// 修改密码。
+ Future changePassword({
+ required String oldPassword,
+ required String newPassword,
+ }) =>
+ _write(() => DioClient.post(
+ 'account/password',
+ data: {'oldPassword': oldPassword, 'newPassword': newPassword},
+ ));
+
+ /// 发送更换手机号验证码到新手机号。
+ Future sendChangeMobileCode(String newMobile) =>
+ _write(() => DioClient.post(
+ 'account/mobile/sms/code',
+ queryParameters: {'mobile': newMobile},
+ ));
+
+ /// 更换绑定手机号。
+ Future changeMobile({
+ required String newMobile,
+ required String code,
+ required String password,
+ }) =>
+ _write(() => DioClient.post(
+ 'account/mobile',
+ data: {
+ 'mobile': newMobile,
+ 'code': code,
+ 'password': password,
+ },
+ ));
+
+ /// 查询微信绑定状态。
+ Future getWechatBinding() async {
+ try {
+ final data = await DioClient.get('account/wechat');
+ if (data is Map) return WechatBinding.fromJson(data);
+ return WechatBinding.unbound;
+ } on ApiException catch (e) {
+ debugPrint('[AccountApi] getWechatBinding failed: ${e.message}');
+ return WechatBinding.unbound;
+ }
+ }
+
+ /// 绑定微信。
+ Future bindWechat(String authCode) => _write(() => DioClient.post(
+ 'account/wechat/bind',
+ data: {'code': authCode},
+ ));
+
+ /// 解绑微信。
+ Future unbindWechat() =>
+ _write(() => DioClient.post('account/wechat/unbind'));
+
+ /// 统一处理写操作:成功返回 null,业务失败返回后台提示文案。
+ Future _write(Future Function() call) async {
+ try {
+ await call();
+ return null;
+ } on ApiException catch (e) {
+ return e.message;
+ }
+ }
+
+ /// 兼容后台可能返回「数组」或「分页对象(含 list/records)」两种结构。
+ List _parseList(
+ dynamic data,
+ T Function(Map) fromJson,
+ ) {
+ Iterable? raw;
+ if (data is List) {
+ raw = data;
+ } else if (data is Map) {
+ raw = (data['list'] ?? data['records']) as Iterable?;
+ }
+ if (raw == null) return const [];
+ return raw
+ .whereType