feat: 完成智能电网电工端 Flutter 应用初始化
创建完整的 Flutter 项目结构,包含 Android 和 iOS 平台配置、核心网络层、路由、状态管理及业务模块(订单、收益、认证),适配原 uni-app 项目功能逻辑
This commit is contained in:
commit
56830e94ec
108 files changed
+9272
No files matched your search
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/providers.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../data/auth_api.dart';
|
||||
import '../data/auth_models.dart';
|
||||
|
||||
final authApiProvider = Provider<AuthApi>(
|
||||
(ref) => AuthApi(ref.watch(apiClientProvider)),
|
||||
);
|
||||
|
||||
class SessionState {
|
||||
const SessionState({this.worker, required this.isLoggedIn});
|
||||
|
||||
final WorkerInfo? worker;
|
||||
final bool isLoggedIn;
|
||||
|
||||
SessionState copyWith({WorkerInfo? worker, bool? isLoggedIn}) =>
|
||||
SessionState(
|
||||
worker: worker ?? this.worker,
|
||||
isLoggedIn: isLoggedIn ?? this.isLoggedIn,
|
||||
);
|
||||
}
|
||||
|
||||
/// 登录态。对应源 `store/user.ts` 的登录/登出与本地缓存恢复。
|
||||
class SessionController extends Notifier<SessionState> {
|
||||
TokenStorage get _storage => ref.read(tokenStorageProvider);
|
||||
|
||||
@override
|
||||
SessionState build() {
|
||||
// 拦截器判定会话失效(401)时,清空本地会话并回到登录页。
|
||||
final sub = ref
|
||||
.watch(apiClientProvider)
|
||||
.onUnauthorized
|
||||
.listen((_) => clearLocal());
|
||||
ref.onDispose(sub.cancel);
|
||||
|
||||
final cached = _storage.userInfo;
|
||||
return SessionState(
|
||||
worker: cached == null ? null : WorkerInfo.fromJson(cached),
|
||||
isLoggedIn: _storage.hasSession,
|
||||
);
|
||||
}
|
||||
|
||||
/// 登录成功后写入 token 与电工信息。
|
||||
Future<void> login({
|
||||
required String phone,
|
||||
required String password,
|
||||
}) async {
|
||||
final res = await ref
|
||||
.read(authApiProvider)
|
||||
.login(phone: phone, password: password);
|
||||
if (!res.isOk || res.data == null) {
|
||||
throw AuthException(res.message.isEmpty ? '登录失败' : res.message);
|
||||
}
|
||||
|
||||
final data = res.data!;
|
||||
await _storage.setAccessToken(data.token);
|
||||
await _storage.setUserInfo(data.workerInfo.toJson());
|
||||
state = SessionState(worker: data.workerInfo, isLoggedIn: true);
|
||||
}
|
||||
|
||||
/// 从后端刷新电工信息(「我的」页进入时调用)。
|
||||
Future<WorkerInfo?> refreshProfile() async {
|
||||
try {
|
||||
final res = await ref.read(authApiProvider).profile();
|
||||
if (res.isOk && res.data != null) {
|
||||
final info = res.data!.basic;
|
||||
await _storage.setUserInfo(info.toJson());
|
||||
state = SessionState(worker: info, isLoggedIn: true);
|
||||
return info;
|
||||
}
|
||||
} catch (_) {
|
||||
// 拉取失败保持原状态
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _storage.clearSession();
|
||||
state = const SessionState(isLoggedIn: false, worker: null);
|
||||
}
|
||||
|
||||
/// 清空本地会话但不跳转(被拦截器判定会话失效时调用)。
|
||||
Future<void> clearLocal() => logout();
|
||||
}
|
||||
|
||||
final sessionProvider = NotifierProvider<SessionController, SessionState>(
|
||||
SessionController.new,
|
||||
);
|
||||
|
||||
/// 会话层业务异常,携带后端返回的可读 message。
|
||||
class AuthException implements Exception {
|
||||
const AuthException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_response.dart';
|
||||
import 'auth_models.dart';
|
||||
|
||||
/// 认证与个人资料接口,对应后端 `/api/worker-app/auth/*` 与 `/profile`。
|
||||
class AuthApi {
|
||||
AuthApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<ApiResponse<LoginResult>> login({
|
||||
required String phone,
|
||||
required String password,
|
||||
}) => _client.post<LoginResult>(
|
||||
'/worker-app/auth/login',
|
||||
body: {'phone': phone, 'password': password},
|
||||
parse: (d) => LoginResult.fromJson((d as Map).cast<String, dynamic>()),
|
||||
needAuth: false,
|
||||
);
|
||||
|
||||
Future<ApiResponse<WorkerInfo>> register({
|
||||
required String workerName,
|
||||
required String phone,
|
||||
required String password,
|
||||
String? idCard,
|
||||
String? workerTitle,
|
||||
}) => _client.post<WorkerInfo>(
|
||||
'/worker-app/auth/register',
|
||||
body: {
|
||||
'workerName': workerName,
|
||||
'phone': phone,
|
||||
'password': password,
|
||||
if (idCard != null && idCard.isNotEmpty) 'idCard': idCard,
|
||||
if (workerTitle != null && workerTitle.isNotEmpty) 'workerTitle': workerTitle,
|
||||
},
|
||||
parse: (d) => WorkerInfo.fromJson((d as Map).cast<String, dynamic>()),
|
||||
needAuth: false,
|
||||
);
|
||||
|
||||
Future<ApiResponse<WorkerProfile>> profile() => _client.get<WorkerProfile>(
|
||||
'/worker-app/profile',
|
||||
parse: (d) => WorkerProfile.fromJson((d as Map).cast<String, dynamic>()),
|
||||
);
|
||||
|
||||
Future<ApiResponse<WorkerInfo>> updateProfile({
|
||||
String? workerName,
|
||||
String? idCard,
|
||||
String? workerTitle,
|
||||
}) => _client.put<WorkerInfo>(
|
||||
'/worker-app/profile',
|
||||
body: {
|
||||
'workerName': ?workerName,
|
||||
'idCard': ?idCard,
|
||||
'workerTitle': ?workerTitle,
|
||||
},
|
||||
parse: (d) => WorkerInfo.fromJson((d as Map).cast<String, dynamic>()),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import '../../../core/utils/id_util.dart';
|
||||
|
||||
/// 电工基础信息,对应后端 `WorkerAppService.basicInfo()`。
|
||||
class WorkerInfo {
|
||||
const WorkerInfo({
|
||||
required this.id,
|
||||
required this.workerName,
|
||||
required this.phone,
|
||||
required this.workerTitle,
|
||||
required this.auditStatus,
|
||||
required this.status,
|
||||
required this.rating,
|
||||
required this.completedCount,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String workerName;
|
||||
final String phone;
|
||||
final String workerTitle;
|
||||
|
||||
/// 审核状态:0 待审核 / 1 已通过 / 2 已驳回
|
||||
final int auditStatus;
|
||||
|
||||
/// 在职状态:0 离职 / 1 在职
|
||||
final int status;
|
||||
|
||||
final double rating;
|
||||
final int completedCount;
|
||||
|
||||
String get displayName => workerName.isNotEmpty ? workerName : phone;
|
||||
|
||||
/// 头像占位文字:取显示名首个字符(按 Unicode 码点截取,兼容中文)。
|
||||
String get avatarText =>
|
||||
displayName.isEmpty ? '电' : String.fromCharCode(displayName.runes.first);
|
||||
|
||||
bool get isApproved => auditStatus == 1;
|
||||
|
||||
String get auditStatusText => switch (auditStatus) {
|
||||
0 => '待审核',
|
||||
1 => '已通过',
|
||||
2 => '已驳回',
|
||||
_ => '未知',
|
||||
};
|
||||
|
||||
factory WorkerInfo.fromJson(Map<String, dynamic> json) => WorkerInfo(
|
||||
id: toIntOr(json['id'], 0),
|
||||
workerName: json['workerName'] as String? ?? '',
|
||||
phone: json['phone'] as String? ?? '',
|
||||
workerTitle: json['workerTitle'] as String? ?? '',
|
||||
auditStatus: toIntOr(json['auditStatus'], 0),
|
||||
status: toIntOr(json['status'], 0),
|
||||
rating: toDoubleOr(json['rating'], 5),
|
||||
completedCount: toIntOr(json['completedCount'], 0),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'workerName': workerName,
|
||||
'phone': phone,
|
||||
'workerTitle': workerTitle,
|
||||
'auditStatus': auditStatus,
|
||||
'status': status,
|
||||
'rating': rating,
|
||||
'completedCount': completedCount,
|
||||
};
|
||||
}
|
||||
|
||||
/// 登录返回体。
|
||||
class LoginResult {
|
||||
const LoginResult({required this.token, required this.workerInfo});
|
||||
|
||||
final String token;
|
||||
final WorkerInfo workerInfo;
|
||||
|
||||
factory LoginResult.fromJson(Map<String, dynamic> json) => LoginResult(
|
||||
token: json['token'] as String? ?? '',
|
||||
workerInfo: WorkerInfo.fromJson(
|
||||
(json['workerInfo'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 电工资质证照,对应 `worker_cert` 表。
|
||||
class CertInfo {
|
||||
const CertInfo({
|
||||
required this.id,
|
||||
required this.certType,
|
||||
required this.certNo,
|
||||
required this.issueAuthority,
|
||||
required this.issueDate,
|
||||
required this.expireDate,
|
||||
required this.certImageUrl,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String certType;
|
||||
final String certNo;
|
||||
final String issueAuthority;
|
||||
final String issueDate;
|
||||
final String expireDate;
|
||||
final String certImageUrl;
|
||||
|
||||
/// 有效期状态:0 正常 / 1 即将到期 / 2 已过期
|
||||
final int status;
|
||||
|
||||
factory CertInfo.fromJson(Map<String, dynamic> json) => CertInfo(
|
||||
id: toIntOr(json['id'], 0),
|
||||
certType: json['certType'] as String? ?? '',
|
||||
certNo: json['certNo'] as String? ?? '',
|
||||
issueAuthority: json['issueAuthority'] as String? ?? '',
|
||||
issueDate: json['issueDate'] as String? ?? '',
|
||||
expireDate: json['expireDate'] as String? ?? '',
|
||||
certImageUrl: json['certImageUrl'] as String? ?? '',
|
||||
status: toIntOr(json['status'], 0),
|
||||
);
|
||||
}
|
||||
|
||||
/// 个人资料,对应 `GET /api/worker-app/profile`。
|
||||
class WorkerProfile {
|
||||
const WorkerProfile({
|
||||
required this.basic,
|
||||
required this.idCard,
|
||||
required this.auditRemark,
|
||||
required this.certStatus,
|
||||
required this.certs,
|
||||
});
|
||||
|
||||
final WorkerInfo basic;
|
||||
final String idCard;
|
||||
final String auditRemark;
|
||||
|
||||
/// 资质汇总状态:none / valid / expiring / expired
|
||||
final String certStatus;
|
||||
final List<CertInfo> certs;
|
||||
|
||||
/// 「我的」页证书区取首张证书展示(源页面只展示一张「电工职业资格证」)。
|
||||
CertInfo? get primaryCert => certs.isEmpty ? null : certs.first;
|
||||
|
||||
factory WorkerProfile.fromJson(Map<String, dynamic> json) => WorkerProfile(
|
||||
basic: WorkerInfo.fromJson(json),
|
||||
idCard: json['idCard'] as String? ?? '',
|
||||
auditRemark: json['auditRemark'] as String? ?? '',
|
||||
certStatus: json['certStatus'] as String? ?? 'none',
|
||||
certs:
|
||||
(json['certs'] as List?)
|
||||
?.map((e) => CertInfo.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/env.dart';
|
||||
import '../../../core/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_icon.dart';
|
||||
import '../application/session_controller.dart';
|
||||
|
||||
/// 是否在 release 包里也显示「服务端设置」入口。
|
||||
///
|
||||
/// 默认关闭,正式包不暴露改地址的口子;打内部测试包时用
|
||||
/// `--dart-define=ENABLE_SERVER_SETTINGS=true` 打开。
|
||||
const _serverSettingsEnabled = bool.fromEnvironment('ENABLE_SERVER_SETTINGS');
|
||||
|
||||
/// 登录页,对应源 `pages/login/index.vue`。
|
||||
///
|
||||
/// **与源的差异(已与用户确认)**:源为「手机号 + 验证码」,后端只支持
|
||||
/// 「手机号 + 密码」,故此处改为手机号 + 密码,并新增注册入口。
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _phone = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
|
||||
bool _agree = false;
|
||||
bool _obscure = true;
|
||||
bool _loading = false;
|
||||
String _error = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_phone.addListener(_onChanged);
|
||||
_password.addListener(_onChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phone.dispose();
|
||||
_password.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onChanged() => setState(() {});
|
||||
|
||||
bool get _canLogin =>
|
||||
_phone.text.length == 11 && _password.text.length >= 6 && _agree;
|
||||
|
||||
Future<void> _handleLogin() async {
|
||||
if (!_canLogin) {
|
||||
if (!_agree) _error = '请先阅读并同意服务协议与隐私政策';
|
||||
setState(() {});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = '';
|
||||
});
|
||||
try {
|
||||
await ref
|
||||
.read(sessionProvider.notifier)
|
||||
.login(phone: _phone.text.trim(), password: _password.text);
|
||||
if (!mounted) return;
|
||||
context.go('/tabs');
|
||||
} on AuthException catch (e) {
|
||||
if (mounted) setState(() => _error = e.message);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _error = '网络异常,请检查服务端地址');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFF0F4FF), Color(0xFFFAF9FE)],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 80, 24, 60),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildLogoSection(),
|
||||
_buildFormSection(),
|
||||
_buildOtherLogin(),
|
||||
_buildAgreement(),
|
||||
const SizedBox(height: 24),
|
||||
_buildRegisterEntry(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 测试用「服务端设置」入口:调试版常显,release 需显式开关
|
||||
if (kDebugMode || _serverSettingsEnabled)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
child: GestureDetector(
|
||||
onTap: _openServerSettings,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: XIcon(
|
||||
'settings',
|
||||
size: 22,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openServerSettings() => showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
|
||||
),
|
||||
builder: (_) => const _ServerSettingsSheet(),
|
||||
);
|
||||
|
||||
// ---- 各区块 ----
|
||||
|
||||
Widget _buildLogoSection() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 48),
|
||||
child: Column(
|
||||
children: [
|
||||
// .logo 96×96 / #0052ff / radius 20 / 0 8px 24px rgba(0,82,255,.3)
|
||||
Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x4D0052FF),
|
||||
blurRadius: 24,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const XIcon('shandian', size: 48, color: Colors.white),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
'VoltLink',
|
||||
style: AppTypography.displayMd.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'欢迎回到 VoltLink',
|
||||
style: TextStyle(fontSize: 16, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFormSection() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 48),
|
||||
child: Column(
|
||||
children: [
|
||||
// .input-group:白底 / radius 16 / padding 1px
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildPhoneRow(),
|
||||
_buildPasswordRow(),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildLoginButton(),
|
||||
if (_error.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
child: Text(
|
||||
_error,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 手机号行:+86 国家码 + 分隔线 + 输入框。
|
||||
Widget _buildPhoneRow() {
|
||||
return Container(
|
||||
height: 56,
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: AppColors.surfaceContainerHighest)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'+86',
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'▼',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(width: 1, height: 32, color: AppColors.surfaceContainerHighest),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _phone,
|
||||
keyboardType: TextInputType.phone,
|
||||
maxLength: 11,
|
||||
style: const TextStyle(fontSize: 17, color: AppColors.onSurface),
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: _inputDecoration('请输入手机号码'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 密码行:源为「验证码 + 获取验证码」,后端改为密码,右侧按钮换成明文切换。
|
||||
Widget _buildPasswordRow() {
|
||||
return SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _password,
|
||||
obscureText: _obscure,
|
||||
style: const TextStyle(fontSize: 17, color: AppColors.onSurface),
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: _inputDecoration('请输入密码(至少 6 位)'),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _obscure = !_obscure),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Text(
|
||||
_obscure ? '显示' : '隐藏',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _inputDecoration(String hint) => InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
counterText: '',
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(fontSize: 17, color: AppColors.textPlaceholder),
|
||||
);
|
||||
|
||||
Widget _buildLoginButton() {
|
||||
final enabled = _canLogin && !_loading;
|
||||
return GestureDetector(
|
||||
onTap: enabled ? _handleLogin : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? AppColors.primary : AppColors.textPlaceholder,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
boxShadow: enabled
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: Color(0x4D0052FF),
|
||||
blurRadius: 20,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(Colors.white),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Text(
|
||||
'立即登录',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'→',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w300,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOtherLogin() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 48),
|
||||
child: Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 24),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 1,
|
||||
child: ColoredBox(color: AppColors.surfaceContainerHighest),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'微信快捷登录',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 1,
|
||||
child: ColoredBox(color: AppColors.surfaceContainerHighest),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => showAppToast('微信登录暂未开放'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF07C160),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const XIcon('weixin', size: 32, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAgreement() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _agree = !_agree),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
margin: const EdgeInsets.only(top: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: _agree ? AppColors.primary : Colors.transparent,
|
||||
border: Border.all(
|
||||
color: _agree ? AppColors.primary : AppColors.textPlaceholder,
|
||||
width: 2,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: _agree
|
||||
? const Text(
|
||||
'✓',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
height: 1,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.6,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: '我已阅读并同意'),
|
||||
_link('《VoltLink服务协议》'),
|
||||
const TextSpan(text: '与'),
|
||||
_link('《隐私政策》'),
|
||||
const TextSpan(text: ',并授权 VoltLink 在登录时获取我的相关信息。'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
TextSpan _link(String text) => TextSpan(
|
||||
text: text,
|
||||
style: const TextStyle(color: AppColors.primary),
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => showAppToast('$text 暂未开放'),
|
||||
);
|
||||
|
||||
Widget _buildRegisterEntry() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'还没有账号?',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.textMuted),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => context.push('/register'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
||||
child: Text(
|
||||
'立即注册',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 调试版「服务端设置」弹窗:只让改 IP 与端口,
|
||||
/// 协议与路径沿用 [Env.apiBaseUrl] 的编译期配置。
|
||||
class _ServerSettingsSheet extends ConsumerStatefulWidget {
|
||||
const _ServerSettingsSheet();
|
||||
|
||||
@override
|
||||
ConsumerState<_ServerSettingsSheet> createState() =>
|
||||
_ServerSettingsSheetState();
|
||||
}
|
||||
|
||||
class _ServerSettingsSheetState extends ConsumerState<_ServerSettingsSheet> {
|
||||
late final TextEditingController _host;
|
||||
late final TextEditingController _port;
|
||||
String _error = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final saved = ref.read(serverAddressProvider);
|
||||
_host = TextEditingController(
|
||||
text: saved.host.isEmpty ? Env.defaultHost : saved.host,
|
||||
);
|
||||
_port = TextEditingController(
|
||||
text: saved.port.isEmpty ? '${Env.defaultPort}' : saved.port,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_host.dispose();
|
||||
_port.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _preview {
|
||||
final host = _host.text.trim();
|
||||
return Env.buildBaseUrl(
|
||||
host.isEmpty ? Env.defaultHost : host,
|
||||
_port.text.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final host = _host.text.trim();
|
||||
final port = _port.text.trim();
|
||||
|
||||
if (host.isEmpty) {
|
||||
setState(() => _error = '请输入服务端 IP');
|
||||
return;
|
||||
}
|
||||
if (host.contains('://') || host.contains('/') || host.contains(':')) {
|
||||
setState(() => _error = '只填 IP 或域名,不要带 http:// 和端口');
|
||||
return;
|
||||
}
|
||||
final parsedPort = int.tryParse(port);
|
||||
if (parsedPort == null || parsedPort < 1 || parsedPort > 65535) {
|
||||
setState(() => _error = '端口需为 1 - 65535 的整数');
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存后 serverAddressProvider 变化会重建 ApiClient,立即生效
|
||||
await ref.read(serverAddressProvider.notifier).save(host, port);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
showAppToast('已切换到 ${Env.buildBaseUrl(host, port)}', success: true);
|
||||
}
|
||||
|
||||
Future<void> _reset() async {
|
||||
await ref.read(serverAddressProvider.notifier).reset();
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
showAppToast('已恢复默认地址', success: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'服务端设置',
|
||||
style: AppTypography.headlineMd.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'保存后立即生效,无需重启 App。',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildField(
|
||||
label: '服务端 IP',
|
||||
controller: _host,
|
||||
hintText: '如 192.168.3.29',
|
||||
keyboardType: TextInputType.url,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildField(
|
||||
label: '端口',
|
||||
controller: _port,
|
||||
hintText: '如 7001',
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// 保存前实时预览拼接结果,避免地址拼错
|
||||
ListenableBuilder(
|
||||
listenable: Listenable.merge([_host, _port]),
|
||||
builder: (context, _) => Text(
|
||||
'生效地址:$_preview',
|
||||
style: AppTypography.dataMono.copyWith(
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_error.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
_error,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.error),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: XPrimaryButton(
|
||||
label: '恢复默认',
|
||||
onPressed: _reset,
|
||||
background: AppColors.surfaceContainerHigh,
|
||||
foreground: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: XPrimaryButton(label: '保存', onPressed: _save),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildField({
|
||||
required String label,
|
||||
required TextEditingController controller,
|
||||
required String hintText,
|
||||
required TextInputType keyboardType,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppTypography.bodySm.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceBright,
|
||||
border: Border.all(color: AppColors.borderSoft),
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
autocorrect: false,
|
||||
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
hintText: hintText,
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textPlaceholder,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_card.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_top_bar.dart';
|
||||
import '../application/session_controller.dart';
|
||||
|
||||
/// 注册页。
|
||||
///
|
||||
/// 源工程登录页只有「手机号 + 验证码」,没有注册页;后端 `/auth/register`
|
||||
/// 支持手机号 + 密码开户,因此这里补一个与登录页同一套视觉语言的注册表单。
|
||||
class RegisterPage extends ConsumerStatefulWidget {
|
||||
const RegisterPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RegisterPage> createState() => _RegisterPageState();
|
||||
}
|
||||
|
||||
class _RegisterPageState extends ConsumerState<RegisterPage> {
|
||||
final _name = TextEditingController();
|
||||
final _phone = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
final _idCard = TextEditingController();
|
||||
final _title = TextEditingController();
|
||||
|
||||
bool _loading = false;
|
||||
String _error = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (final c in [_name, _phone, _password]) {
|
||||
c.addListener(_onChanged);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
_phone.dispose();
|
||||
_password.dispose();
|
||||
_idCard.dispose();
|
||||
_title.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onChanged() => setState(() {});
|
||||
|
||||
bool get _canSubmit =>
|
||||
_name.text.trim().isNotEmpty &&
|
||||
_phone.text.length == 11 &&
|
||||
_password.text.length >= 6;
|
||||
|
||||
Future<void> _handleRegister() async {
|
||||
if (!_canSubmit) {
|
||||
setState(() => _error = '请填写姓名、11 位手机号与至少 6 位密码');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = '';
|
||||
});
|
||||
try {
|
||||
final res = await ref
|
||||
.read(authApiProvider)
|
||||
.register(
|
||||
workerName: _name.text.trim(),
|
||||
phone: _phone.text.trim(),
|
||||
password: _password.text,
|
||||
idCard: _idCard.text.trim(),
|
||||
workerTitle: _title.text.trim(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (!res.isOk) {
|
||||
setState(() => _error = res.message.isEmpty ? '注册失败' : res.message);
|
||||
return;
|
||||
}
|
||||
showAppToast('注册成功,请登录', success: true);
|
||||
context.pop();
|
||||
} on ApiException catch (e) {
|
||||
if (mounted) setState(() => _error = e.message);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _error = '网络异常,请稍后重试');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: XPageShell(
|
||||
topBar: XTopBar.nav(title: '注册账号', onBack: () => context.pop()),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 40),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
XCard(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_Field(
|
||||
label: '姓名',
|
||||
hint: '请输入真实姓名',
|
||||
controller: _name,
|
||||
required: true,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_Field(
|
||||
label: '手机号',
|
||||
hint: '请输入 11 位手机号',
|
||||
controller: _phone,
|
||||
required: true,
|
||||
keyboardType: TextInputType.phone,
|
||||
maxLength: 11,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_Field(
|
||||
label: '密码',
|
||||
hint: '至少 6 位',
|
||||
controller: _password,
|
||||
required: true,
|
||||
obscure: true,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_Field(
|
||||
label: '身份证号',
|
||||
hint: '选填',
|
||||
controller: _idCard,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_Field(
|
||||
label: '工种 / 职称',
|
||||
hint: '选填,如 高压电工',
|
||||
controller: _title,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
XPrimaryButton(
|
||||
label: '提交注册',
|
||||
height: 56,
|
||||
radius: AppRadius.xl,
|
||||
loading: _loading,
|
||||
onPressed: _canSubmit ? _handleRegister : null,
|
||||
shadows: AppShadows.primaryButton,
|
||||
),
|
||||
if (_error.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
child: Text(
|
||||
_error,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.error),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'注册后需等待平台审核通过,方可接单。',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Field extends StatelessWidget {
|
||||
const _Field({
|
||||
required this.label,
|
||||
required this.hint,
|
||||
required this.controller,
|
||||
this.required = false,
|
||||
this.obscure = false,
|
||||
this.keyboardType,
|
||||
this.maxLength,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String hint;
|
||||
final TextEditingController controller;
|
||||
final bool required;
|
||||
final bool obscure;
|
||||
final TextInputType? keyboardType;
|
||||
final int? maxLength;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppTypography.bodySm.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (required)
|
||||
const Text(
|
||||
' *',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.error),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceBright,
|
||||
border: Border.all(color: AppColors.borderSoft),
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
obscureText: obscure,
|
||||
keyboardType: keyboardType,
|
||||
maxLength: maxLength,
|
||||
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
counterText: '',
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textPlaceholder,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/providers.dart';
|
||||
import '../data/earnings_api.dart';
|
||||
import '../data/earnings_models.dart';
|
||||
|
||||
final earningsApiProvider = Provider<EarningsApi>(
|
||||
(ref) => EarningsApi(ref.watch(apiClientProvider)),
|
||||
);
|
||||
|
||||
final earningsSummaryProvider =
|
||||
FutureProvider.autoDispose<EarningsSummary>((ref) async {
|
||||
final res = await ref.watch(earningsApiProvider).summary();
|
||||
if (!res.isOk) throw ApiException(res.message);
|
||||
if (res.data == null) throw const ApiException('收益数据为空');
|
||||
return res.data!;
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_response.dart';
|
||||
import 'earnings_models.dart';
|
||||
|
||||
/// 收益接口,对应后端 `/api/worker-app/earnings/summary`。
|
||||
class EarningsApi {
|
||||
EarningsApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<ApiResponse<EarningsSummary>> summary() =>
|
||||
_client.get<EarningsSummary>(
|
||||
'/worker-app/earnings/summary',
|
||||
parse: (d) => EarningsSummary.fromJson((d as Map).cast<String, dynamic>()),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import '../../../core/utils/id_util.dart';
|
||||
|
||||
/// 收益流水,对应后端 `earningsSummary().data.transactions[]`。
|
||||
///
|
||||
/// 说明:后端没有提现记录来源,因此实际只会返回 `type == 'earning'` 的入账流水。
|
||||
class EarningTransaction {
|
||||
const EarningTransaction({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.amount,
|
||||
required this.status,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
final String id;
|
||||
|
||||
/// earning | withdrawal
|
||||
final String type;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final double amount;
|
||||
|
||||
/// settled | pending | completed
|
||||
final String status;
|
||||
final String icon;
|
||||
|
||||
bool get isWithdrawal => type == 'withdrawal';
|
||||
|
||||
String get statusText => switch (status) {
|
||||
'settled' => '已结算',
|
||||
'pending' => '处理中',
|
||||
'completed' => '已完成',
|
||||
_ => status,
|
||||
};
|
||||
|
||||
factory EarningTransaction.fromJson(Map<String, dynamic> json) =>
|
||||
EarningTransaction(
|
||||
id: json['id'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'earning',
|
||||
title: json['title'] as String? ?? '',
|
||||
subtitle: json['subtitle'] as String? ?? '',
|
||||
amount: toDoubleOr(json['amount'], 0),
|
||||
status: json['status'] as String? ?? 'settled',
|
||||
icon: json['icon'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// 收益汇总,对应 `GET /api/worker-app/earnings/summary`。
|
||||
class EarningsSummary {
|
||||
const EarningsSummary({
|
||||
required this.totalBalance,
|
||||
required this.withdrawableAmount,
|
||||
required this.monthlyEarnings,
|
||||
required this.weeklyTrend,
|
||||
required this.transactions,
|
||||
});
|
||||
|
||||
final double totalBalance;
|
||||
final double withdrawableAmount;
|
||||
final double monthlyEarnings;
|
||||
|
||||
/// 近 7 天每日收益(index 6 = 今天)。
|
||||
final List<double> weeklyTrend;
|
||||
final List<EarningTransaction> transactions;
|
||||
|
||||
/// 柱状图高度百分比:以 7 日峰值为 100%,全 0 时给一个最小可见高度。
|
||||
List<double> get trendPercents {
|
||||
final max = weeklyTrend.isEmpty
|
||||
? 0.0
|
||||
: weeklyTrend.reduce((a, b) => a > b ? a : b);
|
||||
if (max <= 0) return List.filled(weeklyTrend.length, 8);
|
||||
return weeklyTrend.map((v) => (v / max * 100).clamp(8, 100).toDouble()).toList();
|
||||
}
|
||||
|
||||
factory EarningsSummary.fromJson(Map<String, dynamic> json) => EarningsSummary(
|
||||
totalBalance: toDoubleOr(json['totalBalance'], 0),
|
||||
withdrawableAmount: toDoubleOr(json['withdrawableAmount'], 0),
|
||||
monthlyEarnings: toDoubleOr(json['monthlyEarnings'], 0),
|
||||
weeklyTrend:
|
||||
(json['weeklyTrend'] as List?)?.map((e) => toDoubleOr(e, 0)).toList() ??
|
||||
const [0, 0, 0, 0, 0, 0, 0],
|
||||
transactions:
|
||||
(json['transactions'] as List?)
|
||||
?.map(
|
||||
(e) => EarningTransaction.fromJson(
|
||||
(e as Map).cast<String, dynamic>(),
|
||||
),
|
||||
)
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_card.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_icon.dart';
|
||||
import '../../../shared/widgets/x_top_bar.dart';
|
||||
import '../application/earnings_providers.dart';
|
||||
import '../data/earnings_models.dart';
|
||||
|
||||
/// rgba(255,255,255,0.8) —— 余额卡内的次级文字。
|
||||
const _white80 = Color(0xCCFFFFFF);
|
||||
|
||||
/// 柱状图绘图区高度(趋势卡总高 160,余下留给 8px 间距与周标签)。
|
||||
const _barPlotHeight = 130.0;
|
||||
|
||||
/// 「收益」Tab,逐像素复刻源 `src/pages/earnings/index.vue`。
|
||||
///
|
||||
/// 顶栏由本页渲染;底部 Tab 栏由 `TabsPage` 统一提供,故此处不渲染导航。
|
||||
class EarningsTab extends ConsumerWidget {
|
||||
const EarningsTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final summary = ref.watch(earningsSummaryProvider);
|
||||
final unread = ref.watch(noticeUnreadCountProvider);
|
||||
|
||||
return XPageShell(
|
||||
topBar: XTopBar.brand(
|
||||
height: 56,
|
||||
logoSize: 28,
|
||||
titleSize: 18,
|
||||
titleWeight: FontWeight.w600,
|
||||
trailingBadge: unread,
|
||||
onTrailingTap: () => context.push('/notice'),
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
color: AppColors.primary,
|
||||
onRefresh: () async {
|
||||
ref.invalidate(earningsSummaryProvider);
|
||||
await ref.read(earningsSummaryProvider.future);
|
||||
},
|
||||
child: summary.when(
|
||||
loading: () => _placeholder('加载中...'),
|
||||
error: (error, _) => _placeholder(error.toString()),
|
||||
data: (data) => ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 96),
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 24, 16, 24),
|
||||
child: XPageTitle(
|
||||
title: '收益中心',
|
||||
subtitle: '管理您的职业收入与提现详情',
|
||||
),
|
||||
),
|
||||
_balanceCard(data),
|
||||
_trendSection(data),
|
||||
_transactionsSection(data),
|
||||
_referralCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 加载 / 错误占位,保持可下拉(`AlwaysScrollableScrollPhysics`)。
|
||||
Widget _placeholder(String text) => ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [XPlaceholder(text: text)],
|
||||
);
|
||||
|
||||
/// 余额卡:渐变底 + 总余额 + 两列指标 + 白色提现按钮。
|
||||
Widget _balanceCard(EarningsSummary data) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.primaryDeep, AppColors.primary],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'总余额',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _white80,
|
||||
letterSpacing: 0.24,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'¥ ${_formatAmount(data.totalBalance)}',
|
||||
style: AppTypography.displayLg.copyWith(
|
||||
color: AppColors.onPrimary,
|
||||
letterSpacing: -0.8,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _balanceItem(
|
||||
'可提现金额',
|
||||
_formatAmount(data.withdrawableAmount),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _balanceItem(
|
||||
'本月累计收益',
|
||||
_formatAmount(data.monthlyEarnings),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
height: 44,
|
||||
width: double.infinity,
|
||||
child: Material(
|
||||
color: AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: () => showAppToast('提现功能'),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
XIcon('earnings', size: 20, color: AppColors.primary),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'立即提现',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _balanceItem(String label, String value) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: _white80)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'¥ $value',
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/// 最近 7 天趋势:柱高取 `trendPercents`,高亮今天(末位)。
|
||||
Widget _trendSection(EarningsSummary data) {
|
||||
final percents = data.trendPercents;
|
||||
final labels = _weekdayLabels();
|
||||
final activeIndex = percents.isEmpty ? -1 : percents.length - 1;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'最近7天趋势',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => showAppToast('近一周'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Row(
|
||||
children: [
|
||||
Text(
|
||||
'近一周',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 4),
|
||||
XIcon('arrow-down', size: 14, color: AppColors.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
XCard(
|
||||
radius: AppRadius.lg,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: SizedBox(
|
||||
height: 160,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (var i = 0; i < percents.length; i++)
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FractionallySizedBox(
|
||||
widthFactor: 0.8,
|
||||
child: Container(
|
||||
height: percents[i] / 100 * _barPlotHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: i == activeIndex
|
||||
? AppColors.primary
|
||||
: AppColors.primaryFixedDim,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
labels[i % labels.length],
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: i == activeIndex
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
color: i == activeIndex
|
||||
? AppColors.primary
|
||||
: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 收益明细:入账 / 提现两种配色 + 金额与状态。
|
||||
Widget _transactionsSection(EarningsSummary data) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'收益明细',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => showAppToast('查看全部'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Text(
|
||||
'查看全部',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
for (var i = 0; i < data.transactions.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 8),
|
||||
_transactionItem(data.transactions[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _transactionItem(EarningTransaction tx) {
|
||||
final withdrawal = tx.isWithdrawal;
|
||||
final settled = tx.status == 'settled' || tx.status == 'completed';
|
||||
final statusColor = settled ? AppColors.primary : AppColors.textMuted;
|
||||
|
||||
return XCard(
|
||||
radius: AppRadius.lg,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: withdrawal
|
||||
? AppColors.surfaceVariant
|
||||
: AppColors.secondaryFixed,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: XIcon(
|
||||
'lightning',
|
||||
size: 20,
|
||||
color: withdrawal
|
||||
? AppColors.onSurfaceVariant
|
||||
: AppColors.onSecondaryFixed,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
tx.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
tx.subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${withdrawal ? '-' : '+'}¥${_formatAmount(tx.amount.abs())}',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: withdrawal
|
||||
? AppColors.onSurface
|
||||
: AppColors.tertiaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
tx.statusText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: statusColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 邀请好友卡(源里无实际跳转,点击仅提示)。
|
||||
Widget _referralCard() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: GestureDetector(
|
||||
onTap: () => showAppToast('邀请好友加入'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.secondaryFixed,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
border: Border.all(color: AppColors.borderSoft),
|
||||
boxShadow: AppShadows.card,
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'邀请好友加入',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSecondaryFixed,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'每成功邀请一位专业电工,您将获得 ¥100 额外奖励。',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Color(0xB30C006A),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
XIcon('star', size: 48, color: AppColors.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `formatNumber`:千分位 + 两位小数,空值 / 0 回退 `0.00`(与源实现一致)。
|
||||
String _formatAmount(double? value) {
|
||||
if (value == null || value == 0) return '0.00';
|
||||
final parts = value.toStringAsFixed(2).split('.');
|
||||
final grouped = parts[0].replaceAllMapped(
|
||||
RegExp(r'\B(?=(\d{3})+(?!\d))'),
|
||||
(_) => ',',
|
||||
);
|
||||
return '$grouped.${parts[1]}';
|
||||
}
|
||||
|
||||
/// 周标签:index 6 为今天,向前回溯 6 天,保证标签与真实星期一致。
|
||||
List<String> _weekdayLabels() {
|
||||
const names = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
final today = DateTime.now();
|
||||
return [
|
||||
for (var i = 6; i >= 0; i--)
|
||||
names[today.subtract(Duration(days: i)).weekday - 1],
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/utils/id_util.dart';
|
||||
|
||||
/// 站内消息。对应后端 `sys_notice` 的电工端视图。
|
||||
///
|
||||
/// 后端 `toJSON()` 返回原始列名,时间字段为下划线命名 `created_at`,
|
||||
/// `is_read` 为 0/1。
|
||||
class Notice {
|
||||
const Notice({
|
||||
required this.id,
|
||||
required this.noticeType,
|
||||
required this.title,
|
||||
required this.content,
|
||||
required this.bizType,
|
||||
required this.bizId,
|
||||
required this.receiverType,
|
||||
required this.ruleId,
|
||||
required this.batchId,
|
||||
required this.isRead,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
final int id;
|
||||
|
||||
/// 通知类型,如 `cert_expire`。
|
||||
final String noticeType;
|
||||
|
||||
final String title;
|
||||
final String content;
|
||||
|
||||
/// 业务类型,如 `worker_cert`。
|
||||
final String bizType;
|
||||
|
||||
/// 业务主键。
|
||||
final int? bizId;
|
||||
|
||||
/// `user` 管理端 / `worker` 电工端。
|
||||
final String receiverType;
|
||||
|
||||
/// 来源通知规则 / 群发批次,可能为空。
|
||||
final int? ruleId;
|
||||
final int? batchId;
|
||||
|
||||
final bool isRead;
|
||||
|
||||
/// 原始 `created_at`,交给 `format_util` 渲染。
|
||||
final Object? createdAt;
|
||||
|
||||
factory Notice.fromJson(Map<String, dynamic> json) => Notice(
|
||||
id: toIntOr(json['id'], 0),
|
||||
noticeType: json['noticeType'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
content: json['content'] as String? ?? '',
|
||||
bizType: json['bizType'] as String? ?? '',
|
||||
bizId: toIntOrNull(json['bizId']),
|
||||
receiverType: json['receiverType'] as String? ?? '',
|
||||
ruleId: toIntOrNull(json['ruleId']),
|
||||
batchId: toIntOrNull(json['batchId']),
|
||||
isRead: _toBool(json['isRead']),
|
||||
createdAt: json['created_at'],
|
||||
);
|
||||
|
||||
/// 复制为已读,用于标记成功后本地更新,避免整页重拉。
|
||||
Notice copiedWithRead() => Notice(
|
||||
id: id,
|
||||
noticeType: noticeType,
|
||||
title: title,
|
||||
content: content,
|
||||
bizType: bizType,
|
||||
bizId: bizId,
|
||||
receiverType: receiverType,
|
||||
ruleId: ruleId,
|
||||
batchId: batchId,
|
||||
isRead: true,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// 消息分页结果。
|
||||
class NoticePage {
|
||||
const NoticePage({
|
||||
required this.list,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
});
|
||||
|
||||
final List<Notice> list;
|
||||
final int total;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
|
||||
factory NoticePage.fromJson(Map<String, dynamic> json) => NoticePage(
|
||||
list: (json['list'] as List? ?? const [])
|
||||
.map((e) => Notice.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(),
|
||||
total: toIntOr(json['total'], 0),
|
||||
page: toIntOr(json['page'], 1),
|
||||
pageSize: toIntOr(json['pageSize'], 10),
|
||||
);
|
||||
}
|
||||
|
||||
/// 站内消息中心接口,对应后端 `/api/worker-app/notices*`。
|
||||
class NoticeApi {
|
||||
NoticeApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
/// 消息列表。`isRead` 传 true/false 时按已读状态筛选,null 取全部。
|
||||
///
|
||||
/// 后端 `Number(query.isRead)` 解析,故这里显式传 1 / 0。
|
||||
Future<ApiResponse<NoticePage>> list({
|
||||
int page = 1,
|
||||
int pageSize = 10,
|
||||
bool? isRead,
|
||||
}) => _client.get<NoticePage>(
|
||||
'/worker-app/notices',
|
||||
query: {
|
||||
'page': page,
|
||||
'pageSize': pageSize,
|
||||
if (isRead != null) 'isRead': isRead ? 1 : 0,
|
||||
},
|
||||
parse: (d) => NoticePage.fromJson((d as Map).cast<String, dynamic>()),
|
||||
);
|
||||
|
||||
/// 未读消息数。
|
||||
Future<ApiResponse<int>> unreadCount() => _client.get<int>(
|
||||
'/worker-app/notices/unread-count',
|
||||
parse: (d) => toIntOr((d as Map)['count'], 0),
|
||||
);
|
||||
|
||||
/// 全部标记已读,返回更新条数。
|
||||
Future<ApiResponse<int>> readAll() => _client.put<int>(
|
||||
'/worker-app/notices/read-all',
|
||||
parse: (d) => toIntOr((d as Map)['updated'], 0),
|
||||
);
|
||||
|
||||
/// 单条标记已读。
|
||||
Future<ApiResponse<void>> markRead(int id) =>
|
||||
_client.put<void>('/worker-app/notices/$id/read');
|
||||
}
|
||||
|
||||
bool _toBool(Object? v) {
|
||||
if (v is bool) return v;
|
||||
if (v is num) return v != 0;
|
||||
final s = v?.toString().toLowerCase();
|
||||
return s == '1' || s == 'true';
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/utils/format_util.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_card.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_top_bar.dart';
|
||||
import '../data/notice_api.dart';
|
||||
|
||||
/// 消息中心:站内通知列表 + 已读标记 + 分页加载。
|
||||
///
|
||||
/// 列表在页面内维护(分页游标语义强于 Riverpod 缓存),未读数经
|
||||
/// [noticeUnreadCountProvider] 统一刷新。
|
||||
class NoticeCenterPage extends ConsumerStatefulWidget {
|
||||
const NoticeCenterPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<NoticeCenterPage> createState() => _NoticeCenterPageState();
|
||||
}
|
||||
|
||||
class _NoticeCenterPageState extends ConsumerState<NoticeCenterPage> {
|
||||
static const _pageSize = 10;
|
||||
|
||||
final _scroll = ScrollController();
|
||||
final _items = <Notice>[];
|
||||
|
||||
int _page = 0;
|
||||
bool _loading = false;
|
||||
bool _loadingMore = false;
|
||||
bool _hasMore = true;
|
||||
String? _error;
|
||||
|
||||
bool get _hasUnread => _items.any((n) => !n.isRead);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scroll.addListener(_onScroll);
|
||||
_load(reset: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scroll
|
||||
..removeListener(_onScroll)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (!_scroll.hasClients) return;
|
||||
final position = _scroll.position;
|
||||
if (position.pixels >= position.maxScrollExtent - 200) _loadMore();
|
||||
}
|
||||
|
||||
/// 加载一页。`reset` 为真时回到第 1 页(下拉刷新 / 重试)。
|
||||
Future<void> _load({bool reset = false}) async {
|
||||
if (reset) {
|
||||
if (_loading) return;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
} else {
|
||||
if (_loading || _loadingMore || !_hasMore) return;
|
||||
setState(() => _loadingMore = true);
|
||||
}
|
||||
|
||||
final nextPage = reset ? 1 : _page + 1;
|
||||
try {
|
||||
final res = await ref
|
||||
.read(noticeApiProvider)
|
||||
.list(page: nextPage, pageSize: _pageSize);
|
||||
if (!mounted) return;
|
||||
if (!res.isOk || res.data == null) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_loadingMore = false;
|
||||
if (reset) _error = res.message.isEmpty ? '消息加载失败' : res.message;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final data = res.data!;
|
||||
setState(() {
|
||||
if (reset) _items.clear();
|
||||
_items.addAll(data.list);
|
||||
_page = data.page;
|
||||
_hasMore = data.list.isNotEmpty && _items.length < data.total;
|
||||
_loading = false;
|
||||
_loadingMore = false;
|
||||
_error = null;
|
||||
});
|
||||
} on ApiException catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_loadingMore = false;
|
||||
if (reset) _error = e.message;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() => _load();
|
||||
|
||||
Future<void> _markRead(Notice notice) async {
|
||||
if (notice.isRead) return;
|
||||
try {
|
||||
final res = await ref.read(noticeApiProvider).markRead(notice.id);
|
||||
if (!mounted) return;
|
||||
if (!res.isOk) {
|
||||
showAppToast(res.message.isEmpty ? '操作失败' : res.message);
|
||||
return;
|
||||
}
|
||||
final index = _items.indexWhere((n) => n.id == notice.id);
|
||||
if (index >= 0) {
|
||||
setState(() => _items[index] = _items[index].copiedWithRead());
|
||||
}
|
||||
ref.read(noticeUnreadCountProvider.notifier).refresh();
|
||||
} on ApiException catch (e) {
|
||||
if (mounted) showAppToast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markAllRead() async {
|
||||
if (!_hasUnread) return;
|
||||
try {
|
||||
final res = await ref.read(noticeApiProvider).readAll();
|
||||
if (!mounted) return;
|
||||
if (!res.isOk) {
|
||||
showAppToast(res.message.isEmpty ? '操作失败' : res.message);
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
for (var i = 0; i < _items.length; i++) {
|
||||
if (!_items[i].isRead) _items[i] = _items[i].copiedWithRead();
|
||||
}
|
||||
});
|
||||
showAppToast('已全部标记为已读', success: true);
|
||||
ref.read(noticeUnreadCountProvider.notifier).refresh();
|
||||
} on ApiException catch (e) {
|
||||
if (mounted) showAppToast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surfaceContainerLow,
|
||||
body: XPageShell(
|
||||
backgroundColor: AppColors.surfaceContainerLow,
|
||||
topBar: XTopBar.nav(
|
||||
title: '消息中心',
|
||||
onBack: () => context.pop(),
|
||||
trailing: _hasUnread
|
||||
? GestureDetector(
|
||||
onTap: _markAllRead,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||
child: Text(
|
||||
'全部已读',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
child: _buildBody(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_loading && _items.isEmpty) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.4,
|
||||
valueColor: AlwaysStoppedAnimation(AppColors.primary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_items.isEmpty) {
|
||||
return RefreshIndicator(
|
||||
color: AppColors.primary,
|
||||
onRefresh: () => _load(reset: true),
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
if (_error != null)
|
||||
XPlaceholder(text: '消息加载失败\n$_error')
|
||||
else
|
||||
const XPlaceholder(text: '暂无消息'),
|
||||
if (_error != null)
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () => _load(reset: true),
|
||||
child: const Text('点击重试'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return RefreshIndicator(
|
||||
color: AppColors.primary,
|
||||
onRefresh: () => _load(reset: true),
|
||||
child: ListView.separated(
|
||||
controller: _scroll,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
itemCount: _items.length + 1,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == _items.length) return _buildFooter();
|
||||
final notice = _items[index];
|
||||
return _NoticeTile(
|
||||
notice: notice,
|
||||
onTap: notice.isRead ? null : () => _markRead(notice),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter() {
|
||||
if (_loadingMore) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_hasMore) {
|
||||
return GestureDetector(
|
||||
onTap: () => _loadMore(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'加载更多',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'没有更多了',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条消息卡片:未读有蓝底高亮 + 红点,点击标记已读。
|
||||
class _NoticeTile extends StatelessWidget {
|
||||
const _NoticeTile({required this.notice, this.onTap});
|
||||
|
||||
final Notice notice;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final unread = !notice.isRead;
|
||||
return XCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
background: unread
|
||||
? AppColors.accentSubtle
|
||||
: AppColors.surfaceContainerLowest,
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (unread)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 7, right: 8),
|
||||
child: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.error,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
notice.title.isEmpty ? '系统通知' : notice.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: unread ? FontWeight.w600 : FontWeight.w500,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
formatDateTime(notice.createdAt),
|
||||
style: AppTypography.labelCaps.copyWith(
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (notice.content.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: unread ? 16 : 0),
|
||||
child: Text(
|
||||
notice.content,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/providers.dart';
|
||||
import '../data/order_api.dart';
|
||||
import '../data/order_models.dart';
|
||||
|
||||
final orderApiProvider = Provider<OrderApi>(
|
||||
(ref) => OrderApi(ref.watch(apiClientProvider)),
|
||||
);
|
||||
|
||||
/// 订单池(待接订单)。接单成功后由调用方 `invalidate` 触发刷新。
|
||||
final orderPoolProvider = FutureProvider.autoDispose<List<Order>>((ref) async {
|
||||
final res = await ref.watch(orderApiProvider).pool();
|
||||
if (!res.isOk) throw ApiException(res.message);
|
||||
return res.data ?? const [];
|
||||
});
|
||||
|
||||
/// 我的订单,`status` 为 `pending` / `completed` / null(全部)。
|
||||
final myOrdersProvider = FutureProvider.autoDispose
|
||||
.family<List<Order>, String?>((ref, status) async {
|
||||
final res = await ref.watch(orderApiProvider).myOrders(status: status);
|
||||
if (!res.isOk) throw ApiException(res.message);
|
||||
return res.data ?? const [];
|
||||
});
|
||||
|
||||
/// 订单详情。参数为 `(source, id)`。
|
||||
final orderDetailProvider = FutureProvider.autoDispose
|
||||
.family<Order, ({String source, int id})>((ref, arg) async {
|
||||
final res = await ref.watch(orderApiProvider).detail(arg.source, arg.id);
|
||||
if (!res.isOk) throw ApiException(res.message);
|
||||
if (res.data == null) throw const ApiException('订单不存在');
|
||||
return res.data!;
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_response.dart';
|
||||
import 'order_models.dart';
|
||||
|
||||
/// 订单接口,对应后端 `/api/worker-app/orders*`。
|
||||
class OrderApi {
|
||||
OrderApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
/// 订单池:四类未被接单的订单合并。
|
||||
Future<ApiResponse<List<Order>>> pool() => _client.get<List<Order>>(
|
||||
'/worker-app/orders/pool',
|
||||
parse: _parseList,
|
||||
);
|
||||
|
||||
/// 我的订单。`status` 传 `pending` / `completed`,留空取全部。
|
||||
Future<ApiResponse<List<Order>>> myOrders({String? status}) =>
|
||||
_client.get<List<Order>>(
|
||||
'/worker-app/orders',
|
||||
query: {'status': status},
|
||||
parse: _parseList,
|
||||
);
|
||||
|
||||
/// 订单详情。
|
||||
Future<ApiResponse<Order>> detail(String source, int id) =>
|
||||
_client.get<Order>(
|
||||
'/worker-app/orders/$source/$id',
|
||||
parse: (d) => Order.fromJson((d as Map).cast<String, dynamic>()),
|
||||
);
|
||||
|
||||
/// 接单。订单被他人接走时后端返回 409。
|
||||
Future<ApiResponse<void>> grab(String source, int id) =>
|
||||
_client.post<void>('/worker-app/orders/$source/$id/grab');
|
||||
|
||||
/// 完成订单。非本人订单后端返回 403。
|
||||
Future<ApiResponse<void>> complete(String source, int id) =>
|
||||
_client.put<void>('/worker-app/orders/$source/$id/complete');
|
||||
|
||||
static List<Order> _parseList(dynamic data) {
|
||||
final map = (data as Map).cast<String, dynamic>();
|
||||
return (map['list'] as List? ?? const [])
|
||||
.map((e) => Order.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import '../../../core/utils/id_util.dart';
|
||||
|
||||
/// 统一订单视图,对应后端 `WorkerAppService.toOrderView()`。
|
||||
///
|
||||
/// `id` 形如 `repair-12` / `install-5`,`source` 为四类来源之一。
|
||||
class Order {
|
||||
const Order({
|
||||
required this.id,
|
||||
required this.source,
|
||||
required this.status,
|
||||
required this.jobId,
|
||||
required this.title,
|
||||
required this.type,
|
||||
required this.amount,
|
||||
required this.distance,
|
||||
required this.schedule,
|
||||
required this.description,
|
||||
required this.customerName,
|
||||
required this.customerPhone,
|
||||
required this.photos,
|
||||
required this.address,
|
||||
required this.laborFee,
|
||||
required this.materialFee,
|
||||
required this.estimatedDuration,
|
||||
});
|
||||
|
||||
/// `repair-12`
|
||||
final String id;
|
||||
|
||||
/// repair | install | maintenance | survey
|
||||
final String source;
|
||||
|
||||
final int status;
|
||||
|
||||
/// 业务单号(repairNo / installNo / maintenanceNo / surveyNo)
|
||||
final String jobId;
|
||||
|
||||
final String title;
|
||||
|
||||
/// urgent | standard | regular
|
||||
final String type;
|
||||
|
||||
final double amount;
|
||||
final double distance;
|
||||
final String schedule;
|
||||
final String description;
|
||||
final String customerName;
|
||||
final String customerPhone;
|
||||
final List<String> photos;
|
||||
final String address;
|
||||
final double laborFee;
|
||||
final double materialFee;
|
||||
final String estimatedDuration;
|
||||
|
||||
/// 来源中文名,用于收益明细副标题等场景。
|
||||
String get sourceName => switch (source) {
|
||||
'repair' => '家庭电路维修',
|
||||
'install' => '设备安装',
|
||||
'maintenance' => '设备维保',
|
||||
'survey' => '现场勘察',
|
||||
_ => '服务订单',
|
||||
};
|
||||
|
||||
/// 列表页类型标签:紧急呼叫 / 标准预约 / 常规巡检。
|
||||
String get listTypeText => switch (type) {
|
||||
'urgent' => '紧急呼叫',
|
||||
'standard' => '标准预约',
|
||||
_ => '常规巡检',
|
||||
};
|
||||
|
||||
/// 详情页类型标签:紧急 / 标准 / 常规。
|
||||
String get detailTypeText => switch (type) {
|
||||
'urgent' => '紧急',
|
||||
'standard' => '标准',
|
||||
_ => '常规',
|
||||
};
|
||||
|
||||
/// 客户头像占位文字:后端无头像字段,用姓名首字(与「我的」页一致)。
|
||||
String get customerAvatarText => customerName.isEmpty
|
||||
? '客'
|
||||
: String.fromCharCode(customerName.runes.first);
|
||||
|
||||
/// 卡片上展示的订单号:后端 `id` 前缀即来源,取 `jobId` 更贴近源 `#EL-8821` 的观感。
|
||||
String get displayNo => jobId.isNotEmpty ? jobId : id;
|
||||
|
||||
/// 后端接口里的数字主键:`repair-12` → 12。
|
||||
int get intId {
|
||||
final idx = id.lastIndexOf('-');
|
||||
return toIntOr(idx >= 0 ? id.substring(idx + 1) : id, 0);
|
||||
}
|
||||
|
||||
factory Order.fromJson(Map<String, dynamic> json) => Order(
|
||||
id: json['id'] as String? ?? '',
|
||||
source: json['source'] as String? ?? '',
|
||||
status: toIntOr(json['status'], 0),
|
||||
jobId: json['jobId'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
type: json['type'] as String? ?? 'regular',
|
||||
amount: toDoubleOr(json['amount'], 0),
|
||||
distance: toDoubleOr(json['distance'], 0),
|
||||
schedule: json['schedule'] as String? ?? '',
|
||||
description: json['description'] as String? ?? '',
|
||||
customerName: json['customerName'] as String? ?? '',
|
||||
customerPhone: json['customerPhone'] as String? ?? '',
|
||||
photos:
|
||||
(json['photos'] as List?)?.map((e) => e.toString()).toList() ??
|
||||
const [],
|
||||
address: json['address'] as String? ?? '',
|
||||
laborFee: toDoubleOr(json['laborFee'], 0),
|
||||
materialFee: toDoubleOr(json['materialFee'], 0),
|
||||
estimatedDuration: json['estimatedDuration'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/utils/format_util.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_card.dart';
|
||||
import '../../../shared/widgets/x_chip.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_icon.dart';
|
||||
import '../../../shared/widgets/x_top_bar.dart';
|
||||
import '../application/order_providers.dart';
|
||||
import '../data/order_models.dart';
|
||||
|
||||
/// 金额展示:整数不带小数,否则保留两位。
|
||||
String _money(num v) =>
|
||||
v == v.roundToDouble() ? v.toStringAsFixed(0) : formatMoney(v);
|
||||
|
||||
/// 订单详情页,对应源 `pages/orderdetail/index.vue`。
|
||||
///
|
||||
/// **与源的差异(后端无对应字段)**:客户卡片省略评分与任务数,改为展示电话;
|
||||
/// 服务地点只有一行地址(后端无 `addressDetail`);现场照片为空时只显示「添加」。
|
||||
class OrderDetailPage extends ConsumerWidget {
|
||||
const OrderDetailPage({super.key, required this.source, required this.id});
|
||||
|
||||
/// repair | install | maintenance | survey
|
||||
final String source;
|
||||
final int id;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final detail = ref.watch(orderDetailProvider((source: source, id: id)));
|
||||
|
||||
return Scaffold(
|
||||
body: XPageShell(
|
||||
backgroundColor: AppColors.surfaceContainerLow,
|
||||
topBar: XTopBar.nav(
|
||||
title: '订单详情',
|
||||
onBack: () => context.pop(),
|
||||
trailing: GestureDetector(
|
||||
onTap: () => showAppToast('分享功能'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: XIcon('share', size: 24, color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: switch (detail) {
|
||||
AsyncData(:final value) => _buildContent(context, value),
|
||||
AsyncError(:final error) => XPlaceholder(text: '订单加载失败\n$error'),
|
||||
_ => const Center(
|
||||
child: SizedBox(
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.4,
|
||||
valueColor: AlwaysStoppedAnimation(AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
// 底部固定操作栏,对应源 `.bottom-action`
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _BottomAction(
|
||||
onStart: () => showAppToast('已开始出发', success: true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, Order order) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(bottom: 120),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_MapSection(address: order.address),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_JobCard(order: order),
|
||||
const SizedBox(height: 12),
|
||||
_CustomerCard(order: order),
|
||||
const SizedBox(height: 16),
|
||||
_PhotosSection(photos: order.photos),
|
||||
const SizedBox(height: 16),
|
||||
_FinancialCard(order: order),
|
||||
const SizedBox(height: 16),
|
||||
_LocationCard(address: order.address),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 顶部地图占位(176 高)+ 右下角「导航」按钮。
|
||||
class _MapSection extends StatelessWidget {
|
||||
const _MapSection({required this.address});
|
||||
|
||||
final String address;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 176,
|
||||
child: Stack(
|
||||
children: [
|
||||
const Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFDDE1FF), Color(0xFFF4F3F8)],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Positioned.fill(
|
||||
child: CustomPaint(painter: _DotPatternPainter()),
|
||||
),
|
||||
Center(
|
||||
child: XIcon(
|
||||
'location',
|
||||
size: 48,
|
||||
color: AppColors.primary.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 12,
|
||||
child: GestureDetector(
|
||||
onTap: () => showAppToast('打开导航'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x4D0052FF),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
XIcon('navigation', size: 20, color: Colors.white),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'导航',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 工单卡片,对应源 `.job-card`。
|
||||
class _JobCard extends StatelessWidget {
|
||||
const _JobCard({required this.order});
|
||||
|
||||
final Order order;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return XCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
'JOB ID: #${order.jobId}',
|
||||
style: AppTypography.labelCaps.copyWith(
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
order.title,
|
||||
style: AppTypography.headlineMd.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
XTypeChip(
|
||||
type: order.type,
|
||||
text: order.detailTypeText,
|
||||
compact: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 描述块
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.borderSoft),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 2),
|
||||
child: XIcon('warning', size: 20, color: AppColors.error),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'"${order.description}"',
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MetaItem(label: '预约时间', value: order.schedule),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 40,
|
||||
color: AppColors.borderSoft,
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16),
|
||||
child: _MetaItem(
|
||||
label: '预计时长',
|
||||
value: order.estimatedDuration,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaItem extends StatelessWidget {
|
||||
const _MetaItem({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value.isEmpty ? '-' : value,
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户卡片,对应源 `.customer-card`。
|
||||
class _CustomerCard extends StatelessWidget {
|
||||
const _CustomerCard({required this.order});
|
||||
|
||||
final Order order;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return XCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
// 后端无客户头像,用姓名首字占位(与「我的」页头像一致)
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
order.customerAvatarText,
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
order.customerName.isEmpty ? '未知客户' : order.customerName,
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
order.customerPhone.isEmpty
|
||||
? '暂无联系方式'
|
||||
: order.customerPhone,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
GestureDetector(
|
||||
onTap: () => showAppToast('拨打电话'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const XIcon('call', size: 18, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 现场照片横向列表。
|
||||
class _PhotosSection extends StatelessWidget {
|
||||
const _PhotosSection({required this.photos});
|
||||
|
||||
final List<String> photos;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'现场照片',
|
||||
style: AppTypography.bodySm.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textMuted,
|
||||
letterSpacing: 0.26,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 128,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (final url in photos) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
child: Image.network(
|
||||
url,
|
||||
width: 128,
|
||||
height: 128,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
width: 128,
|
||||
height: 128,
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
child: const XIcon(
|
||||
'camera',
|
||||
size: 28,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
GestureDetector(
|
||||
onTap: () => showAppToast('添加现场照片'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: 128,
|
||||
height: 128,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(
|
||||
color: AppColors.textPlaceholder,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
XIcon('add-photo', size: 32, color: AppColors.primary),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'添加',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 费用卡片,对应源 `.financial-card`。
|
||||
class _FinancialCard extends StatelessWidget {
|
||||
const _FinancialCard({required this.order});
|
||||
|
||||
final Order order;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return XCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Text(
|
||||
'预计收益',
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
_row('人工费', '¥${_money(order.laborFee)}'),
|
||||
const SizedBox(height: 12),
|
||||
_row('预计材料费', '¥${_money(order.materialFee)}'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(top: BorderSide(color: AppColors.borderSoft)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'总计预估',
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'¥${_money(order.amount)}',
|
||||
style: AppTypography.titleLg.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const XIcon('info', size: 18, color: AppColors.textMuted),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'最终金额将根据现场核查和实际材料使用情况而定。',
|
||||
style: AppTypography.labelCaps.copyWith(
|
||||
color: AppColors.textMuted,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String label, String value) => Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurfaceVariant),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
value,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 服务地点卡片。
|
||||
class _LocationCard extends StatelessWidget {
|
||||
const _LocationCard({required this.address});
|
||||
|
||||
final String address;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return XCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'服务地点',
|
||||
style: AppTypography.bodySm.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textMuted,
|
||||
letterSpacing: 0.26,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 2),
|
||||
child: XIcon('location', size: 20, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
address.isEmpty ? '地址待确认' : address,
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部固定「开始出发」按钮。
|
||||
class _BottomAction extends StatelessWidget {
|
||||
const _BottomAction({required this.onStart});
|
||||
|
||||
final VoidCallback onStart;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.headerGlass,
|
||||
border: Border(top: BorderSide(color: AppColors.borderSoft)),
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: onStart,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
height: 54,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x4D0052FF),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
XIcon('bolt', size: 24, color: Colors.white),
|
||||
SizedBox(width: 10),
|
||||
Text(
|
||||
'开始出发',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 源 `.map-bg::before` 的圆点纹理(100×100 平铺,品牌蓝 8% 透明度)。
|
||||
class _DotPatternPainter extends CustomPainter {
|
||||
const _DotPatternPainter();
|
||||
|
||||
static const _dots = <(double, double, double)>[
|
||||
(11, 11, 7),
|
||||
(59, 36, 7),
|
||||
(16, 33, 3),
|
||||
(79, 64, 3),
|
||||
(34, 87, 3),
|
||||
(90, 11, 3),
|
||||
(12, 82, 4),
|
||||
(40, 17, 4),
|
||||
(63, 5, 5),
|
||||
(57, 66, 4),
|
||||
(86, 87, 5),
|
||||
(32, 58, 5),
|
||||
(89, 45, 5),
|
||||
(80, 27, 2),
|
||||
(60, 89, 2),
|
||||
(35, 39, 2),
|
||||
(12, 58, 2),
|
||||
];
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..color = const Color(0x140052FF);
|
||||
for (var ox = 0.0; ox < size.width; ox += 100) {
|
||||
for (var oy = 0.0; oy < size.height; oy += 100) {
|
||||
for (final (cx, cy, r) in _dots) {
|
||||
canvas.drawCircle(Offset(ox + cx, oy + cy), r, paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -0,0 +1,647 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../core/utils/format_util.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_chip.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_icon.dart';
|
||||
import '../../../shared/widgets/x_top_bar.dart';
|
||||
import '../application/order_providers.dart';
|
||||
import '../data/order_models.dart';
|
||||
|
||||
/// 金额展示:整数不带小数,否则保留两位(源 mock 里金额是整数)。
|
||||
String _money(num v) =>
|
||||
v == v.roundToDouble() ? v.toStringAsFixed(0) : formatMoney(v);
|
||||
|
||||
/// 订单 Tab(待接订单),对应源 `pages/order/index.vue`。
|
||||
///
|
||||
/// 顶栏与地图区固定,只有下方订单列表滚动;沿用源 `.orders-section`
|
||||
/// 24px 顶部圆角「上移压住地图」的观感(把地图高度相应减 24,视觉等价)。
|
||||
class OrderTab extends ConsumerStatefulWidget {
|
||||
const OrderTab({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<OrderTab> createState() => _OrderTabState();
|
||||
}
|
||||
|
||||
class _OrderTabState extends ConsumerState<OrderTab> {
|
||||
/// 本会话内已接单的订单 id(接单后列表会刷新移除,这里用于渲染「已接单」态)。
|
||||
final _grabbed = <String>{};
|
||||
String? _grabbing;
|
||||
|
||||
Future<void> _handleGrab(Order order) async {
|
||||
if (_grabbing != null || _grabbed.contains(order.id)) return;
|
||||
setState(() => _grabbing = order.id);
|
||||
try {
|
||||
final res = await ref
|
||||
.read(orderApiProvider)
|
||||
.grab(order.source, order.intId);
|
||||
if (!mounted) return;
|
||||
if (res.isOk) {
|
||||
setState(() => _grabbed.add(order.id));
|
||||
showAppToast('接单成功', success: true);
|
||||
ref.invalidate(orderPoolProvider);
|
||||
} else {
|
||||
showAppToast(res.message.isEmpty ? '接单失败' : res.message);
|
||||
}
|
||||
} on ApiException catch (e) {
|
||||
if (mounted) showAppToast(e.message);
|
||||
} finally {
|
||||
if (mounted) setState(() => _grabbing = null);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pool = ref.watch(orderPoolProvider);
|
||||
final unread = ref.watch(noticeUnreadCountProvider);
|
||||
|
||||
return XPageShell(
|
||||
topBar: XTopBar.brand(
|
||||
height: 64,
|
||||
logoSize: 28,
|
||||
titleSize: 20,
|
||||
titleWeight: FontWeight.w700,
|
||||
trailingBadge: unread,
|
||||
onTrailingTap: () => context.push('/notice'),
|
||||
),
|
||||
child: RefreshIndicator(
|
||||
color: AppColors.primary,
|
||||
onRefresh: () async {
|
||||
ref.invalidate(orderPoolProvider);
|
||||
await ref.read(orderPoolProvider.future);
|
||||
},
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
// 源地图高 320,被下方 24px 圆角区块遮住底部 → 这里直接取 296
|
||||
SizedBox(height: 296, child: const _MapPlaceholder()),
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.bgPage,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(AppRadius.xxl),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 120),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildSectionHeader(pool),
|
||||
const SizedBox(height: 16),
|
||||
..._buildBody(pool),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(AsyncValue<List<Order>> pool) {
|
||||
final count = pool.value?.length;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'待接订单',
|
||||
style: AppTypography.navLargeTitle.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
count == null ? '正在获取附近订单…' : '附近有 $count 个订单',
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => showAppToast('筛选功能'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'筛选',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const XIcon('filter', size: 18, color: AppColors.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildBody(AsyncValue<List<Order>> pool) {
|
||||
if (pool.isLoading && !pool.hasValue) {
|
||||
return const [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 60),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.4,
|
||||
valueColor: AlwaysStoppedAnimation(AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (pool.hasError && !pool.hasValue) {
|
||||
return [
|
||||
XPlaceholder(text: '订单加载失败\n${pool.error}'),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: () => ref.invalidate(orderPoolProvider),
|
||||
child: const Text('点击重试'),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
final orders = pool.value ?? const <Order>[];
|
||||
if (orders.isEmpty) {
|
||||
return const [XPlaceholder(text: '附近暂无待接订单')];
|
||||
}
|
||||
return [
|
||||
for (final order in orders)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _OrderCard(
|
||||
order: order,
|
||||
grabbing: _grabbing == order.id,
|
||||
grabbed: _grabbed.contains(order.id),
|
||||
onGrab: () => _handleGrab(order),
|
||||
onTap: () => context.push('/order/${order.source}/${order.intId}'),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// 单张待接订单卡片,对应源 `.order-card`。
|
||||
class _OrderCard extends StatelessWidget {
|
||||
const _OrderCard({
|
||||
required this.order,
|
||||
required this.grabbing,
|
||||
required this.grabbed,
|
||||
required this.onGrab,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final Order order;
|
||||
final bool grabbing;
|
||||
final bool grabbed;
|
||||
final VoidCallback onGrab;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppColors.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.borderSoft),
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
boxShadow: AppShadows.orderCard,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'#${order.displayNo}',
|
||||
style: AppTypography.labelCaps.copyWith(
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
XTypeChip(type: order.type, text: order.listTypeText),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'¥${_money(order.amount)}',
|
||||
style: AppTypography.headlineMd.copyWith(
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'预估收益',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
order.title,
|
||||
style: AppTypography.headlineMd.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const XIcon(
|
||||
'location',
|
||||
size: 16,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${order.distance}km',
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
const XIcon(
|
||||
'clock',
|
||||
size: 16,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
order.schedule,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildGrabButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGrabButton() {
|
||||
final (Color bg, Color fg, BoxShadow? shadow, Widget icon) = switch ((
|
||||
grabbing,
|
||||
grabbed,
|
||||
order.type,
|
||||
)) {
|
||||
(true, _, _) => (
|
||||
AppColors.primary,
|
||||
Colors.white,
|
||||
_primaryShadow,
|
||||
const _SpinningRefresh(),
|
||||
),
|
||||
(_, true, _) => (
|
||||
AppColors.tertiaryFixed,
|
||||
AppColors.onTertiaryFixedVariant,
|
||||
null,
|
||||
const XIcon('check', size: 20, color: AppColors.onTertiaryFixedVariant),
|
||||
),
|
||||
(_, _, 'urgent') => (
|
||||
AppColors.primary,
|
||||
Colors.white,
|
||||
_primaryShadow,
|
||||
const XIcon('forward', size: 20, color: Colors.white),
|
||||
),
|
||||
_ => (
|
||||
AppColors.surfaceContainerLow,
|
||||
AppColors.onSurface,
|
||||
null,
|
||||
const XIcon('forward', size: 20, color: AppColors.onSurface),
|
||||
),
|
||||
};
|
||||
|
||||
final label = grabbing
|
||||
? '接单中...'
|
||||
: grabbed
|
||||
? '已接单'
|
||||
: (order.type == 'urgent' ? '立即接单' : '接单');
|
||||
final outlined = !grabbing && !grabbed && order.type != 'urgent';
|
||||
|
||||
return Opacity(
|
||||
opacity: grabbing ? 0.7 : 1,
|
||||
child: GestureDetector(
|
||||
onTap: (grabbing || grabbed) ? null : onGrab,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
height: 48,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: outlined
|
||||
? Border.all(color: const Color(0x80C3C5D9))
|
||||
: null,
|
||||
boxShadow: shadow == null ? null : [shadow],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
icon,
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: fg,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static const _primaryShadow = BoxShadow(
|
||||
color: Color(0x4D0052FF),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 4),
|
||||
);
|
||||
}
|
||||
|
||||
/// 接单中的旋转刷新图标。
|
||||
class _SpinningRefresh extends StatefulWidget {
|
||||
const _SpinningRefresh();
|
||||
|
||||
@override
|
||||
State<_SpinningRefresh> createState() => _SpinningRefreshState();
|
||||
}
|
||||
|
||||
class _SpinningRefreshState extends State<_SpinningRefresh>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _c = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 1),
|
||||
)..repeat();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => RotationTransition(
|
||||
turns: _c,
|
||||
child: const XIcon('refresh', size: 20, color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
/// 地图占位:源 `.map-bg` 的圆点纹理 + 两个标记 + 右侧控制按钮。
|
||||
class _MapPlaceholder extends StatelessWidget {
|
||||
const _MapPlaceholder();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
const Positioned.fill(
|
||||
child: ColoredBox(color: AppColors.surfaceContainerHighest),
|
||||
),
|
||||
const Positioned.fill(child: CustomPaint(painter: _DotPatternPainter())),
|
||||
// 紧急标记(带脉冲光环)
|
||||
const Positioned(
|
||||
top: 40,
|
||||
left: 60,
|
||||
child: _PulsingMarker(),
|
||||
),
|
||||
// 普通标记
|
||||
Positioned(
|
||||
top: 120,
|
||||
right: 80,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const XIcon('shandian', size: 18, color: Colors.white),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 48,
|
||||
child: Column(
|
||||
children: [
|
||||
_MapControlButton(
|
||||
icon: 'location',
|
||||
onTap: () => showAppToast('定位中...'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_MapControlButton(
|
||||
icon: 'map',
|
||||
onTap: () => showAppToast('图层切换'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 紧急标记:红色胶囊 + 呼吸光环,对应源 `.marker.urgent`。
|
||||
class _PulsingMarker extends StatefulWidget {
|
||||
const _PulsingMarker();
|
||||
|
||||
@override
|
||||
State<_PulsingMarker> createState() => _PulsingMarkerState();
|
||||
}
|
||||
|
||||
class _PulsingMarkerState extends State<_PulsingMarker>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _c = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _c,
|
||||
builder: (context, _) => Container(
|
||||
width: 76 + 24 * _c.value,
|
||||
height: 32 + 24 * _c.value,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.secondaryContainer.withValues(
|
||||
alpha: 0.4 * (1 - _c.value),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x1A000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
XIcon('shandian', size: 14, color: Colors.white),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'紧急',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MapControlButton extends StatelessWidget {
|
||||
const _MapControlButton({required this.icon, required this.onTap});
|
||||
|
||||
final String icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xE6FFFFFF),
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x14000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: XIcon(icon, size: 20, color: AppColors.primary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 源 `.map-bg::before` 的圆点纹理(100×100 平铺,品牌蓝 8% 透明度)。
|
||||
class _DotPatternPainter extends CustomPainter {
|
||||
const _DotPatternPainter();
|
||||
|
||||
/// 取自源 SVG 里的 (cx, cy, r) 列表。
|
||||
static const _dots = <(double, double, double)>[
|
||||
(11, 11, 7),
|
||||
(59, 36, 7),
|
||||
(16, 33, 3),
|
||||
(79, 64, 3),
|
||||
(34, 87, 3),
|
||||
(90, 11, 3),
|
||||
(12, 82, 4),
|
||||
(40, 17, 4),
|
||||
(63, 5, 5),
|
||||
(57, 66, 4),
|
||||
(86, 87, 5),
|
||||
(32, 58, 5),
|
||||
(89, 45, 5),
|
||||
(80, 27, 2),
|
||||
(60, 89, 2),
|
||||
(35, 39, 2),
|
||||
(12, 58, 2),
|
||||
];
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..color = const Color(0x140052FF);
|
||||
for (var ox = 0.0; ox < size.width; ox += 100) {
|
||||
for (var oy = 0.0; oy < size.height; oy += 100) {
|
||||
for (final (cx, cy, r) in _dots) {
|
||||
canvas.drawCircle(Offset(ox + cx, oy + cy), r, paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/providers.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../shared/widgets/app_feedback.dart';
|
||||
import '../../../shared/widgets/x_card.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_icon.dart';
|
||||
import '../../../shared/widgets/x_top_bar.dart';
|
||||
import '../../auth/application/session_controller.dart';
|
||||
import '../../auth/data/auth_models.dart';
|
||||
|
||||
/// 渐变卡内各级白色透明度,对应源 `rgba(255,255,255,x)`。
|
||||
const _white80 = Color(0xCCFFFFFF);
|
||||
const _white60 = Color(0x99FFFFFF);
|
||||
const _white20 = Color(0x33FFFFFF);
|
||||
const _white15 = Color(0x26FFFFFF);
|
||||
const _white10 = Color(0x1AFFFFFF);
|
||||
|
||||
/// 个人资料接口。后端未提供独立 provider,故在此局部声明。
|
||||
/// 源页面「我的」只展示一张证书,这里取 `WorkerProfile.primaryCert`。
|
||||
final _profileProvider = FutureProvider.autoDispose<WorkerProfile>((ref) async {
|
||||
final res = await ref.watch(authApiProvider).profile();
|
||||
if (!res.isOk || res.data == null) throw ApiException(res.message);
|
||||
return res.data!;
|
||||
});
|
||||
|
||||
/// 「我的」Tab,逐像素复刻源 `src/pages/profile/index.vue`。
|
||||
///
|
||||
/// 顶栏由本页渲染;底部 Tab 栏由 `TabsPage` 提供。
|
||||
/// 「专业技能」区块与「从业年限」统计项后端无对应字段,已按约定省略。
|
||||
class ProfileTab extends ConsumerWidget {
|
||||
const ProfileTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final session = ref.watch(sessionProvider);
|
||||
final profileAsync = ref.watch(_profileProvider);
|
||||
final unread = ref.watch(noticeUnreadCountProvider);
|
||||
final profile = profileAsync.value;
|
||||
final worker = profile?.basic ?? session.worker;
|
||||
|
||||
return XPageShell(
|
||||
topBar: XTopBar.brand(
|
||||
height: 56,
|
||||
logoSize: 28,
|
||||
titleSize: 18,
|
||||
titleWeight: FontWeight.w600,
|
||||
trailingBadge: unread,
|
||||
onTrailingTap: () => context.push('/notice'),
|
||||
),
|
||||
child: worker == null
|
||||
? _placeholder(profileAsync)
|
||||
: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 96),
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 24, 16, 24),
|
||||
child: XPageTitle(
|
||||
title: '我的',
|
||||
subtitle: '管理您的个人资料与账户设置',
|
||||
),
|
||||
),
|
||||
_profileCard(worker, profile?.primaryCert),
|
||||
_menuSection(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: _logoutButton(ref),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 无缓存资料时展示加载 / 错误态。
|
||||
Widget _placeholder(AsyncValue<WorkerProfile> async) {
|
||||
final text = async.when(
|
||||
loading: () => '加载中...',
|
||||
error: (error, _) => error.toString(),
|
||||
data: (_) => '加载中...',
|
||||
);
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [XPlaceholder(text: text)],
|
||||
);
|
||||
}
|
||||
|
||||
/// 资料卡:渐变底 + 头像 + 姓名 / ID + 证书区 + 两项统计。
|
||||
Widget _profileCard(WorkerInfo worker, CertInfo? cert) {
|
||||
final certType = cert?.certType ?? '';
|
||||
final certTitle = certType.isEmpty ? '电工职业资格证' : certType;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.primaryDeep, AppColors.primary],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.onPrimary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
worker.avatarText,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
child: Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: AppColors.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
worker.displayName,
|
||||
style: AppTypography.titleLg.copyWith(
|
||||
color: AppColors.onPrimary,
|
||||
letterSpacing: -0.44,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _white20,
|
||||
borderRadius: BorderRadius.circular(AppRadius.regular),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'ID',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _white80,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'${worker.id}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.onPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: _white15,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const XIcon('cert', size: 18, color: AppColors.onPrimary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
certTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
_certBadge(worker),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (cert == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
'暂无资质证照',
|
||||
style: TextStyle(fontSize: 13, color: _white60),
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
_certRow('证书编号', cert.certNo),
|
||||
_certRow('有效期至', cert.expireDate, divider: false),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _statItem('${worker.completedCount}', '已完成订单')),
|
||||
Container(width: 1, height: 40, color: _white20),
|
||||
Expanded(
|
||||
child: _statItem(worker.rating.toStringAsFixed(1), '评分'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 审核通过显示绿色「已认证」,否则以弱化样式显示审核状态文案。
|
||||
Widget _certBadge(WorkerInfo worker) {
|
||||
final approved = worker.isApproved;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: approved ? AppColors.success : _white20,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
approved ? '已认证' : worker.auditStatusText,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: approved ? AppColors.onPrimary : _white80,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _certRow(String label, String value, {bool divider = true}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: divider ? const Border(bottom: BorderSide(color: _white10)) : null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(label, style: const TextStyle(fontSize: 13, color: _white80)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statItem(String value, String label) => Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.onPrimary,
|
||||
letterSpacing: -0.48,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: _white80)),
|
||||
],
|
||||
);
|
||||
|
||||
/// 两组 `ios-card` 菜单,行点击仅提示(同源实现)。
|
||||
Widget _menuSection() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
_menuGroup(const [
|
||||
('order', '我的订单'),
|
||||
('earnings', '收益记录'),
|
||||
('notify', '账户设置'),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
_menuGroup(const [('star', '帮助中心'), ('shandian', '关于我们')]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _menuGroup(List<(String, String)> items) {
|
||||
return XCard(
|
||||
radius: AppRadius.lg,
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++) ...[
|
||||
if (i > 0)
|
||||
Container(
|
||||
height: 1,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
color: AppColors.borderSoft,
|
||||
),
|
||||
_menuRow(items[i].$1, items[i].$2),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _menuRow(String icon, String label) {
|
||||
return GestureDetector(
|
||||
onTap: () => showAppToast(label),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
XIcon(icon, size: 22, color: AppColors.onSurfaceVariant),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: AppTypography.bodyLg.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const XIcon(
|
||||
'arrow-right',
|
||||
size: 16,
|
||||
color: AppColors.textPlaceholder,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _logoutButton(WidgetRef ref) {
|
||||
return XCard(
|
||||
radius: AppRadius.lg,
|
||||
padding: EdgeInsets.zero,
|
||||
onTap: () => _handleLogout(ref),
|
||||
child: const SizedBox(
|
||||
height: 48,
|
||||
width: double.infinity,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'退出登录',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.tertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleLogout(WidgetRef ref) async {
|
||||
final confirmed = await showAppConfirm(
|
||||
title: '提示',
|
||||
content: '确定要退出登录吗?',
|
||||
confirmText: '确定',
|
||||
cancelText: '取消',
|
||||
);
|
||||
if (!confirmed) return;
|
||||
await ref.read(sessionProvider.notifier).logout();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user