feat: 完成智能电网电工端 Flutter 应用初始化

创建完整的 Flutter 项目结构,包含 Android 和 iOS 平台配置、核心网络层、路由、状态管理及业务模块(订单、收益、认证),适配原 uni-app 项目功能逻辑
This commit is contained in:
andyesfly committed 2026-10-06 11:54:10 +08:00
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;
}
+58
View File
@@ -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>()),
);
}
+151
View File
@@ -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],
];
}
+149
View File
@@ -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!;
});
+46
View File
@@ -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();
}
}
+113
View File
@@ -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();
}
}