feat: 完成 Flutter 版智能电网 IoT 管理应用初始化搭建

This commit is contained in:
andyesfly committed 2026-10-02 17:21:53 +08:00
commit f61b7bf90b
120 files changed
+15229

No files matched your search

@@ -0,0 +1,95 @@
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.user, required this.isLoggedIn});
final UserInfo? user;
final bool isLoggedIn;
SessionState copyWith({UserInfo? user, bool? isLoggedIn, bool clearUser = false}) =>
SessionState(
user: clearUser ? null : (user ?? this.user),
isLoggedIn: isLoggedIn ?? this.isLoggedIn,
);
}
/// 登录态,对应 uni-app 版 `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(
user: cached == null ? null : UserInfo.fromJson(cached),
isLoggedIn: _storage.hasSession,
);
}
Future<bool> login({
required String username,
required String password,
}) async {
final res = await ref
.read(authApiProvider)
.login(username: username, password: password);
if (!res.isOk || res.data == null) return false;
final data = res.data!;
await _storage.setAccessToken(data.accessToken);
if (data.refreshToken.isNotEmpty) {
await _storage.setRefreshToken(data.refreshToken);
}
final user = UserInfo(
id: data.userInfo.id,
username: data.userInfo.username,
realName: data.userInfo.realName,
roles: data.roles,
);
await _storage.setUserInfo(user.toJson());
state = SessionState(user: user, isLoggedIn: true);
return true;
}
Future<bool> fetchUserInfo() async {
try {
final res = await ref.read(authApiProvider).currentUser();
if (res.isOk && res.data != null) {
await _storage.setUserInfo(res.data!.toJson());
state = SessionState(user: res.data, isLoggedIn: true);
return true;
}
} catch (_) {
// 拉取失败保持原状态
}
return false;
}
Future<void> logout() async {
await _storage.clearSession();
state = const SessionState(isLoggedIn: false, user: null);
}
/// 清空本地会话但不触发网络请求(被拦截器判定会话失效时调用)。
Future<void> clearLocal() => logout();
}
final sessionProvider = NotifierProvider<SessionController, SessionState>(
SessionController.new,
);
+27
View File
@@ -0,0 +1,27 @@
import '../../../core/network/api_client.dart';
import '../../../core/network/api_response.dart';
import 'auth_models.dart';
/// 认证相关接口,对应 uni-app 版 `api/auth.ts`。
class AuthApi {
AuthApi(this._client);
final ApiClient _client;
Future<ApiResponse<LoginResult>> login({
required String username,
required String password,
}) => _client.post<LoginResult>(
'/auth/login',
body: {'username': username, 'password': password},
parse: (d) => LoginResult.fromJson((d as Map).cast<String, dynamic>()),
needAuth: false,
);
Future<ApiResponse<UserInfo>> currentUser() => _client.get<UserInfo>(
'/auth/currentUser',
parse: (d) => UserInfo.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<void>> logout() => _client.post<void>('/auth/logout');
}
+80
View File
@@ -0,0 +1,80 @@
import '../../../core/utils/id_util.dart';
/// 登录态用户信息,对应 `types/auth.ts` 的 `UserInfo`。
class UserInfo {
const UserInfo({
required this.id,
required this.username,
required this.realName,
required this.roles,
});
final int id;
final String username;
final String realName;
final List<String> roles;
String get displayName => realName.isNotEmpty ? realName : username;
/// 头像占位文字:取显示名首个字符(按 Unicode 码点截取,兼容中文)。
String get avatarText {
if (displayName.isEmpty) return '?';
return String.fromCharCode(displayName.runes.first);
}
bool get isAdmin => roles.contains('ADMIN');
factory UserInfo.fromJson(Map<String, dynamic> json) => UserInfo(
id: toIntOr(json['id'], 0),
username: json['username'] as String? ?? '',
realName: json['realName'] as String? ?? '',
roles: (json['roles'] as List?)?.map((e) => e.toString()).toList() ?? const [],
);
Map<String, dynamic> toJson() => {
'id': id,
'username': username,
'realName': realName,
'roles': roles,
};
}
/// 登录接口返回体,对应 `types/auth.ts` 的 `LoginResponse`。
class LoginResult {
const LoginResult({
required this.accessToken,
required this.refreshToken,
required this.userInfo,
required this.roles,
});
final String accessToken;
final String refreshToken;
final UserInfo userInfo;
final List<String> roles;
factory LoginResult.fromJson(Map<String, dynamic> json) => LoginResult(
accessToken: json['accessToken'] as String? ?? '',
refreshToken: json['refreshToken'] as String? ?? '',
userInfo: UserInfo.fromJson(
(json['userInfo'] as Map?)?.cast<String, dynamic>() ?? const {},
),
roles: (json['roles'] as List?)?.map((e) => e.toString()).toList() ?? const [],
);
}
/// 后端角色编码 → 中文名称映射。
const roleLabelMap = <String, String>{
'ADMIN': '运营管理员',
'SALESMAN': '业务员',
'WORKER': '共享电工',
'CUSTOMER': '客户',
};
/// 将角色编码数组映射为可读文本(多个角色以 / 分隔)。
String roleLabel(List<String>? roles) {
if (roles != null && roles.isNotEmpty) {
return roles.map((r) => roleLabelMap[r] ?? r).join(' / ');
}
return '运营管理员';
}
@@ -0,0 +1,279 @@
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 '../../../shared/widgets/x_card.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../application/session_controller.dart';
/// 登录页,对应 `pages/login/index.vue`。
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _username = TextEditingController();
final _password = TextEditingController();
bool _loading = false;
String _errorMsg = '';
@override
void dispose() {
_username.dispose();
_password.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
final username = _username.text.trim();
final password = _password.text;
if (username.isEmpty || password.isEmpty) {
setState(() => _errorMsg = '请输入账号和密码');
return;
}
setState(() {
_loading = true;
_errorMsg = '';
});
try {
final ok = await ref
.read(sessionProvider.notifier)
.login(username: username, password: password);
if (!mounted) return;
if (ok) {
context.go('/tabs');
} else {
setState(() => _errorMsg = '登录失败,请检查账号密码');
}
} catch (e) {
if (!mounted) return;
setState(() => _errorMsg = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.bgPage,
body: Stack(
children: [
// 源页面右上/左下的两处柔光渐变
const Positioned(
right: -80,
top: -80,
child: _SoftBlob(color: Color(0x0F0054CD), size: 280),
),
const Positioned(
left: -80,
bottom: -80,
child: _SoftBlob(color: Color(0x0D712AE2), size: 280),
),
LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: (constraints.maxHeight - 80).clamp(0, double.infinity),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildBrand(),
const SizedBox(height: 40),
_buildForm(),
const SizedBox(height: 40),
Text(
'© 鑫潼智电 · 运营管理',
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
),
),
),
),
],
),
);
}
Widget _buildBrand() {
return Column(
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.brandGradientStart, AppColors.brandGradientEnd],
),
borderRadius: BorderRadius.circular(20),
boxShadow: const [
BoxShadow(
color: Color(0x401D6FFF),
blurRadius: 20,
offset: Offset(0, 8),
),
],
),
alignment: Alignment.center,
child: const XIcon('bolt', size: 34, color: Colors.white),
),
const SizedBox(height: 16),
Text(
'鑫潼智电',
style: AppTypography.headlineMd.copyWith(
fontSize: 26,
letterSpacing: 2,
),
),
const SizedBox(height: 4),
const Text(
'鑫潼智电运营管理平台',
style: TextStyle(fontSize: 14, color: AppColors.textMuted),
),
],
);
}
Widget _buildForm() {
return XCard(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildField(
label: '账号',
icon: 'person',
controller: _username,
hintText: '请输入账号',
obscure: false,
),
const SizedBox(height: 18),
_buildField(
label: '密码',
icon: 'more',
controller: _password,
hintText: '请输入密码',
obscure: true,
),
const SizedBox(height: 8),
XPrimaryButton(
label: '登 录',
loading: _loading,
onPressed: _handleLogin,
),
if (_errorMsg.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 14),
child: Text(
_errorMsg,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
color: AppColors.statusError,
),
),
),
],
),
);
}
Widget _buildField({
required String label,
required String icon,
required TextEditingController controller,
required String hintText,
required bool obscure,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: AppTypography.labelBold.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.borderRule),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Row(
children: [
XIcon(icon, size: 20, color: AppColors.textMuted),
const SizedBox(width: 10),
Expanded(
child: TextField(
controller: controller,
obscureText: obscure,
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,
),
),
),
),
],
),
),
],
);
}
}
/// 登录页背景的柔光圆斑(对应源页面两处 radial-gradient)。
class _SoftBlob extends StatelessWidget {
const _SoftBlob({required this.color, required this.size});
final Color color;
final double size;
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [color, color.withValues(alpha: 0)],
),
),
),
);
}
}
@@ -0,0 +1,77 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import 'customer_models.dart';
final customerApiProvider = Provider<CustomerApi>(
(ref) => CustomerApi(ref.watch(apiClientProvider)),
);
/// 客户档案接口,对应 uni-app 版 `api/customer.ts`。
class CustomerApi {
CustomerApi(this._client);
final ApiClient _client;
Future<ApiResponse<CustomerPage>> list({
int page = 1,
int pageSize = 100,
String? keyword,
int? status,
int? salesmanId,
}) => _client.get<CustomerPage>(
'/customers',
query: {
'page': page,
'pageSize': pageSize,
'keyword': keyword,
'status': status,
'salesmanId': salesmanId,
},
parse: (d) {
final m = (d as Map).cast<String, dynamic>();
return CustomerPage(
list: (m['list'] as List? ?? [])
.map((e) => Customer.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
total: (m['total'] as num?)?.toInt() ?? 0,
page: (m['page'] as num?)?.toInt() ?? 1,
pageSize: (m['pageSize'] as num?)?.toInt() ?? pageSize,
);
},
);
Future<ApiResponse<Customer>> detail(int id) => _client.get<Customer>(
'/customers/$id',
parse: (d) => Customer.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<Customer>> create(Map<String, dynamic> body) =>
_client.post<Customer>(
'/customers',
body: body,
parse: (d) => Customer.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<Customer>> update(int id, Map<String, dynamic> body) =>
_client.put<Customer>(
'/customers/$id',
body: body,
parse: (d) => Customer.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<void>> delete(int id) => _client.delete<void>('/customers/$id');
Future<ApiResponse<void>> updateStatus(int id, int status) =>
_client.put<void>('/customers/$id/status', body: {'status': status});
Future<ApiResponse<List<CustomerOption>>> options() =>
_client.get<List<CustomerOption>>(
'/customers/options/all',
parse: (d) => (d as List)
.map((e) => CustomerOption.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
);
}
@@ -0,0 +1,143 @@
import 'package:flutter/painting.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/utils/id_util.dart';
/// 客户档案,与后端 `/api/customers` 契约对齐(`types/customer.ts`)。
class Customer {
const Customer({
required this.id,
required this.customerNo,
required this.customerName,
required this.province,
required this.city,
required this.district,
required this.address,
required this.contactPerson,
required this.contactPhone,
required this.postalCode,
required this.fax,
required this.customerLevel,
required this.status,
this.salesmanId,
this.createdAt,
this.updatedAt,
});
final int id;
final String customerNo;
final String customerName;
final String province;
final String city;
final String district;
final String address;
final String contactPerson;
final String contactPhone;
final String postalCode;
final String fax;
final String customerLevel;
final int status;
final int? salesmanId;
final String? createdAt;
final String? updatedAt;
factory Customer.fromJson(Map<String, dynamic> json) => Customer(
id: toIntOr(json['id'], 0),
customerNo: json['customerNo'] as String? ?? '',
customerName: json['customerName'] as String? ?? '',
province: json['province'] as String? ?? '',
city: json['city'] as String? ?? '',
district: json['district'] as String? ?? '',
address: json['address'] as String? ?? '',
contactPerson: json['contactPerson'] as String? ?? '',
contactPhone: json['contactPhone'] as String? ?? '',
postalCode: json['postalCode'] as String? ?? '',
fax: json['fax'] as String? ?? '',
customerLevel: json['customerLevel'] as String? ?? '',
status: toIntOr(json['status'], 1),
salesmanId: toIntOrNull(json['salesmanId']),
createdAt: json['created_at'] as String?,
updatedAt: json['updated_at'] as String?,
);
Map<String, dynamic> toJson() => {
'id': id,
'customerNo': customerNo,
'customerName': customerName,
'province': province,
'city': city,
'district': district,
'address': address,
'contactPerson': contactPerson,
'contactPhone': contactPhone,
'postalCode': postalCode,
'fax': fax,
'customerLevel': customerLevel,
'status': status,
if (salesmanId != null) 'salesmanId': salesmanId,
};
/// 列表卡片右侧的「地区」展示:省市拼接,缺失时提示未填写。
String get locationText {
final text = '$province$city';
return text.isEmpty ? '未填写地区' : text;
}
}
/// 客户下拉选项(拜访/客户关系选择用)。
class CustomerOption {
const CustomerOption({
required this.id,
required this.customerNo,
required this.customerName,
required this.status,
});
final int id;
final String customerNo;
final String customerName;
final int status;
factory CustomerOption.fromJson(Map<String, dynamic> json) => CustomerOption(
id: toIntOr(json['id'], 0),
customerNo: json['customerNo'] as String? ?? '',
customerName: json['customerName'] as String? ?? '',
status: toIntOr(json['status'], 1),
);
}
class CustomerPage {
const CustomerPage({
required this.list,
required this.total,
required this.page,
required this.pageSize,
});
final List<Customer> list;
final int total;
final int page;
final int pageSize;
}
/// 客户状态:1 意向 2 签约 3 运维中 4 终止
const customerStatusMap = <int, ({String text, ChipTone tone})>{
1: (text: '意向', tone: ChipTone.warn),
2: (text: '签约', tone: ChipTone.primary),
3: (text: '运维中', tone: ChipTone.tertiary),
4: (text: '终止', tone: ChipTone.error),
};
String customerStatusText(int status) => customerStatusMap[status]?.text ?? '未知';
ChipTone customerStatusTone(int status) =>
customerStatusMap[status]?.tone ?? ChipTone.muted;
/// 列表卡片左侧色条颜色。
Color customerStatusBarColor(int status) => switch (status) {
1 => AppColors.statusWarn,
2 => AppColors.primary,
3 => AppColors.tertiaryContainer,
4 => AppColors.statusError,
_ => AppColors.borderRule,
};
+99
View File
@@ -0,0 +1,99 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import '../../../core/utils/id_util.dart';
final userApiProvider = Provider<UserApi>(
(ref) => UserApi(ref.watch(apiClientProvider)),
);
class SysRole {
const SysRole({required this.id, required this.roleCode, required this.roleName});
final int id;
final String roleCode;
final String roleName;
factory SysRole.fromJson(Map<String, dynamic> json) => SysRole(
id: toIntOr(json['id'], 0),
roleCode: json['roleCode'] as String? ?? '',
roleName: json['roleName'] as String? ?? '',
);
}
class SysUser {
const SysUser({
required this.id,
required this.username,
required this.realName,
this.phone,
this.email,
this.status,
});
final int id;
final String username;
final String realName;
final String? phone;
final String? email;
final int? status;
/// 下拉项展示文本:姓名 · 手机号
String get optionLabel {
final name = realName.isNotEmpty ? realName : username;
return phone == null || phone!.isEmpty ? name : '$name · $phone';
}
factory SysUser.fromJson(Map<String, dynamic> json) => SysUser(
id: toIntOr(json['id'], 0),
username: json['username'] as String? ?? '',
realName: json['realName'] as String? ?? '',
phone: json['phone'] as String?,
email: json['email'] as String?,
status: toIntOrNull(json['status']),
);
}
/// 用户/角色接口,对应 uni-app 版 `api/user.ts`。
class UserApi {
UserApi(this._client);
final ApiClient _client;
Future<ApiResponse<List<SysRole>>> roles() => _client.get<List<SysRole>>(
'/users/roles',
parse: (d) => (d as List)
.map((e) => SysRole.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
);
Future<ApiResponse<List<SysUser>>> list({
int page = 1,
int pageSize = 100,
int? status,
int? roleId,
}) => _client.get<List<SysUser>>(
'/users',
query: {'page': page, 'pageSize': pageSize, 'status': status, 'roleId': roleId},
parse: (d) {
final m = (d as Map).cast<String, dynamic>();
return (m['list'] as List? ?? [])
.map((e) => SysUser.fromJson((e as Map).cast<String, dynamic>()))
.toList();
},
);
/// 业务员下拉选项:先按 roleCode 解析 SALESMAN 角色 id,再取其启用状态的用户。
Future<List<SysUser>> salesmanOptions() async {
final roleRes = await roles();
final salesmanRole = (roleRes.data ?? [])
.where((r) => r.roleCode == 'SALESMAN')
.firstOrNull;
if (salesmanRole == null) return const [];
final userRes = await list(page: 1, pageSize: 100, status: 1, roleId: salesmanRole.id);
return userRes.data ?? const [];
}
}
@@ -0,0 +1,443 @@
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_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 '../../visit/data/visit_api.dart';
import '../../visit/data/visit_models.dart';
import '../data/customer_api.dart';
import '../data/customer_models.dart';
/// 信息卡里的等宽数值(联系电话 / 传真 / 邮编)。
/// 源页面 `.info-value.font-data-mono`:字号 15 覆盖全局 12,字重 500、行高 16、等宽字体。
const _monoFallback = <String>['JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas'];
const _infoMonoStyle = TextStyle(
fontSize: 15,
height: 16 / 15,
fontWeight: FontWeight.w500,
fontFamilyFallback: _monoFallback,
);
/// 客户详情页,对应 `pages/customer/detail.vue`。
class CustomerDetailPage extends ConsumerStatefulWidget {
const CustomerDetailPage({super.key, required this.id});
final int id;
@override
ConsumerState<CustomerDetailPage> createState() => _CustomerDetailPageState();
}
class _CustomerDetailPageState extends ConsumerState<CustomerDetailPage> {
Customer? _customer;
bool _loading = true;
List<VisitInfo> _visits = const [];
int _visitTotal = 0;
bool _visitLoading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
await Future.wait([_loadCustomer(), _loadVisits()]);
}
Future<void> _loadCustomer() async {
setState(() => _loading = true);
try {
final res = await ref.read(customerApiProvider).detail(widget.id);
if (!mounted) return;
if (res.isOk && res.data != null) {
setState(() => _customer = res.data);
} else {
showAppToast(res.message.isEmpty ? '客户不存在' : res.message);
}
} catch (_) {
if (mounted) showAppToast('加载失败');
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadVisits() async {
setState(() => _visitLoading = true);
try {
final res = await ref
.read(visitApiProvider)
.list(customerId: widget.id, page: 1, pageSize: 5);
if (!mounted) return;
if (res.isOk && res.data != null) {
setState(() {
_visits = res.data!.list;
_visitTotal = res.data!.total;
});
}
} catch (_) {
// 拜访记录加载失败不影响客户详情展示
} finally {
if (mounted) setState(() => _visitLoading = false);
}
}
Future<void> _goEdit() async {
await context.push('/customer/edit?id=${widget.id}');
if (!mounted) return;
_load();
}
Future<void> _onDelete() async {
final confirmed = await showAppConfirm(
title: '删除客户',
content: '确定删除该客户吗?此操作不可恢复。',
destructive: true,
);
if (!confirmed) return;
try {
final res = await ref.read(customerApiProvider).delete(widget.id);
if (res.isOk) {
showAppToast('已删除', success: true);
if (mounted) context.pop();
} else {
showAppToast(res.message.isEmpty ? '删除失败' : res.message);
}
} catch (_) {
showAppToast('删除失败');
}
}
@override
Widget build(BuildContext context) {
return XPageShell(
topBar: XTopBar.nav(
title: '客户详情',
onBack: () => context.pop(),
trailing: GestureDetector(
onTap: _goEdit,
behavior: HitTestBehavior.opaque,
child: const XIcon('edit', size: 20, color: AppColors.primary),
),
),
child: _buildBody(),
);
}
Widget _buildBody() {
final c = _customer;
if (c == null) {
if (_loading) {
return Center(
child: Text(
'加载中...',
style: AppTypography.bodyMd.copyWith(color: AppColors.textMuted),
),
);
}
return const SizedBox.shrink();
}
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeaderCard(c),
_buildInfoCard(c),
_buildSectionHead(),
if (_visits.isNotEmpty) _buildVisitCard() else _buildEmptyBlock(),
XPrimaryButton(
label: '删除客户',
onPressed: _onDelete,
height: 46,
margin: const EdgeInsets.fromLTRB(16, 28, 16, 0),
background: AppColors.surface,
foreground: AppColors.statusError,
borderColor: AppColors.statusError,
fontSize: 15,
pill: false,
),
],
),
);
}
Widget _buildHeaderCard(Customer c) {
return XCard(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 6,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
c.customerName,
style: AppTypography.headlineMdMobile.copyWith(
color: AppColors.onSurface,
),
),
XChip(
customerStatusText(c.status),
tone: customerStatusTone(c.status),
),
if (c.customerLevel.isNotEmpty)
XChip(c.customerLevel, tone: ChipTone.muted),
],
),
const SizedBox(height: 10),
Row(
children: [
Text(
'客户编号',
style: AppTypography.bodyMd.copyWith(
fontSize: 12,
color: AppColors.textMuted,
),
),
const SizedBox(width: 8),
Text(
c.customerNo,
style: AppTypography.dataMono.copyWith(
color: AppColors.onSurface,
),
),
],
),
],
),
);
}
Widget _buildInfoCard(Customer c) {
final region = [c.province, c.city, c.district]
.where((e) => e.isNotEmpty)
.toList();
final items = <Widget>[
_infoItem('location', '地区', region.isEmpty ? '-' : region.join(' ')),
_infoItem('business', '详细地址', c.address.isEmpty ? '-' : c.address),
_infoItem('person', '联系人', c.contactPerson.isEmpty ? '-' : c.contactPerson),
_infoItem(
'call',
'联系电话',
c.contactPhone.isEmpty ? '-' : c.contactPhone,
mono: true,
),
_infoItem('category', '传真', c.fax.isEmpty ? '-' : c.fax, mono: true),
_infoItem('category', '邮编', c.postalCode.isEmpty ? '-' : c.postalCode, mono: true),
_infoItem('clock', '创建时间', formatDateTime(c.createdAt)),
];
return XCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Column(
children: [
for (var i = 0; i < items.length; i++) ...[
if (i > 0) Container(height: 1, color: AppColors.borderRule),
items[i],
],
],
),
);
}
Widget _infoItem(String icon, String label, String value, {bool mono = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
children: [
XIcon(icon, size: 18, color: AppColors.textMuted),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: AppTypography.bodyMd.copyWith(
fontSize: 12,
color: AppColors.textMuted,
),
),
Text(
value,
style: mono
? _infoMonoStyle.copyWith(color: AppColors.onSurface)
: AppTypography.bodyMd.copyWith(
color: AppColors.onSurface,
),
),
],
),
),
],
),
);
}
Widget _buildSectionHead() {
return Padding(
padding: const EdgeInsets.only(top: 22, bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const XIcon('schedule', size: 17, color: AppColors.primary),
const SizedBox(width: 6),
Text(
'拜访记录',
style: AppTypography.titleSm.copyWith(
fontSize: 16,
color: AppColors.onSurface,
),
),
],
),
GestureDetector(
onTap: () => context.push('/visit/form?customerId=${widget.id}'),
behavior: HitTestBehavior.opaque,
child: Row(
children: [
const XIcon('add', size: 15, color: AppColors.primary),
const SizedBox(width: 2),
Text(
'发起拜访',
style: AppTypography.labelBold.copyWith(
color: AppColors.primary,
),
),
],
),
),
],
),
);
}
Widget _buildVisitCard() {
final items = <Widget>[];
for (var i = 0; i < _visits.length; i++) {
if (i > 0) items.add(Container(height: 1, color: AppColors.borderRule));
items.add(_visitItem(_visits[i]));
}
if (_visitTotal > _visits.length) {
items.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Text(
'共 $_visitTotal 条拜访,仅显示最近 ${_visits.length} 条',
textAlign: TextAlign.center,
style: AppTypography.bodyMd.copyWith(
fontSize: 12,
color: AppColors.textMuted,
),
),
),
);
}
return XCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Column(children: items),
);
}
Widget _visitItem(VisitInfo v) {
return GestureDetector(
onTap: () => context.push('/visit/detail/${v.id}'),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 13, 0, 13),
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: -10,
top: 1,
bottom: 1,
child: Container(
width: 3,
decoration: BoxDecoration(
color: visitStatusBarColor(v.status),
borderRadius: BorderRadius.circular(2),
),
),
),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 6,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
(v.subject == null || v.subject!.isEmpty)
? '客户拜访'
: v.subject!,
style: AppTypography.bodyMd.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
XChip(
visitStatusText(v.status),
tone: visitStatusTone(v.status),
),
],
),
const SizedBox(height: 4),
Text(
'${v.visitNo} · ${formatDate(v.planTime)}',
style: AppTypography.dataMono.copyWith(
color: AppColors.textMuted,
),
),
],
),
),
const SizedBox(width: 10),
const XIcon('forward', size: 16, color: AppColors.textPlaceholder),
],
),
],
),
),
);
}
Widget _buildEmptyBlock() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_visitLoading ? '加载中...' : '暂无拜访记录',
style: AppTypography.bodyMd.copyWith(
fontSize: 13,
color: AppColors.textMuted,
),
),
);
}
}
@@ -0,0 +1,653 @@
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_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../../auth/application/session_controller.dart';
import '../data/customer_api.dart';
import '../data/customer_models.dart';
import '../data/user_api.dart';
/// 设施类型下拉项,照抄源页面 `levelOptions`。
const _levelOptions = ['医院', '学校', '企业', '其他'];
/// 状态下拉的可选值,照抄源页面 `statusOptions`。
const _statusValues = [1, 2, 3, 4];
/// 省市区三级选择器的内置示例数据(源页面用 uni 的 region picker)。
const _regionData = <String, Map<String, List<String>>>{
'北京市': {
'北京市': ['东城区', '西城区', '朝阳区', '海淀区', '丰台区', '石景山区', '通州区'],
},
'上海市': {
'上海市': ['黄浦区', '徐汇区', '长宁区', '静安区', '普陀区', '浦东新区', '闵行区'],
},
'广东省': {
'广州市': ['越秀区', '天河区', '海珠区', '白云区', '番禺区'],
'深圳市': ['福田区', '南山区', '罗湖区', '宝安区', '龙岗区'],
'珠海市': ['香洲区', '金湾区', '斗门区'],
},
'浙江省': {
'杭州市': ['上城区', '拱墅区', '西湖区', '滨江区', '余杭区'],
'宁波市': ['海曙区', '江北区', '鄞州区', '北仑区'],
'温州市': ['鹿城区', '龙湾区', '瓯海区'],
},
'江苏省': {
'南京市': ['玄武区', '秦淮区', '鼓楼区', '江宁区'],
'苏州市': ['姑苏区', '吴中区', '相城区', '工业园区'],
'无锡市': ['梁溪区', '滨湖区', '新吴区'],
},
'四川省': {
'成都市': ['锦江区', '青羊区', '武侯区', '成华区', '高新区'],
'绵阳市': ['涪城区', '游仙区', '安州区'],
},
};
/// 客户编辑页(新增 / 编辑),对应 `pages/customer/edit.vue`。
class CustomerEditPage extends ConsumerStatefulWidget {
const CustomerEditPage({super.key, this.id});
/// 为 null 表示新增。
final int? id;
@override
ConsumerState<CustomerEditPage> createState() => _CustomerEditPageState();
}
class _CustomerEditPageState extends ConsumerState<CustomerEditPage> {
final _customerName = TextEditingController();
final _customerNo = TextEditingController();
final _address = TextEditingController();
final _contactPerson = TextEditingController();
final _contactPhone = TextEditingController();
final _postalCode = TextEditingController();
final _fax = TextEditingController();
late final bool _isEdit = widget.id != null;
bool _isAdmin = false;
bool _saving = false;
String _province = '';
String _city = '';
String _district = '';
String _customerLevel = '';
int _status = 1;
List<SysUser> _salesmen = const [];
int _salesmanIndex = 0;
List<String> get _salesmanLabels =>
['未指定', ..._salesmen.map((s) => s.optionLabel)];
@override
void initState() {
super.initState();
_isAdmin = ref.read(sessionProvider).user?.isAdmin ?? false;
_load();
}
@override
void dispose() {
_customerName.dispose();
_customerNo.dispose();
_address.dispose();
_contactPerson.dispose();
_contactPhone.dispose();
_postalCode.dispose();
_fax.dispose();
super.dispose();
}
Future<void> _load() async {
if (_isAdmin) {
try {
final list = await ref.read(userApiProvider).salesmanOptions();
if (mounted) setState(() => _salesmen = list);
} catch (_) {
if (mounted) setState(() => _salesmen = const []);
}
}
final id = widget.id;
if (id == null) return;
try {
final res = await ref.read(customerApiProvider).detail(id);
if (!mounted) return;
final d = res.data;
if (res.isOk && d != null) {
setState(() {
_customerName.text = d.customerName;
_customerNo.text = d.customerNo;
_province = d.province;
_city = d.city;
_district = d.district;
_address.text = d.address;
_contactPerson.text = d.contactPerson;
_contactPhone.text = d.contactPhone;
_postalCode.text = d.postalCode;
_fax.text = d.fax;
_customerLevel = d.customerLevel;
_status = d.status;
final si = _salesmen.indexWhere((s) => s.id == d.salesmanId);
_salesmanIndex = si >= 0 ? si + 1 : 0;
});
}
} catch (_) {
// 详情加载失败保持空表单
}
}
Future<void> _onSave() async {
if (_customerName.text.trim().isEmpty) {
showAppToast('请输入客户名称');
return;
}
setState(() => _saving = true);
final payload = <String, dynamic>{
'customerName': _customerName.text,
if (_isEdit) 'customerNo': _customerNo.text,
'province': _province,
'city': _city,
'district': _district,
'address': _address.text,
'contactPerson': _contactPerson.text,
'contactPhone': _contactPhone.text,
'postalCode': _postalCode.text,
'fax': _fax.text,
'customerLevel': _customerLevel,
'status': _status,
};
// ADMIN 可指定负责业务员;未指定时传 null 以清空。
if (_isAdmin) {
payload['salesmanId'] =
_salesmanIndex > 0 ? _salesmen[_salesmanIndex - 1].id : null;
}
try {
final api = ref.read(customerApiProvider);
final ApiResponse<Customer> res;
if (_isEdit) {
res = await api.update(widget.id!, payload);
} else {
payload.remove('customerNo');
res = await api.create(payload);
}
if (res.isOk) {
showAppToast('保存成功', success: true);
if (mounted) context.pop();
} else {
showAppToast(res.message.isEmpty ? '保存失败' : res.message);
}
} catch (_) {
showAppToast('保存失败,请重试');
} finally {
if (mounted) setState(() => _saving = false);
}
}
String get _regionText => _province.isEmpty
? '请选择省市区'
: [_province, _city, _district].where((e) => e.isNotEmpty).join(' ');
Future<void> _pickRegion() async {
final res = await showModalBottomSheet<({String province, String city, String district})>(
context: context,
backgroundColor: Colors.transparent,
builder: (_) => const _RegionPickerSheet(),
);
if (res == null) return;
setState(() {
_province = res.province;
_city = res.city;
_district = res.district;
});
}
Future<void> _pickLevel() async {
final index = await _pickIndex(
'选择设施类型',
_levelOptions,
selected: _levelOptions.indexOf(_customerLevel),
);
if (index == null) return;
setState(() => _customerLevel = _levelOptions[index]);
}
Future<void> _pickStatus() async {
final index = await _pickIndex(
'选择状态',
_statusValues.map(customerStatusText).toList(),
selected: _statusValues.indexOf(_status),
);
if (index == null) return;
setState(() => _status = _statusValues[index]);
}
Future<void> _pickSalesman() async {
final index = await _pickIndex(
'选择负责业务员',
_salesmanLabels,
selected: _salesmanIndex,
);
if (index == null) return;
setState(() => _salesmanIndex = index);
}
Future<int?> _pickIndex(
String title,
List<String> options, {
int? selected,
}) {
return showModalBottomSheet<int>(
context: context,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)),
),
builder: (ctx) => SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
Expanded(
child: Text(
title,
style: AppTypography.titleSm.copyWith(fontSize: 16),
),
),
GestureDetector(
onTap: () => Navigator.of(ctx).pop(),
behavior: HitTestBehavior.opaque,
child: const XIcon('close', size: 20, color: AppColors.textMuted),
),
],
),
),
Container(height: 1, color: AppColors.borderRule),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: options.length,
itemBuilder: (_, i) {
final sel = selected == i;
return InkWell(
onTap: () => Navigator.of(ctx).pop(i),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: AppColors.borderRule),
),
),
child: Row(
children: [
Expanded(
child: Text(
options[i],
style: AppTypography.bodyMd.copyWith(
color: sel
? AppColors.primary
: AppColors.onSurface,
),
),
),
if (sel)
const XIcon('check', size: 18, color: AppColors.primary),
],
),
),
);
},
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return XPageShell(
topBar: XTopBar.nav(
title: _isEdit ? '编辑客户' : '新增客户',
onBack: () => context.pop(),
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
child: Column(
children: [
_buildForm(),
XPrimaryButton(
label: '保存',
loading: _saving,
onPressed: _onSave,
margin: const EdgeInsets.fromLTRB(16, 24, 16, 40),
),
],
),
),
);
}
Widget _buildForm() {
final fields = <Widget>[
_field(
'客户名称',
required: true,
control: _input(_customerName, '请输入客户名称'),
),
_field(
'客户编号',
control: _input(_customerNo, '保存后自动生成', enabled: !_isEdit),
),
_field(
'省市区',
control: _picker(
text: _regionText,
placeholder: _province.isEmpty,
onTap: _pickRegion,
),
),
_field('详细地址', control: _input(_address, '请输入详细地址')),
_field('联系人', control: _input(_contactPerson, '请输入联系人')),
_field(
'联系电话',
control: _input(_contactPhone, '请输入联系电话', number: true),
),
_field('邮编', control: _input(_postalCode, '选填', number: true)),
_field('传真', control: _input(_fax, '选填')),
_field(
'设施类型',
control: _picker(
text: _customerLevel.isEmpty ? '请选择设施类型' : _customerLevel,
placeholder: _customerLevel.isEmpty,
onTap: _pickLevel,
),
),
_field(
'状态',
control: _picker(
text: customerStatusText(_status),
placeholder: false,
onTap: _pickStatus,
),
),
if (_isAdmin)
_field(
'负责业务员',
control: _picker(
text: _salesmanIndex > 0 ? _salesmanLabels[_salesmanIndex] : '未指定',
placeholder: _salesmanIndex <= 0,
onTap: _pickSalesman,
),
),
];
return XCard(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
child: Column(
children: [
for (var i = 0; i < fields.length; i++) ...[
if (i > 0) Container(height: 1, color: AppColors.borderRule),
fields[i],
],
],
),
);
}
Widget _field(String label, {bool required = false, required Widget control}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
children: [
TextSpan(text: label),
if (required)
const TextSpan(
text: ' *',
style: TextStyle(color: AppColors.statusError),
),
],
),
style: AppTypography.labelBold.copyWith(
color: AppColors.onSurfaceVariant,
),
),
const SizedBox(height: 8),
control,
],
),
);
}
Widget _input(
TextEditingController controller,
String hint, {
bool enabled = true,
bool number = false,
}) {
return Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: TextField(
controller: controller,
enabled: enabled,
keyboardType: number ? TextInputType.number : TextInputType.text,
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: hint,
hintStyle: AppTypography.bodyMd.copyWith(
color: AppColors.textPlaceholder,
),
contentPadding: EdgeInsets.zero,
),
),
),
);
}
Widget _picker({
required String text,
required bool placeholder,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Expanded(
child: Text(
text,
style: AppTypography.bodyMd.copyWith(
color: placeholder
? AppColors.textPlaceholder
: AppColors.onSurface,
),
),
),
const XIcon('forward', size: 16, color: AppColors.outline),
],
),
),
);
}
}
/// 省 / 市 / 区三级选择器(自建,替代 uni 的 region picker)。
class _RegionPickerSheet extends StatefulWidget {
const _RegionPickerSheet();
@override
State<_RegionPickerSheet> createState() => _RegionPickerSheetState();
}
class _RegionPickerSheetState extends State<_RegionPickerSheet> {
int _level = 0;
String? _province;
String? _city;
void _back() {
setState(() {
if (_level == 2) {
_city = null;
} else {
_province = null;
}
_level--;
});
}
@override
Widget build(BuildContext context) {
final List<String> items;
if (_level == 0) {
items = _regionData.keys.toList();
} else if (_level == 1) {
items = _regionData[_province]!.keys.toList();
} else {
items = _regionData[_province!]![_city!]!;
}
final crumbs = [_province, _city].whereType<String>().join(' ');
return SafeArea(
top: false,
child: Container(
decoration: const BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Row(
children: [
if (_level > 0)
GestureDetector(
onTap: _back,
behavior: HitTestBehavior.opaque,
child: const XIcon('back', size: 20, color: AppColors.primary),
),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'选择省市区',
style: AppTypography.titleSm.copyWith(fontSize: 16),
),
if (crumbs.isNotEmpty)
Text(
crumbs,
style: AppTypography.bodyMd.copyWith(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
),
),
GestureDetector(
onTap: () => Navigator.of(context).pop(),
behavior: HitTestBehavior.opaque,
child: const XIcon('close', size: 20, color: AppColors.textMuted),
),
],
),
),
Container(height: 1, color: AppColors.borderRule),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: items.length,
itemBuilder: (_, i) {
final name = items[i];
return InkWell(
onTap: () {
if (_level == 0) {
setState(() {
_province = name;
_level = 1;
});
} else if (_level == 1) {
setState(() {
_city = name;
_level = 2;
});
} else {
Navigator.of(context).pop(
(
province: _province!,
city: _city!,
district: name,
),
);
}
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: AppColors.borderRule),
),
),
child: Text(
name,
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurface,
),
),
),
);
},
),
),
],
),
),
);
}
}
@@ -0,0 +1,465 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/tab_index.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_chip.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../data/customer_api.dart';
import '../data/customer_models.dart';
/// 客户 Tab,对应 `components/tabs/CustomerTab.vue`。
class CustomerTab extends ConsumerStatefulWidget {
const CustomerTab({super.key});
@override
ConsumerState<CustomerTab> createState() => _CustomerTabState();
}
class _CustomerTabState extends ConsumerState<CustomerTab> {
static const _statusOptions = <({String label, int? value})>[
(label: '全部', value: null),
(label: '意向', value: 1),
(label: '签约', value: 2),
(label: '运维中', value: 3),
(label: '终止', value: 4),
];
static const _levelOptions = <({String label, String? value})>[
(label: '全部', value: null),
(label: '医院', value: '医院'),
(label: '学校', value: '学校'),
(label: '企业', value: '企业'),
];
final _searchController = TextEditingController();
Timer? _searchTimer;
List<Customer> _list = const [];
bool _loading = false;
int? _filterStatus;
String? _filterLevel;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
@override
void dispose() {
_searchTimer?.cancel();
_searchController.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
// 列表由后端按 keyword / status 过滤;设施类型后端未提供查询条件,前端过滤
final keyword = _searchController.text.trim();
final res = await ref
.read(customerApiProvider)
.list(
page: 1,
pageSize: 100,
keyword: keyword.isEmpty ? null : keyword,
status: _filterStatus,
);
if (!mounted) return;
if (res.isOk && res.data != null) {
setState(() => _list = res.data!.list);
}
} catch (_) {
showAppToast('加载客户失败');
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _onSearchChanged(String _) {
_searchTimer?.cancel();
_searchTimer = Timer(const Duration(milliseconds: 350), _load);
}
void _onStatusChip(int? value) {
if (_filterStatus == value) return;
setState(() => _filterStatus = value);
_load();
}
String? _statusAction(int status) {
if (status == 1) return '跟进展';
if (status == 2) return '安排';
return null;
}
Future<void> _goDetail(Customer c) async {
await context.push('/customer/detail/${c.id}');
if (mounted) _load();
}
Future<void> _goEdit(Customer c) async {
await context.push('/customer/edit?id=${c.id}');
if (mounted) _load();
}
Future<void> _goCreate() async {
await context.push('/customer/edit');
if (mounted) _load();
}
@override
Widget build(BuildContext context) {
ref.listen(tabIndexProvider, (previous, next) {
if (next == AppTab.customer) _load();
});
final visible = _filterLevel == null
? _list
: _list.where((c) => c.customerLevel == _filterLevel).toList();
return Stack(
children: [
XPageShell(
topBar: XTopBar.brand(
title: '鑫潼智电',
trailingColor: AppColors.primary,
),
child: Column(
children: [
_buildPageHead(),
_buildSearchBar(),
_buildFilters(),
Expanded(child: _buildList(visible)),
],
),
),
Positioned(
right: 20,
bottom: 110,
child: GestureDetector(
onTap: _goCreate,
behavior: HitTestBehavior.opaque,
child: Container(
width: 52,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(14),
boxShadow: const [
BoxShadow(
color: Color(0x4D0054CD),
blurRadius: 16,
offset: Offset(0, 6),
),
],
),
child: const XIcon('add', size: 24, color: Colors.white),
),
),
),
],
);
}
Widget _buildPageHead() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('客户', style: AppTypography.headlineMd),
Padding(
padding: const EdgeInsets.only(top: 2),
child: const Text(
'管理和跟踪您的运营客户组合',
style: TextStyle(fontSize: 14, color: AppColors.textMuted),
),
),
],
),
);
}
Widget _buildSearchBar() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
child: XSearchField(
controller: _searchController,
hintText: '按客户名称、联系人或电话搜索...',
onChanged: _onSearchChanged,
),
);
}
Widget _buildFilters() {
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildFilterGroup(
'生命周期状态',
Row(
children: [
for (final option in _statusOptions)
Padding(
padding: const EdgeInsets.only(right: 8),
child: XFilterChip(
label: option.label,
selected: _filterStatus == option.value,
onTap: () => _onStatusChip(option.value),
),
),
],
),
),
_buildFilterGroup(
'设施类型',
Row(
children: [
for (final option in _levelOptions)
Padding(
padding: const EdgeInsets.only(right: 8),
child: XFilterChip(
label: option.label,
selected: _filterLevel == option.value,
onTap: () => setState(() => _filterLevel = option.value),
),
),
],
),
),
],
),
);
}
Widget _buildFilterGroup(String label, Widget chips) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 6),
child: Text(
label,
style: AppTypography.captionSm.copyWith(
color: AppColors.textMuted,
letterSpacing: 1,
),
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: chips,
),
],
),
);
}
Widget _buildList(List<Customer> visible) {
if (_loading && _list.isEmpty) {
return const XPlaceholder(text: '加载中...');
}
if (visible.isEmpty) {
return const XPlaceholder(text: '暂无客户');
}
return RefreshIndicator(
color: AppColors.primary,
onRefresh: _load,
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 140),
itemCount: visible.length,
itemBuilder: (context, index) => _customerCard(visible[index]),
),
);
}
Widget _customerCard(Customer c) {
final action = _statusAction(c.status);
return XCard(
padding: EdgeInsets.zero,
margin: const EdgeInsets.only(bottom: 12),
onTap: () => _goDetail(c),
child: Stack(
children: [
// 左侧状态色条 / 右侧编号面板均贴边,圆角按卡片圆角裁出
Positioned(
left: 0,
top: 0,
bottom: 0,
width: 4,
child: DecoratedBox(
decoration: BoxDecoration(
color: customerStatusBarColor(c.status),
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(AppRadius.lg),
),
),
),
),
const Positioned(
right: 0,
top: 0,
bottom: 0,
width: 86,
child: DecoratedBox(
decoration: BoxDecoration(
color: AppColors.surfaceBright,
borderRadius: BorderRadius.horizontal(
right: Radius.circular(AppRadius.lg),
),
),
),
),
Padding(
padding: const EdgeInsets.only(left: 4, right: 86),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Wrap(
spacing: 6,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
c.customerName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
XChip(
customerStatusText(c.status),
tone: customerStatusTone(c.status),
),
if (c.customerLevel.isNotEmpty)
XChip(c.customerLevel, tone: ChipTone.muted),
],
),
),
const SizedBox(width: 6),
const XIcon('more', size: 20, color: AppColors.textMuted),
],
),
const SizedBox(height: 10),
Row(
children: [
const XIcon(
'location',
size: 16,
color: AppColors.textMuted,
),
const SizedBox(width: 4),
Text(
c.locationText,
style: const TextStyle(
fontSize: 14,
color: AppColors.textMuted,
),
),
const SizedBox(width: 16),
const XIcon('person', size: 16, color: AppColors.textMuted),
const SizedBox(width: 4),
Text(
c.contactPerson.isEmpty ? '-' : c.contactPerson,
style: const TextStyle(
fontSize: 14,
color: AppColors.textMuted,
),
),
],
),
],
),
),
),
Positioned(
right: 0,
top: 0,
bottom: 0,
width: 86,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'客户编号',
style: TextStyle(fontSize: 11, color: AppColors.textMuted),
),
const SizedBox(height: 6),
Text(
c.customerNo,
style: AppTypography.dataMono.copyWith(
fontSize: 11,
color: AppColors.onSurface,
),
),
if (action != null) ...[
const SizedBox(height: 6),
GestureDetector(
onTap: () => _goEdit(c),
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0x1A712AE2),
borderRadius: BorderRadius.circular(AppRadius.regular),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const XIcon(
'calendar',
size: 14,
color: AppColors.secondary,
),
const SizedBox(width: 2),
Text(
action,
style: AppTypography.captionSm.copyWith(
color: AppColors.secondary,
),
),
],
),
),
),
],
],
),
),
),
],
),
);
}
}
@@ -0,0 +1,22 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import 'dashboard_models.dart';
final dashboardApiProvider = Provider<DashboardApi>(
(ref) => DashboardApi(ref.watch(apiClientProvider)),
);
/// 工作台统计接口,对应 uni-app 版 `api/dashboard.ts`。
class DashboardApi {
DashboardApi(this._client);
final ApiClient _client;
Future<ApiResponse<DashboardStats>> stats() => _client.get<DashboardStats>(
'/dashboard/stats',
parse: (d) => DashboardStats.fromJson((d as Map).cast<String, dynamic>()),
);
}
@@ -0,0 +1,159 @@
import '../../../core/utils/id_util.dart';
/// 名称/数值二元组(勘察状态分布、模块统计、客户状态分布)。
class NameValue {
const NameValue({required this.name, required this.value});
final String name;
final int value;
factory NameValue.fromJson(Map<String, dynamic> json) => NameValue(
name: json['name'] as String? ?? '',
value: (json['value'] as num?)?.toInt() ?? 0,
);
}
/// 近期勘察派单流水。
class DashboardSurveyItem {
const DashboardSurveyItem({
required this.id,
required this.orderNo,
required this.customerName,
required this.locale,
required this.owner,
required this.status,
required this.time,
});
final int id;
final String orderNo;
final String customerName;
final String locale;
final String owner;
/// 0 待指派 / 1 现场勘察中 / 2 待技术复核 / 3 已归档 / 5 已取消
final int status;
final String time;
factory DashboardSurveyItem.fromJson(Map<String, dynamic> json) =>
DashboardSurveyItem(
id: toIntOr(json['id'], 0),
orderNo: json['orderNo'] as String? ?? '',
customerName: json['customerName'] as String? ?? '',
locale: json['locale'] as String? ?? '',
owner: json['owner'] as String? ?? '',
status: toIntOr(json['status'], 0),
time: json['time'] as String? ?? '',
);
}
/// 智电工程推进看板(contract 聚合)。
class DashboardProjectItem {
const DashboardProjectItem({
required this.id,
required this.customerName,
required this.amount,
required this.stage,
required this.stageLabel,
required this.progress,
required this.owner,
required this.desc,
required this.orderNo,
});
final int id;
final String customerName;
final num amount;
final int stage;
final String stageLabel;
final int progress;
final String owner;
final String desc;
final String orderNo;
factory DashboardProjectItem.fromJson(Map<String, dynamic> json) =>
DashboardProjectItem(
id: toIntOr(json['id'], 0),
customerName: json['customerName'] as String? ?? '',
amount: (json['amount'] as num?) ?? 0,
stage: toIntOr(json['stage'], 0),
stageLabel: json['stageLabel'] as String? ?? '',
progress: toIntOr(json['progress'], 0),
owner: json['owner'] as String? ?? '',
desc: json['desc'] as String? ?? '',
orderNo: json['orderNo'] as String? ?? '',
);
}
/// `/api/dashboard/stats` 真实返回体(与后端 dashboard.service.ts 对齐)。
class DashboardStats {
const DashboardStats({
required this.customerTotal,
required this.customerMonthlyNew,
required this.contractTotal,
required this.contractYearAmount,
required this.surveyPending,
required this.surveyTotal,
required this.visitMonthly,
required this.visitTotal,
required this.planTotal,
required this.acceptTotal,
required this.surveyByStatus,
required this.modules,
required this.trend,
required this.customerStatus,
required this.customerSignRate,
required this.surveyList,
required this.projects,
});
final int customerTotal;
final int customerMonthlyNew;
final int contractTotal;
final num contractYearAmount;
final int surveyPending;
final int surveyTotal;
final int visitMonthly;
final int visitTotal;
final int planTotal;
final int acceptTotal;
final List<NameValue> surveyByStatus;
final List<NameValue> modules;
final List<NameValue> customerStatus;
final num customerSignRate;
final List<DashboardSurveyItem> surveyList;
final List<DashboardProjectItem> projects;
/// 趋势项字段为 `{ date, customers, contracts }`,App 端暂未使用,保留原始结构。
final List<Map<String, dynamic>> trend;
factory DashboardStats.fromJson(Map<String, dynamic> json) => DashboardStats(
customerTotal: toIntOr(json['customerTotal'], 0),
customerMonthlyNew: toIntOr(json['customerMonthlyNew'], 0),
contractTotal: toIntOr(json['contractTotal'], 0),
contractYearAmount: (json['contractYearAmount'] as num?) ?? 0,
surveyPending: toIntOr(json['surveyPending'], 0),
surveyTotal: toIntOr(json['surveyTotal'], 0),
visitMonthly: toIntOr(json['visitMonthly'], 0),
visitTotal: toIntOr(json['visitTotal'], 0),
planTotal: toIntOr(json['planTotal'], 0),
acceptTotal: toIntOr(json['acceptTotal'], 0),
surveyByStatus: _nameValues(json['surveyByStatus']),
modules: _nameValues(json['modules']),
trend: (json['trend'] as List? ?? [])
.map((e) => (e as Map).cast<String, dynamic>())
.toList(),
customerStatus: _nameValues(json['customerStatus']),
customerSignRate: (json['customerSignRate'] as num?) ?? 0,
surveyList: (json['surveyList'] as List? ?? [])
.map((e) => DashboardSurveyItem.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
projects: (json['projects'] as List? ?? [])
.map((e) => DashboardProjectItem.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
);
static List<NameValue> _nameValues(dynamic raw) => (raw as List? ?? [])
.map((e) => NameValue.fromJson((e as Map).cast<String, dynamic>()))
.toList();
}
@@ -0,0 +1,345 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/tab_index.dart';
import '../../../core/theme/app_colors.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 '../../auth/application/session_controller.dart';
import '../../auth/data/auth_models.dart';
import '../../customer/data/customer_api.dart';
import '../../dashboard/data/dashboard_api.dart';
/// 「我的」功能入口配置。
typedef _MenuItem = ({
String label,
String icon,
Color background,
Color color,
VoidCallback onTap,
});
/// 我的 Tab,对应 `components/tabs/MineTab.vue`。
class MineTab extends ConsumerStatefulWidget {
const MineTab({super.key});
@override
ConsumerState<MineTab> createState() => _MineTabState();
}
class _MineTabState extends ConsumerState<MineTab> {
int _customerCount = 0;
int _activeProjects = 0;
int _surveys = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
Future<void> _load() async {
var customerCount = _customerCount;
var activeProjects = _activeProjects;
var surveys = _surveys;
// 客户总数:独立接口
try {
final res = await ref
.read(customerApiProvider)
.list(page: 1, pageSize: 1);
if (res.isOk) customerCount = res.data?.total ?? 0;
} catch (_) {
// 静默刷新:请求失败时保留已渲染的数据
}
// 进行中项目 + 勘测任务:一次 dashboard 请求
try {
final res = await ref.read(dashboardApiProvider).stats();
final dash = res.isOk ? res.data : null;
if (dash != null) {
activeProjects = dash.contractTotal;
surveys = dash.surveyTotal;
}
} catch (_) {
// 静默刷新:请求失败时保留已渲染的数据
}
if (!mounted) return;
setState(() {
_customerCount = customerCount;
_activeProjects = activeProjects;
_surveys = surveys;
});
}
Future<void> _goSurvey() async {
await context.push('/survey');
if (mounted) _load();
}
Future<void> _onLogout() async {
final confirmed = await showAppConfirm(
title: '退出登录',
content: '确定要退出当前账号吗?',
destructive: true,
);
if (!confirmed) return;
await ref.read(sessionProvider.notifier).logout();
}
@override
Widget build(BuildContext context) {
ref.listen(tabIndexProvider, (previous, next) {
if (next == AppTab.mine) _load();
});
final user = ref.watch(sessionProvider).user;
final displayName = user?.displayName ?? '陈伟';
final avatarText = user?.avatarText ?? '陈';
final roleText = user == null ? '高级销售工程师' : roleLabel(user.roles);
final menus = <_MenuItem>[
(
label: '客户管理',
icon: 'groups',
background: const Color(0x140054CD),
color: AppColors.primary,
onTap: () =>
ref.read(tabIndexProvider.notifier).select(AppTab.customer),
),
(
label: '项目中心',
icon: 'calendar',
background: const Color(0x14712AE2),
color: AppColors.secondary,
onTap: () =>
ref.read(tabIndexProvider.notifier).select(AppTab.project),
),
(
label: '勘测任务',
icon: 'camera',
background: const Color(0x1400685F),
color: AppColors.tertiary,
onTap: _goSurvey,
),
(
label: '设置',
icon: 'more',
background: const Color(0x14E0852B),
color: AppColors.statusWarn,
onTap: () => showAppToast('设置功能即将上线'),
),
];
return XPageShell(
topBar: XTopBar.brand(title: '我的'),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 130),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildUserCard(avatarText, displayName, roleText),
_buildStats(),
XCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Column(
children: [
for (var i = 0; i < menus.length; i++) ...[
if (i > 0)
const Divider(
height: 1,
thickness: 1,
color: AppColors.borderRule,
),
_menuItem(menus[i]),
],
],
),
),
XPrimaryButton(
label: '退出登录',
onPressed: _onLogout,
height: 46,
margin: const EdgeInsets.only(top: 28, bottom: 40),
background: AppColors.surface,
foreground: AppColors.statusError,
borderColor: AppColors.statusError,
fontSize: 15,
pill: false,
),
],
),
),
);
}
Widget _buildUserCard(String avatarText, String displayName, String roleText) {
return XCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
margin: const EdgeInsets.only(bottom: 14),
child: Row(
children: [
Container(
width: 60,
height: 60,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.surfaceContainerHigh,
shape: BoxShape.circle,
border: Border.all(color: AppColors.primaryFixedDim, width: 2),
),
child: Text(
avatarText,
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName,
style: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
roleText,
style: const TextStyle(
fontSize: 13,
color: AppColors.textMuted,
),
),
),
const Padding(
padding: EdgeInsets.only(top: 8),
child: XChip(
'在线',
tone: ChipTone.tertiary,
leading: _OnlineDot(),
),
),
],
),
),
const XIcon('forward', size: 20, color: AppColors.onSurfaceVariant),
],
),
);
}
Widget _buildStats() {
return XCard(
padding: const EdgeInsets.symmetric(vertical: 20),
margin: const EdgeInsets.only(bottom: 14),
child: Row(
children: [
_statCell('$_customerCount', '我的客户'),
const _StatDivider(),
_statCell('$_activeProjects', '进行中项目'),
const _StatDivider(),
_statCell('$_surveys', '勘测任务'),
],
),
);
}
Widget _statCell(String value, String label) {
return Expanded(
child: Column(
children: [
Text(
value,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
const SizedBox(height: 4),
Text(
label,
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
],
),
);
}
Widget _menuItem(_MenuItem menu) {
return GestureDetector(
onTap: menu.onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 15),
child: Row(
children: [
Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: menu.background,
borderRadius: BorderRadius.circular(10),
),
child: XIcon(menu.icon, size: 20, color: menu.color),
),
const SizedBox(width: 14),
Expanded(
child: Text(
menu.label,
style: const TextStyle(
fontSize: 15,
color: AppColors.onSurface,
),
),
),
const XIcon('forward', size: 18, color: AppColors.outline),
],
),
),
);
}
}
/// 个人信息卡「在线」chip 左侧的状态圆点。
class _OnlineDot extends StatelessWidget {
const _OnlineDot();
@override
Widget build(BuildContext context) {
return Container(
width: 7,
height: 7,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.tertiary,
),
);
}
}
/// 统计卡内的 1px 分隔线。
class _StatDivider extends StatelessWidget {
const _StatDivider();
@override
Widget build(BuildContext context) {
return Container(width: 1, height: 32, color: AppColors.borderRule);
}
}
@@ -0,0 +1,73 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import 'project_models.dart';
final projectApiProvider = Provider<ProjectApi>(
(ref) => ProjectApi(ref.watch(apiClientProvider)),
);
/// 智电改造方案接口,对应后端 `/api/project-plans`。
///
/// 说明:uni-app 版项目模块使用本地 mock(`api/mock.ts`),字段与后端契约完全不一致;
/// Flutter 版按用户确认改为直连后端真实接口。
class ProjectApi {
ProjectApi(this._client);
final ApiClient _client;
Future<ApiResponse<ProjectPlanPage>> list({
int page = 1,
int pageSize = 100,
String? keyword,
int? customerId,
int? status,
String? registerStartDate,
String? registerEndDate,
}) => _client.get<ProjectPlanPage>(
'/project-plans',
query: {
'page': page,
'pageSize': pageSize,
'keyword': keyword,
'customerId': customerId,
'status': status,
'registerStartDate': registerStartDate,
'registerEndDate': registerEndDate,
},
parse: (d) {
final m = (d as Map).cast<String, dynamic>();
return ProjectPlanPage(
list: (m['list'] as List? ?? [])
.map((e) => ProjectPlan.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
total: (m['total'] as num?)?.toInt() ?? 0,
page: (m['page'] as num?)?.toInt() ?? 1,
pageSize: (m['pageSize'] as num?)?.toInt() ?? pageSize,
);
},
);
Future<ApiResponse<ProjectPlan>> detail(int id) => _client.get<ProjectPlan>(
'/project-plans/$id',
parse: (d) => ProjectPlan.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<ProjectPlan>> create(Map<String, dynamic> body) =>
_client.post<ProjectPlan>(
'/project-plans',
body: body,
parse: (d) => ProjectPlan.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<ProjectPlan>> update(int id, Map<String, dynamic> body) =>
_client.put<ProjectPlan>(
'/project-plans/$id',
body: body,
parse: (d) => ProjectPlan.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<void>> delete(int id) => _client.delete<void>('/project-plans/$id');
}
@@ -0,0 +1,150 @@
import 'package:flutter/painting.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/utils/id_util.dart';
/// 方案状态:0 草稿 1 已确认。
abstract final class ProjectPlanStatus {
static const draft = 0;
static const confirmed = 1;
}
const projectStatusMap = <int, ({String text, ChipTone tone})>{
0: (text: '草稿', tone: ChipTone.warn),
1: (text: '已确认', tone: ChipTone.primary),
};
String projectStatusText(int? s) => projectStatusMap[s]?.text ?? '未知';
ChipTone projectStatusTone(int? s) => projectStatusMap[s]?.tone ?? ChipTone.muted;
/// 列表卡片左侧色条颜色。
Color projectStatusBarColor(int? status) => switch (status) {
0 => AppColors.statusWarn,
1 => AppColors.primary,
_ => AppColors.borderRule,
};
/// 项目方案设备明细,对应后端 `project_plan_detail` 表。
class ProjectPlanDetail {
const ProjectPlanDetail({
required this.id,
required this.planId,
required this.customerId,
required this.equipName,
required this.quantity,
this.equipId,
this.smartDeviceId,
this.remark,
});
final int id;
final int planId;
final int customerId;
final String equipName;
final num quantity;
final int? equipId;
final int? smartDeviceId;
final String? remark;
factory ProjectPlanDetail.fromJson(Map<String, dynamic> json) => ProjectPlanDetail(
id: toIntOr(json['id'], 0),
planId: toIntOr(json['planId'], 0),
customerId: toIntOr(json['customerId'], 0),
equipName: json['equipName'] as String? ?? '',
quantity: (json['quantity'] as num?) ?? 1,
equipId: toIntOrNull(json['equipId']),
smartDeviceId: toIntOrNull(json['smartDeviceId']),
remark: json['remark'] as String?,
);
Map<String, dynamic> toJson() => {
'equipName': equipName,
'quantity': quantity,
if (equipId != null) 'equipId': equipId,
if (smartDeviceId != null) 'smartDeviceId': smartDeviceId,
if (remark != null && remark!.isNotEmpty) 'remark': remark,
};
ProjectPlanDetail copyWith({String? equipName, num? quantity, String? remark}) =>
ProjectPlanDetail(
id: id,
planId: planId,
customerId: customerId,
equipName: equipName ?? this.equipName,
quantity: quantity ?? this.quantity,
equipId: equipId,
smartDeviceId: smartDeviceId,
remark: remark ?? this.remark,
);
}
/// 智电改造方案,与后端 `/api/project-plans` 契约对齐。
class ProjectPlan {
const ProjectPlan({
required this.id,
required this.planNo,
required this.customerId,
required this.status,
this.registrar,
this.registerTime,
this.planDesc,
this.planFileUrl,
this.createdAt,
this.details = const [],
});
final int id;
final String planNo;
final int customerId;
/// 方案登记人(业务员姓名文本)
final String? registrar;
/// 方案登记时间(DATEONLY,`YYYY-MM-DD`)
final String? registerTime;
final String? planDesc;
/// 方案最终文件 URL
final String? planFileUrl;
final String? createdAt;
/// 设备明细,仅详情接口返回
final List<ProjectPlanDetail> details;
final int status;
factory ProjectPlan.fromJson(Map<String, dynamic> json) => ProjectPlan(
id: toIntOr(json['id'], 0),
planNo: json['planNo'] as String? ?? '',
customerId: toIntOr(json['customerId'], 0),
registrar: json['registrar'] as String?,
registerTime: json['registerTime'] as String?,
planDesc: json['planDesc'] as String?,
planFileUrl: json['planFileUrl'] as String?,
createdAt: json['created_at'] as String?,
details: (json['details'] as List? ?? [])
.map((e) => ProjectPlanDetail.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
status: toIntOr(json['status'], 0),
);
/// 设备明细合计数量。
num get detailQuantityTotal =>
details.fold<num>(0, (sum, d) => sum + d.quantity);
String get displayTitle => planNo.isNotEmpty ? planNo : '方案 #$id';
}
class ProjectPlanPage {
const ProjectPlanPage({
required this.list,
required this.total,
required this.page,
required this.pageSize,
});
final List<ProjectPlan> list;
final int total;
final int page;
final int pageSize;
}
@@ -0,0 +1,338 @@
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_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 '../../customer/data/customer_api.dart';
import '../data/project_api.dart';
import '../data/project_models.dart';
/// 项目详情页。
///
/// 源 `pages/project/detail.vue` 基于 mock 数据(团队 / 里程碑 / 设备概览等),
/// 这里按用户确认改为直连后端 `/api/project-plans/:id`,保留信息卡 + 分组 + 明细列表的视觉语言。
class ProjectDetailPage extends ConsumerStatefulWidget {
const ProjectDetailPage({super.key, required this.id});
final int id;
@override
ConsumerState<ProjectDetailPage> createState() => _ProjectDetailPageState();
}
class _ProjectDetailPageState extends ConsumerState<ProjectDetailPage> {
ProjectPlan? _plan;
String? _customerName;
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final res = await ref.read(projectApiProvider).detail(widget.id);
if (!mounted) return;
if (res.isOk && res.data != null) {
setState(() => _plan = res.data);
await _loadCustomer(res.data!.customerId);
} else {
showAppToast(res.message.isEmpty ? '方案不存在' : res.message);
}
} catch (_) {
if (mounted) showAppToast('加载失败');
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadCustomer(int customerId) async {
try {
final res = await ref.read(customerApiProvider).detail(customerId);
if (!mounted) return;
if (res.isOk && res.data != null) {
setState(() => _customerName = res.data!.customerName);
}
} catch (_) {
// 客户名称获取失败时降级为「客户 #id」
}
}
String _customerLabel(int customerId) {
final name = _customerName;
return (name == null || name.isEmpty) ? '客户 #$customerId' : name;
}
String _textOrDash(String? value) =>
(value == null || value.isEmpty) ? '-' : value;
String _formatQty(num quantity) => quantity == quantity.roundToDouble()
? quantity.toInt().toString()
: quantity.toString();
@override
Widget build(BuildContext context) {
final planNo = _plan?.planNo;
return XPageShell(
topBar: XTopBar.nav(
title: (planNo == null || planNo.isEmpty) ? '方案详情' : planNo,
onBack: () => context.pop(),
),
child: _buildBody(),
);
}
Widget _buildBody() {
final p = _plan;
if (p == null) {
return XPlaceholder(text: _loading ? '加载中...' : '方案不存在');
}
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeaderCard(p),
_buildInfoCard(p),
_buildDetailSection(p),
],
),
);
}
Widget _buildHeaderCard(ProjectPlan p) {
return XCard(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 6,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
_customerLabel(p.customerId),
style: AppTypography.headlineMdMobile.copyWith(
color: AppColors.onSurface,
),
),
XChip(
projectStatusText(p.status),
tone: projectStatusTone(p.status),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Text(
'方案单号',
style: AppTypography.bodyMd.copyWith(
fontSize: 12,
color: AppColors.textMuted,
),
),
const SizedBox(width: 8),
Text(
p.displayTitle,
style: AppTypography.dataMono.copyWith(
color: AppColors.onSurface,
),
),
],
),
],
),
);
}
Widget _buildInfoCard(ProjectPlan p) {
final items = <Widget>[
_infoItem('person', '登记人', _textOrDash(p.registrar)),
_infoItem('calendar', '登记时间', formatDate(p.registerTime)),
_infoItem('clock', '创建时间', formatDateTime(p.createdAt)),
_infoItem('category', '方案说明', _textOrDash(p.planDesc)),
];
return XCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Column(
children: [
for (var i = 0; i < items.length; i++) ...[
if (i > 0) Container(height: 1, color: AppColors.borderRule),
items[i],
],
if (p.planFileUrl != null && p.planFileUrl!.isNotEmpty) ...[
Container(height: 1, color: AppColors.borderRule),
_buildFileRow(p.planFileUrl!),
],
],
),
);
}
Widget _infoItem(String icon, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
children: [
XIcon(icon, size: 18, color: AppColors.textMuted),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: AppTypography.bodyMd.copyWith(
fontSize: 12,
color: AppColors.textMuted,
),
),
Text(
value,
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurface,
),
),
],
),
),
],
),
);
}
/// 方案文件:源页面用 uni 下载打开,Flutter 未引入 url_launcher,这里仅展示链接文本。
Widget _buildFileRow(String url) {
return GestureDetector(
onTap: () => showAppToast(url),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
children: [
const XIcon('domain', size: 18, color: AppColors.textMuted),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'方案文件',
style: AppTypography.bodyMd.copyWith(
fontSize: 12,
color: AppColors.textMuted,
),
),
Text(
url,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.bodyMd.copyWith(
color: AppColors.primary,
),
),
],
),
),
const SizedBox(width: 6),
const XIcon('forward', size: 16, color: AppColors.textPlaceholder),
],
),
),
);
}
Widget _buildDetailSection(ProjectPlan p) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(top: 22, bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const XSectionTitle('设备明细', icon: 'category', fontSize: 16),
Text(
'合计 ${_formatQty(p.detailQuantityTotal)}',
style: AppTypography.dataMono.copyWith(
color: AppColors.primary,
),
),
],
),
),
if (p.details.isEmpty)
const XPlaceholder(text: '暂无设备明细', dashed: true)
else
_buildDetailCard(p.details),
],
);
}
Widget _buildDetailCard(List<ProjectPlanDetail> details) {
final items = <Widget>[];
for (var i = 0; i < details.length; i++) {
if (i > 0) items.add(Container(height: 1, color: AppColors.borderRule));
items.add(_detailItem(details[i]));
}
return XCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Column(children: items),
);
}
Widget _detailItem(ProjectPlanDetail d) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
d.equipName.isEmpty ? '未命名设备' : d.equipName,
style: AppTypography.bodyMd.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
if (d.remark != null && d.remark!.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
d.remark!,
style: AppTypography.bodyMd.copyWith(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
],
),
),
const SizedBox(width: 12),
Text(
'×${_formatQty(d.quantity)}',
style: AppTypography.dataMono.copyWith(color: AppColors.primary),
),
],
),
);
}
}
@@ -0,0 +1,330 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/tab_index.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 '../../customer/data/customer_api.dart';
import '../data/project_api.dart';
import '../data/project_models.dart';
/// 项目 Tab。
///
/// 源 `components/tabs/ProjectTab.vue` 使用本地 mock(字段为 projectName / progress / stage 等),
/// 这里按用户确认改为直连后端 `/api/project-plans`,视觉沿用原卡片式语言但字段换成后端真实字段。
class ProjectTab extends ConsumerStatefulWidget {
const ProjectTab({super.key});
@override
ConsumerState<ProjectTab> createState() => _ProjectTabState();
}
class _ProjectTabState extends ConsumerState<ProjectTab> {
static const _statusOptions = <({String label, int? value})>[
(label: '全部', value: null),
(label: '草稿', value: ProjectPlanStatus.draft),
(label: '已确认', value: ProjectPlanStatus.confirmed),
];
final _searchController = TextEditingController();
Timer? _searchTimer;
List<ProjectPlan> _list = const [];
Map<int, String> _customerNames = const {};
bool _loading = false;
int? _filterStatus;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadCustomers();
_load();
});
}
@override
void dispose() {
_searchTimer?.cancel();
_searchController.dispose();
super.dispose();
}
/// `/project-plans` 只返回 customerId,需要先建立 id → 客户名称 映射用于展示。
Future<void> _loadCustomers() async {
try {
final res = await ref
.read(customerApiProvider)
.list(page: 1, pageSize: 200);
if (!mounted) return;
if (res.isOk && res.data != null) {
setState(() {
_customerNames = {
for (final c in res.data!.list) c.id: c.customerName,
};
});
}
} catch (_) {
// 客户名称映射失败时降级为「客户 #id」,不影响方案列表展示
}
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final keyword = _searchController.text.trim();
final res = await ref
.read(projectApiProvider)
.list(
page: 1,
pageSize: 100,
keyword: keyword.isEmpty ? null : keyword,
status: _filterStatus,
);
if (!mounted) return;
if (res.isOk && res.data != null) {
setState(() => _list = res.data!.list);
}
} catch (_) {
showAppToast('加载方案失败');
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _onSearchChanged(String _) {
_searchTimer?.cancel();
_searchTimer = Timer(const Duration(milliseconds: 350), _load);
}
void _onStatusChip(int? value) {
if (_filterStatus == value) return;
setState(() => _filterStatus = value);
_load();
}
Future<void> _goDetail(ProjectPlan p) async {
await context.push('/project/detail/${p.id}');
if (mounted) _load();
}
String _customerLabel(int customerId) {
final name = _customerNames[customerId];
return (name == null || name.isEmpty) ? '客户 #$customerId' : name;
}
@override
Widget build(BuildContext context) {
ref.listen(tabIndexProvider, (previous, next) {
if (next == AppTab.project) _load();
});
return XPageShell(
topBar: XTopBar.brand(
title: '鑫潼智电',
trailingColor: AppColors.primary,
),
child: Column(
children: [
_buildPageHead(),
_buildSearchBar(),
_buildFilters(),
Expanded(child: _buildList()),
],
),
);
}
Widget _buildPageHead() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('项目', style: AppTypography.headlineMd),
Padding(
padding: const EdgeInsets.only(top: 2),
child: const Text(
'管理与跟踪智电改造方案',
style: TextStyle(fontSize: 14, color: AppColors.textMuted),
),
),
],
),
);
}
Widget _buildSearchBar() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
child: XSearchField(
controller: _searchController,
hintText: '按方案单号或说明搜索...',
onChanged: _onSearchChanged,
),
);
}
Widget _buildFilters() {
return Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 4, bottom: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final option in _statusOptions)
Padding(
padding: const EdgeInsets.only(right: 8),
child: XFilterChip(
label: option.label,
selected: _filterStatus == option.value,
onTap: () => _onStatusChip(option.value),
),
),
],
),
),
);
}
Widget _buildList() {
if (_loading && _list.isEmpty) {
return const XPlaceholder(text: '加载中...');
}
if (_list.isEmpty) {
return const XPlaceholder(text: '暂无方案');
}
return RefreshIndicator(
color: AppColors.primary,
onRefresh: _load,
child: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 120),
itemCount: _list.length,
itemBuilder: (context, index) => _planCard(_list[index]),
),
);
}
Widget _planCard(ProjectPlan p) {
final registrar = p.registrar;
return XCard(
padding: EdgeInsets.zero,
margin: const EdgeInsets.only(bottom: 12),
onTap: () => _goDetail(p),
child: Stack(
children: [
// 左侧状态色条贴边,圆角按卡片圆角裁出
Positioned(
left: 0,
top: 0,
bottom: 0,
width: 4,
child: DecoratedBox(
decoration: BoxDecoration(
color: projectStatusBarColor(p.status),
borderRadius: const BorderRadius.horizontal(
left: Radius.circular(AppRadius.lg),
),
),
),
),
Padding(
padding: const EdgeInsets.only(left: 4),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
_customerLabel(p.customerId),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
),
const SizedBox(width: 8),
XChip(
projectStatusText(p.status),
tone: projectStatusTone(p.status),
),
],
),
const SizedBox(height: 6),
Text(
p.displayTitle,
style: AppTypography.dataMono.copyWith(
color: AppColors.textMuted,
),
),
const SizedBox(height: 10),
Row(
children: [
const XIcon(
'person',
size: 15,
color: AppColors.textMuted,
),
const SizedBox(width: 4),
Expanded(
child: Text(
(registrar == null || registrar.isEmpty)
? '未填写登记人'
: registrar,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
color: AppColors.textMuted,
),
),
),
const SizedBox(width: 12),
const XIcon('clock', size: 15, color: AppColors.textMuted),
const SizedBox(width: 4),
Text(
formatDate(p.registerTime),
style: AppTypography.dataMono.copyWith(
color: AppColors.textMuted,
),
),
],
),
if (p.planDesc != null && p.planDesc!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
p.planDesc!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
color: AppColors.onSurfaceVariant,
),
),
],
],
),
),
),
],
),
);
}
}
+95
View File
@@ -0,0 +1,95 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import 'survey_models.dart';
final surveyApiProvider = Provider<SurveyApi>(
(ref) => SurveyApi(ref.watch(apiClientProvider)),
);
/// SOP 回执增量保存结果。
class SopSaveResult {
const SopSaveResult({required this.updated, required this.skipped, required this.sopResults});
final int updated;
final List<int> skipped;
final List<SurveySopResult> sopResults;
factory SopSaveResult.fromJson(Map<String, dynamic> json) => SopSaveResult(
updated: (json['updated'] as num?)?.toInt() ?? 0,
skipped: (json['skipped'] as List? ?? []).map((e) => (e as num).toInt()).toList(),
sopResults: (json['sopResults'] as List? ?? [])
.map((e) => SurveySopResult.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
);
}
/// 勘察工单接口,对应 uni-app 版 `api/survey.ts`。
class SurveyApi {
SurveyApi(this._client);
final ApiClient _client;
Future<ApiResponse<SurveyPage>> list({
int page = 1,
int pageSize = 100,
String? keyword,
int? status,
int? customerId,
int? priority,
int? workerId,
int? salesmanId,
String? planStartDate,
String? planEndDate,
int? includeDraft,
}) => _client.get<SurveyPage>(
'/survey-orders/',
query: {
'page': page,
'pageSize': pageSize,
'keyword': keyword,
'status': status,
'customerId': customerId,
'priority': priority,
'workerId': workerId,
'salesmanId': salesmanId,
'planStartDate': planStartDate,
'planEndDate': planEndDate,
'includeDraft': includeDraft,
},
parse: (d) {
final m = (d as Map).cast<String, dynamic>();
return SurveyPage(
list: (m['list'] as List? ?? [])
.map((e) => SurveyItem.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
total: (m['total'] as num?)?.toInt() ?? 0,
page: (m['page'] as num?)?.toInt() ?? 1,
pageSize: (m['pageSize'] as num?)?.toInt() ?? pageSize,
);
},
);
Future<ApiResponse<SurveyItem>> detail(int id) => _client.get<SurveyItem>(
'/survey-orders/$id',
parse: (d) => SurveyItem.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<SurveyItem>> update(int id, Map<String, dynamic> body) =>
_client.put<SurveyItem>(
'/survey-orders/$id',
body: body,
parse: (d) => SurveyItem.fromJson((d as Map).cast<String, dynamic>()),
);
/// 按 SOP 检查项增量保存现场回执;
/// 仅现场勘察中(status=1)且工单对当前用户可见时后端才放行。
Future<ApiResponse<SopSaveResult>> saveSopResults(int id, List<SopResultPayload> results) =>
_client.put<SopSaveResult>(
'/survey-orders/$id/sop-results',
body: {'results': results.map((e) => e.toJson()).toList()},
parse: (d) => SopSaveResult.fromJson((d as Map).cast<String, dynamic>()),
);
}
+357
View File
@@ -0,0 +1,357 @@
import 'package:flutter/painting.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/utils/id_util.dart';
/// 勘察工单状态:0 待指派 1 现场勘察中 2 待技术复核 3 已归档 4 预留 5 已取消。
abstract final class SurveyStatus {
static const pendingAssign = 0;
static const surveying = 1;
static const pendingReview = 2;
static const archived = 3;
static const reserved = 4;
static const canceled = 5;
}
/// 优先级:1 特急 P1 2 加急 P2 3 常规 P3。
abstract final class SurveyPriority {
static const urgent = 1;
static const expedited = 2;
static const regular = 3;
}
/// App 端展示用的状态枚举(与 uni-app 版 `SurveyStatusMap` 一一对应)。
enum SurveyStatusKey { pending, inProgress, review, completed, canceled }
const surveyStatusMap = <SurveyStatusKey, ({String text, ChipTone tone})>{
SurveyStatusKey.pending: (text: '待处理', tone: ChipTone.warn),
SurveyStatusKey.inProgress: (text: '进行中', tone: ChipTone.primary),
SurveyStatusKey.review: (text: '待复核', tone: ChipTone.secondary),
SurveyStatusKey.completed: (text: '已完成', tone: ChipTone.tertiary),
SurveyStatusKey.canceled: (text: '已取消', tone: ChipTone.error),
};
/// 数字状态 → App 展示键。
SurveyStatusKey mapSurveyStatus(int? code) => switch (code) {
SurveyStatus.surveying => SurveyStatusKey.inProgress,
SurveyStatus.pendingReview => SurveyStatusKey.review,
SurveyStatus.archived => SurveyStatusKey.completed,
SurveyStatus.canceled => SurveyStatusKey.canceled,
_ => SurveyStatusKey.pending,
};
String surveyStatusText(int? code) => surveyStatusMap[mapSurveyStatus(code)]!.text;
ChipTone surveyStatusTone(int? code) => surveyStatusMap[mapSurveyStatus(code)]!.tone;
const surveyPriorityMap = <int, ({String text, ChipTone tone})>{
1: (text: '特急 P1', tone: ChipTone.error),
2: (text: '加急 P2', tone: ChipTone.warn),
3: (text: '常规 P3', tone: ChipTone.muted),
};
String surveyPriorityText(int? p) => surveyPriorityMap[p]?.text ?? '—';
ChipTone surveyPriorityTone(int? p) => surveyPriorityMap[p]?.tone ?? ChipTone.muted;
/// 列表卡片左侧色条颜色。
Color surveyStatusBarColor(int? status) => switch (status) {
SurveyStatus.pendingAssign => AppColors.statusWarn,
SurveyStatus.surveying => AppColors.primary,
SurveyStatus.pendingReview => AppColors.secondary,
SurveyStatus.archived => AppColors.tertiaryContainer,
SurveyStatus.canceled => AppColors.statusError,
_ => AppColors.borderRule,
};
class SurveyCustomer {
const SurveyCustomer({required this.id, required this.customerName, this.city, this.address});
final int id;
final String customerName;
final String? city;
final String? address;
factory SurveyCustomer.fromJson(Map<String, dynamic> json) => SurveyCustomer(
id: toIntOr(json['id'], 0),
customerName: json['customerName'] as String? ?? '',
city: json['city'] as String?,
address: json['address'] as String?,
);
}
class SurveyWorker {
const SurveyWorker({required this.id, required this.workerName, this.phone});
final int id;
final String workerName;
final String? phone;
factory SurveyWorker.fromJson(Map<String, dynamic> json) => SurveyWorker(
id: toIntOr(json['id'], 0),
workerName: json['workerName'] as String? ?? '',
phone: json['phone'] as String?,
);
}
class SurveySopResult {
const SurveySopResult({
required this.id,
required this.surveyOrderId,
required this.sopItemId,
required this.checked,
required this.itemName,
this.itemDesc = '',
this.receiptType = '',
this.receiptRequire = '',
this.required = false,
this.receiptValue = const [],
this.receiptUrls = const [],
this.remark = '',
});
final int id;
final int surveyOrderId;
final int sopItemId;
final bool checked;
final String itemName;
final String itemDesc;
final String receiptType;
final String receiptRequire;
final bool required;
/// 回执内容:photo/meter 类为照片对象 key 数组,form 类为单元素文本值数组。
final List<String> receiptValue;
/// 详情接口附带:照片类回执的预签名访问 URL(与 receiptValue 顺序一致)。
final List<String> receiptUrls;
final String remark;
factory SurveySopResult.fromJson(Map<String, dynamic> json) => SurveySopResult(
id: toIntOr(json['id'], 0),
surveyOrderId: toIntOr(json['surveyOrderId'], 0),
sopItemId: toIntOr(json['sopItemId'], 0),
checked: json['checked'] == true,
itemName: json['itemName'] as String? ?? '',
itemDesc: json['itemDesc'] as String? ?? '',
receiptType: json['receiptType'] as String? ?? '',
receiptRequire: json['receiptRequire'] as String? ?? '',
required: json['required'] == true,
receiptValue: (json['receiptValue'] as List? ?? []).map((e) => e.toString()).toList(),
receiptUrls: (json['receiptUrls'] as List? ?? []).map((e) => e.toString()).toList(),
remark: json['remark'] as String? ?? '',
);
}
/// 逐项回执的增量提交体:只传需要变更的 SOP 项,未传项保持原值。
class SopResultPayload {
const SopResultPayload({required this.sopItemId, this.checked, this.receiptValue, this.remark});
final int sopItemId;
final bool? checked;
final List<String>? receiptValue;
final String? remark;
Map<String, dynamic> toJson() => {
'sopItemId': sopItemId,
if (checked != null) 'checked': checked,
if (receiptValue != null) 'receiptValue': receiptValue,
if (remark != null) 'remark': remark,
};
}
/// 勘察工单,与后端 `/api/survey-orders` 契约对齐。
class SurveyItem {
const SurveyItem({
required this.id,
required this.surveyNo,
required this.customerId,
required this.surveyor,
required this.priority,
required this.taskTitle,
required this.siteLocation,
required this.source,
required this.loopSpec,
required this.progressDone,
required this.drawingCount,
required this.techContact,
required this.slaLevel,
required this.urgeCount,
required this.isDraft,
required this.photos,
required this.reviewRemark,
required this.cancelReason,
required this.remark,
required this.status,
required this.createdAt,
required this.updatedAt,
required this.customerName,
required this.workerName,
required this.salesmanName,
required this.salesmanPhone,
this.workerId,
this.surveyTime,
this.loopCount,
this.progressTotal,
this.salesmanId,
this.planStart,
this.planEnd,
this.expectMinutes,
this.expectArrivalAt,
this.lastUrgedAt,
this.estimatedFee,
this.actualFee,
this.reviewedAt,
this.customer,
this.worker,
this.sopResults,
});
final int id;
final String surveyNo;
final int customerId;
final String surveyor;
final int? workerId;
final String? surveyTime;
final int priority;
final String taskTitle;
final String siteLocation;
final String source;
final String loopSpec;
final int? loopCount;
final int progressDone;
final int? progressTotal;
final int drawingCount;
final int? salesmanId;
final String techContact;
final String? planStart;
final String? planEnd;
final int? expectMinutes;
final String? expectArrivalAt;
final String slaLevel;
final int urgeCount;
final String? lastUrgedAt;
final int isDraft;
final num? estimatedFee;
final num? actualFee;
final List<String> photos;
final String reviewRemark;
final String? reviewedAt;
final String cancelReason;
final String remark;
final int status;
final String? createdAt;
final String? updatedAt;
// decorate 注入的冗余字段
final SurveyCustomer? customer;
final String customerName;
final SurveyWorker? worker;
final String workerName;
final String salesmanName;
final String salesmanPhone;
// 仅详情接口返回
final List<SurveySopResult>? sopResults;
factory SurveyItem.fromJson(Map<String, dynamic> json) => SurveyItem(
id: toIntOr(json['id'], 0),
surveyNo: json['surveyNo'] as String? ?? '',
customerId: toIntOr(json['customerId'], 0),
surveyor: json['surveyor'] as String? ?? '',
workerId: toIntOrNull(json['workerId']),
surveyTime: json['surveyTime'] as String?,
priority: toIntOr(json['priority'], 3),
taskTitle: json['taskTitle'] as String? ?? '',
siteLocation: json['siteLocation'] as String? ?? '',
source: json['source'] as String? ?? '',
loopSpec: json['loopSpec'] as String? ?? '',
loopCount: toIntOrNull(json['loopCount']),
progressDone: toIntOr(json['progressDone'], 0),
progressTotal: toIntOrNull(json['progressTotal']),
drawingCount: toIntOr(json['drawingCount'], 0),
salesmanId: toIntOrNull(json['salesmanId']),
techContact: json['techContact'] as String? ?? '',
planStart: json['planStart'] as String?,
planEnd: json['planEnd'] as String?,
expectMinutes: toIntOrNull(json['expectMinutes']),
expectArrivalAt: json['expectArrivalAt'] as String?,
slaLevel: json['slaLevel'] as String? ?? '',
urgeCount: toIntOr(json['urgeCount'], 0),
lastUrgedAt: json['lastUrgedAt'] as String?,
isDraft: toIntOr(json['isDraft'], 0),
estimatedFee: json['estimatedFee'] as num?,
actualFee: json['actualFee'] as num?,
photos: (json['photos'] as List? ?? []).map((e) => e.toString()).toList(),
reviewRemark: json['reviewRemark'] as String? ?? '',
reviewedAt: json['reviewedAt'] as String?,
cancelReason: json['cancelReason'] as String? ?? '',
remark: json['remark'] as String? ?? '',
status: toIntOr(json['status'], 0),
createdAt: json['createdAt'] as String?,
updatedAt: json['updatedAt'] as String?,
customer: json['customer'] == null
? null
: SurveyCustomer.fromJson((json['customer'] as Map).cast<String, dynamic>()),
customerName: json['customerName'] as String? ?? '',
worker: json['worker'] == null
? null
: SurveyWorker.fromJson((json['worker'] as Map).cast<String, dynamic>()),
workerName: json['workerName'] as String? ?? '',
salesmanName: json['salesmanName'] as String? ?? '',
salesmanPhone: json['salesmanPhone'] as String? ?? '',
sopResults: json['sopResults'] == null
? null
: (json['sopResults'] as List)
.map((e) => SurveySopResult.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
);
/// 计划时间展示,等价于 `formatSurveyPlanTime`。
String get planTimeText => formatSurveyPlanTime(planStart, planEnd);
String get displayTitle => taskTitle.isNotEmpty ? taskTitle : '勘察 #$surveyNo';
String get displayCustomer => customerName.isNotEmpty ? customerName : (customer?.customerName ?? '客户 #$customerId');
}
class SurveyPage {
const SurveyPage({
required this.list,
required this.total,
required this.page,
required this.pageSize,
});
final List<SurveyItem> list;
final int total;
final int page;
final int pageSize;
}
/// 勘察单计划时间展示:`05-12 09:00 - 11:00` / `05-12 09:00 - 05-13 10:00` / `未安排`。
String formatSurveyPlanTime(String? planStart, String? planEnd) {
if ((planStart == null || planStart.isEmpty) && (planEnd == null || planEnd.isEmpty)) {
return '未安排';
}
String hm(DateTime dt) =>
'${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
String md(DateTime dt) =>
'${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
final start = parseSurveyDate(planStart);
final end = parseSurveyDate(planEnd);
if (start != null && end != null) {
final sameDay =
start.year == end.year && start.month == end.month && start.day == end.day;
return sameDay ? '${hm(start)} - ${md(end)}' : '${hm(start)} - ${hm(end)}';
}
final only = start ?? end;
return only == null ? (planStart ?? planEnd ?? '未安排') : hm(only);
}
DateTime? parseSurveyDate(String? raw) {
if (raw == null || raw.isEmpty) return null;
return DateTime.tryParse(raw);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,165 @@
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_typography.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 '../data/survey_api.dart';
import '../data/survey_models.dart';
/// 勘测任务列表,对应 `pages/survey/list.vue`。
class SurveyListPage extends ConsumerStatefulWidget {
const SurveyListPage({super.key});
@override
ConsumerState<SurveyListPage> createState() => _SurveyListPageState();
}
class _SurveyListPageState extends ConsumerState<SurveyListPage> {
List<SurveyItem> _list = const [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final res = await ref.read(surveyApiProvider).list(pageSize: 100);
if (!mounted) return;
if (res.isOk && res.data != null) {
setState(() => _list = res.data!.list);
}
} catch (_) {
if (mounted) showAppToast('加载失败');
}
}
Future<void> _goForm(SurveyItem item) async {
await context.push('/survey/form?id=${item.id}');
if (mounted) _load();
}
@override
Widget build(BuildContext context) {
return XPageShell(
topBar: XTopBar.brand(title: '勘测任务'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text(
'管理您的现场勘测与检查任务',
style: TextStyle(fontSize: 14, color: AppColors.textMuted),
),
),
Expanded(child: _buildContent()),
],
),
);
}
Widget _buildContent() {
if (_list.isEmpty) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: const [XPlaceholder(text: '暂无勘测任务')],
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
itemCount: _list.length,
itemBuilder: (context, index) => _buildCard(_list[index]),
);
}
Widget _buildCard(SurveyItem item) {
return XCard(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
onTap: () => _goForm(item),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${item.customerName.isEmpty ? '未知客户' : item.customerName}的勘测',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
const SizedBox(height: 4),
Text(
'任务ID:${item.surveyNo}',
style: AppTypography.dataMono.copyWith(
color: AppColors.textMuted,
),
),
],
),
),
),
XChip(
surveyStatusText(item.status),
tone: surveyStatusTone(item.status),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 14),
child: Column(
children: [
_buildLine(
'location',
AppColors.primary,
item.siteLocation.isEmpty ? '地点未填' : item.siteLocation,
),
const SizedBox(height: 10),
_buildLine(
'schedule',
AppColors.statusWarn,
'计划时间:${item.planTimeText}',
),
],
),
),
],
),
);
}
Widget _buildLine(String icon, Color color, String text) {
return Row(
children: [
XIcon(icon, size: 18, color: color),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: const TextStyle(fontSize: 14, color: AppColors.onSurfaceVariant),
),
),
],
);
}
}
+105
View File
@@ -0,0 +1,105 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import 'visit_models.dart';
final visitApiProvider = Provider<VisitApi>(
(ref) => VisitApi(ref.watch(apiClientProvider)),
);
/// 拜访台账接口,对应 uni-app 版 `api/customerVisit.ts`。
class VisitApi {
VisitApi(this._client);
final ApiClient _client;
Future<ApiResponse<VisitPage>> list({
int page = 1,
int pageSize = 100,
String? keyword,
int? customerId,
int? salesmanId,
int? visitType,
int? status,
int? priority,
String? planStartDate,
String? planEndDate,
}) => _client.get<VisitPage>(
'/customer-visits',
query: {
'page': page,
'pageSize': pageSize,
'keyword': keyword,
'customerId': customerId,
'salesmanId': salesmanId,
'visitType': visitType,
'status': status,
'priority': priority,
'planStartDate': planStartDate,
'planEndDate': planEndDate,
},
parse: (d) {
final m = (d as Map).cast<String, dynamic>();
return VisitPage(
list: (m['list'] as List? ?? [])
.map((e) => VisitInfo.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
total: (m['total'] as num?)?.toInt() ?? 0,
page: (m['page'] as num?)?.toInt() ?? 1,
pageSize: (m['pageSize'] as num?)?.toInt() ?? pageSize,
);
},
);
Future<ApiResponse<VisitStats>> stats() => _client.get<VisitStats>(
'/customer-visits/stats',
parse: (d) => VisitStats.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<VisitInfo>> detail(int id) => _client.get<VisitInfo>(
'/customer-visits/$id',
parse: (d) => VisitInfo.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<VisitInfo>> create(Map<String, dynamic> body) =>
_client.post<VisitInfo>(
'/customer-visits',
body: body,
parse: (d) => VisitInfo.fromJson((d as Map).cast<String, dynamic>()),
);
/// 批量安排拜访:多选客户生成统一的待执行计划。
Future<ApiResponse<VisitBatchResult>> batchCreate(Map<String, dynamic> body) =>
_client.post<VisitBatchResult>(
'/customer-visits/batch',
body: body,
parse: (d) => VisitBatchResult.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<VisitInfo>> update(int id, Map<String, dynamic> body) =>
_client.put<VisitInfo>(
'/customer-visits/$id',
body: body,
parse: (d) => VisitInfo.fromJson((d as Map).cast<String, dynamic>()),
);
/// 状态流转:待执行 → 已完成 / 取消 等。
Future<ApiResponse<VisitInfo>> updateStatus(int id, Map<String, dynamic> body) =>
_client.put<VisitInfo>(
'/customer-visits/$id/status',
body: body,
parse: (d) => VisitInfo.fromJson((d as Map).cast<String, dynamic>()),
);
/// 改派:更换跟进业务员(仅 ADMIN)。
Future<ApiResponse<VisitInfo>> reassign(int id, Map<String, dynamic> body) =>
_client.put<VisitInfo>(
'/customer-visits/$id/reassign',
body: body,
parse: (d) => VisitInfo.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<void>> delete(int id) => _client.delete<void>('/customer-visits/$id');
}
+342
View File
@@ -0,0 +1,342 @@
import 'package:flutter/painting.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/utils/id_util.dart';
/// 数值下拉选项。
typedef ValueOption = ({int value, String label});
/// 拜访类型:1 首访摸排 2 方案沟通 3 商务二访 4 维保回访 5 应急巡检
const visitTypeOptions = <ValueOption>[
(value: 1, label: '首访摸排'),
(value: 2, label: '方案沟通'),
(value: 3, label: '商务二访'),
(value: 4, label: '维保回访'),
(value: 5, label: '应急巡检'),
];
/// 拜访方式:1 现场拜访 2 商务拜访 3 电话沟通 4 线上会议
const visitModeOptions = <ValueOption>[
(value: 1, label: '现场拜访'),
(value: 2, label: '商务拜访'),
(value: 3, label: '电话沟通'),
(value: 4, label: '线上会议'),
];
/// 优先级:1 特急 P1 2 加急 P2 3 常规 P3
const visitPriorityOptions = <ValueOption>[
(value: 1, label: '特急 · P1'),
(value: 2, label: '加急 · P2'),
(value: 3, label: '常规 · P3'),
];
/// 意向评级:1=A 高意向 2=B 中意向 3=C 低意向
const visitIntentOptions = <ValueOption>[
(value: 1, label: 'A级 · 高意向'),
(value: 2, label: 'B级 · 中意向'),
(value: 3, label: 'C级 · 低意向'),
];
/// 状态:0 待执行 1 已完成待复核 2 取证待技术复核 3 已立项转勘察 4 已归档 5 已取消
const visitStatusOptions = <ValueOption>[
(value: 0, label: '待执行'),
(value: 1, label: '已完成待复核'),
(value: 2, label: '取证待技术复核'),
(value: 3, label: '已立项转勘察'),
(value: 4, label: '已归档'),
(value: 5, label: '已取消'),
];
abstract final class VisitStatus {
static const pending = 0;
static const done = 1;
static const evidence = 2;
static const survey = 3;
static const archived = 4;
static const canceled = 5;
}
const visitStatusMap = <int, ({String text, ChipTone tone})>{
0: (text: '待执行', tone: ChipTone.warn),
1: (text: '已完成待复核', tone: ChipTone.primary),
2: (text: '取证待技术复核', tone: ChipTone.secondary),
3: (text: '已立项转勘察', tone: ChipTone.secondary),
4: (text: '已归档', tone: ChipTone.tertiary),
5: (text: '已取消', tone: ChipTone.error),
};
const visitPriorityMap = <int, ({String text, ChipTone tone})>{
1: (text: '特急 P1', tone: ChipTone.error),
2: (text: '加急 P2', tone: ChipTone.warn),
3: (text: '常规 P3', tone: ChipTone.muted),
};
String visitStatusText(int? s) => visitStatusMap[s]?.text ?? '未知';
ChipTone visitStatusTone(int? s) => visitStatusMap[s]?.tone ?? ChipTone.muted;
String visitPriorityText(int? p) => visitPriorityMap[p]?.text ?? '—';
ChipTone visitPriorityTone(int? p) => visitPriorityMap[p]?.tone ?? ChipTone.muted;
String optionLabel(List<ValueOption> options, int? value) =>
options.where((o) => o.value == value).map((o) => o.label).firstOrNull ?? '—';
/// 列表卡片左侧色条颜色。
Color visitStatusBarColor(int status) => switch (status) {
0 => AppColors.statusWarn,
1 => AppColors.primary,
2 || 3 => AppColors.secondary,
4 => AppColors.tertiaryContainer,
5 => AppColors.statusError,
_ => AppColors.borderRule,
};
/// 关联客户(列表/详情 include 的精简字段)。
class VisitCustomer {
const VisitCustomer({
required this.id,
this.customerNo,
this.customerName,
this.customerLevel,
this.contactPerson,
this.contactPhone,
this.address,
this.salesmanId,
});
final int id;
final String? customerNo;
final String? customerName;
final String? customerLevel;
final String? contactPerson;
final String? contactPhone;
final String? address;
final int? salesmanId;
factory VisitCustomer.fromJson(Map<String, dynamic> json) => VisitCustomer(
id: toIntOr(json['id'], 0),
customerNo: json['customerNo'] as String?,
customerName: json['customerName'] as String?,
customerLevel: json['customerLevel'] as String?,
contactPerson: json['contactPerson'] as String?,
contactPhone: json['contactPhone'] as String?,
address: json['address'] as String?,
salesmanId: toIntOrNull(json['salesmanId']),
);
}
/// 拜访台账,与后端 `/api/customer-visits` 契约对齐。
class VisitInfo {
const VisitInfo({
required this.id,
required this.visitNo,
required this.customerId,
required this.visitPerson,
required this.status,
this.visitPhone,
this.visitContent,
this.visitTime,
this.salesmanId,
this.willPlan,
this.visitType,
this.visitMode,
this.priority,
this.subject,
this.planTime,
this.planEndTime,
this.checkinTime,
this.checkinStatus,
this.checkinLng,
this.checkinLat,
this.spaceDesc,
this.intentLevel,
this.intentDesc,
this.demands,
this.companion,
this.receptionPerson,
this.receptionTitle,
this.receptionPhone,
this.resultRating,
this.nextVisitTime,
this.nextAction,
this.photos = const [],
this.photoUrls = const [],
this.surveyOrderId,
this.projectNo,
this.createdAt,
this.updatedAt,
this.customer,
this.salesmanName,
this.salesmanPhone,
});
final int id;
final String visitNo;
final int customerId;
final String visitPerson;
final int status;
final String? visitPhone;
final String? visitContent;
final String? visitTime;
final int? salesmanId;
final int? willPlan;
final int? visitType;
final int? visitMode;
final int? priority;
final String? subject;
final String? planTime;
final String? planEndTime;
final String? checkinTime;
final int? checkinStatus;
final double? checkinLng;
final double? checkinLat;
final String? spaceDesc;
final int? intentLevel;
final String? intentDesc;
final String? demands;
final String? companion;
final String? receptionPerson;
final String? receptionTitle;
final String? receptionPhone;
final String? resultRating;
final String? nextVisitTime;
final String? nextAction;
/// 现场照片对象 key 列表(持久化字段)
final List<String> photos;
/// 现场照片预签名访问 URL 列表(后端动态生成)
final List<String> photoUrls;
final int? surveyOrderId;
final String? projectNo;
final String? createdAt;
final String? updatedAt;
final VisitCustomer? customer;
final String? salesmanName;
final String? salesmanPhone;
factory VisitInfo.fromJson(Map<String, dynamic> json) => VisitInfo(
id: toIntOr(json['id'], 0),
visitNo: json['visitNo'] as String? ?? '',
customerId: toIntOr(json['customerId'], 0),
visitPerson: json['visitPerson'] as String? ?? '',
status: toIntOr(json['status'], 0),
visitPhone: json['visitPhone'] as String?,
visitContent: json['visitContent'] as String?,
visitTime: json['visitTime'] as String?,
salesmanId: toIntOrNull(json['salesmanId']),
willPlan: toIntOrNull(json['willPlan']),
visitType: toIntOrNull(json['visitType']),
visitMode: toIntOrNull(json['visitMode']),
priority: toIntOrNull(json['priority']),
subject: json['subject'] as String?,
planTime: json['planTime'] as String?,
planEndTime: json['planEndTime'] as String?,
checkinTime: json['checkinTime'] as String?,
checkinStatus: toIntOrNull(json['checkinStatus']),
checkinLng: (json['checkinLng'] as num?)?.toDouble(),
checkinLat: (json['checkinLat'] as num?)?.toDouble(),
spaceDesc: json['spaceDesc'] as String?,
intentLevel: toIntOrNull(json['intentLevel']),
intentDesc: json['intentDesc'] as String?,
demands: json['demands'] as String?,
companion: json['companion'] as String?,
receptionPerson: json['receptionPerson'] as String?,
receptionTitle: json['receptionTitle'] as String?,
receptionPhone: json['receptionPhone'] as String?,
resultRating: json['resultRating'] as String?,
nextVisitTime: json['nextVisitTime'] as String?,
nextAction: json['nextAction'] as String?,
photos: (json['photos'] as List? ?? []).map((e) => e.toString()).toList(),
photoUrls: (json['photoUrls'] as List? ?? []).map((e) => e.toString()).toList(),
surveyOrderId: toIntOrNull(json['surveyOrderId']),
projectNo: json['projectNo'] as String?,
createdAt: json['created_at'] as String?,
updatedAt: json['updated_at'] as String?,
customer: json['customer'] == null
? null
: VisitCustomer.fromJson((json['customer'] as Map).cast<String, dynamic>()),
salesmanName: json['salesmanName'] as String?,
salesmanPhone: json['salesmanPhone'] as String?,
);
String get customerName =>
customer?.customerName ?? '客户 #$customerId';
}
class VisitPage {
const VisitPage({
required this.list,
required this.total,
required this.page,
required this.pageSize,
});
final List<VisitInfo> list;
final int total;
final int page;
final int pageSize;
}
/// KPI 统计卡数据。
class VisitStats {
const VisitStats({
required this.total,
required this.pending,
required this.todayPlan,
required this.urgentPending,
required this.conversionRate,
required this.pendingReview,
required this.completed,
required this.converted,
required this.intentTotal,
});
final int total;
final int pending;
final int todayPlan;
final int urgentPending;
final num conversionRate;
final int pendingReview;
final int completed;
final int converted;
final int intentTotal;
factory VisitStats.fromJson(Map<String, dynamic> json) => VisitStats(
total: toIntOr(json['total'], 0),
pending: toIntOr(json['pending'], 0),
todayPlan: toIntOr(json['todayPlan'], 0),
urgentPending: toIntOr(json['urgentPending'], 0),
conversionRate: (json['conversionRate'] as num?) ?? 0,
pendingReview: toIntOr(json['pendingReview'], 0),
completed: toIntOr(json['completed'], 0),
converted: toIntOr(json['converted'], 0),
intentTotal: toIntOr(json['intentTotal'], 0),
);
static const empty = VisitStats(
total: 0,
pending: 0,
todayPlan: 0,
urgentPending: 0,
conversionRate: 0,
pendingReview: 0,
completed: 0,
converted: 0,
intentTotal: 0,
);
}
/// 批量安排拜访结果。
class VisitBatchResult {
const VisitBatchResult({required this.total, required this.ids});
final int total;
final List<int> ids;
factory VisitBatchResult.fromJson(Map<String, dynamic> json) => VisitBatchResult(
total: toIntOr(json['total'], 0),
ids: (json['ids'] as List? ?? []).map((e) => toIntOr(e, 0)).toList(),
);
}
@@ -0,0 +1,695 @@
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_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../../auth/application/session_controller.dart';
import '../../customer/data/customer_api.dart';
import '../../customer/data/customer_models.dart';
import '../../customer/data/user_api.dart';
import '../data/visit_api.dart';
import '../data/visit_models.dart';
/// 批量安排拜访页,对应 `pages/visit/batch.vue`。
class VisitBatchPage extends ConsumerStatefulWidget {
const VisitBatchPage({super.key});
@override
ConsumerState<VisitBatchPage> createState() => _VisitBatchPageState();
}
class _VisitBatchPageState extends ConsumerState<VisitBatchPage> {
final _keyword = TextEditingController();
final _subject = TextEditingController();
final List<CustomerOption> _customers = [];
final List<int> _selectedIds = [];
List<SysUser> _salesmen = const [];
bool _customerLoading = false;
bool _saving = false;
bool _initialized = false;
String _planTime = todayStr();
String _planEndTime = '';
int _typeIndex = 0;
int _modeIndex = 0;
int _priorityIndex = 2;
int _salesmanIndex = -1;
late final List<String> _typeLabels = visitTypeOptions
.map((o) => o.label)
.toList();
late final List<String> _modeLabels = visitModeOptions
.map((o) => o.label)
.toList();
late final List<String> _priorityLabels = visitPriorityOptions
.map((o) => o.label)
.toList();
@override
void dispose() {
_keyword.dispose();
_subject.dispose();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_initialized) return;
_initialized = true;
final preset = int.tryParse(
GoRouterState.of(context).uri.queryParameters['customerId'] ?? '',
);
if (preset != null && preset > 0) _selectedIds.add(preset);
_loadCustomers();
if (_isAdmin) _loadSalesmen();
}
bool get _isAdmin => ref.read(sessionProvider).user?.isAdmin ?? false;
int get _visitType => visitTypeOptions[_typeIndex].value;
int get _visitMode => visitModeOptions[_modeIndex].value;
int get _priority => visitPriorityOptions[_priorityIndex].value;
int? get _salesmanId =>
_salesmanIndex > 0 ? _salesmen[_salesmanIndex - 1].id : null;
List<String> get _salesmanLabels => [
'默认本人',
..._salesmen.map((s) => s.optionLabel),
];
List<CustomerOption> get _filteredCustomers {
final kw = _keyword.text.trim().toLowerCase();
if (kw.isEmpty) return _customers;
return _customers
.where(
(c) =>
c.customerName.toLowerCase().contains(kw) ||
c.customerNo.toLowerCase().contains(kw),
)
.toList();
}
Future<void> _loadCustomers() async {
_customerLoading = true;
try {
final res = await ref.read(customerApiProvider).options();
if (!mounted) return;
if (res.isOk && res.data != null) {
_customers
..clear()
..addAll(res.data!);
}
} catch (_) {
showAppToast('客户列表加载失败');
} finally {
if (mounted) setState(() => _customerLoading = false);
}
}
Future<void> _loadSalesmen() async {
try {
final list = await ref.read(userApiProvider).salesmanOptions();
if (mounted) setState(() => _salesmen = list);
} catch (_) {
if (mounted) setState(() => _salesmen = const []);
}
}
void _toggle(int id) {
setState(() {
if (_selectedIds.contains(id)) {
_selectedIds.remove(id);
} else {
_selectedIds.add(id);
}
});
}
void _selectAll() {
setState(() {
for (final c in _filteredCustomers) {
if (!_selectedIds.contains(c.id)) _selectedIds.add(c.id);
}
});
}
void _clearAll() => setState(_selectedIds.clear);
Future<void> _pickPlanStart() async {
final picked = await _pickDate(_planTime);
if (picked == null) return;
if (!mounted) return;
setState(() => _planTime = formatDate(picked));
}
Future<void> _pickPlanEnd() async {
final picked = await _pickDate(_planEndTime);
if (picked == null) return;
if (!mounted) return;
setState(() => _planEndTime = formatDate(picked));
}
Future<DateTime?> _pickDate(String current) {
return showDatePicker(
context: context,
initialDate: parseDate(current) ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
}
Future<void> _pickOption({
required List<String> labels,
required int selectedIndex,
required ValueChanged<int> onSelected,
}) async {
final index = await showModalBottomSheet<int>(
context: context,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)),
),
builder: (ctx) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
for (var i = 0; i < labels.length; i++)
ListTile(
title: Text(
labels[i],
style: AppTypography.bodyMd.copyWith(
color: AppColors.onSurface,
),
),
trailing: i == selectedIndex
? const XIcon('check', size: 18, color: AppColors.primary)
: null,
onTap: () => Navigator.of(ctx).pop(i),
),
],
),
),
);
if (index == null) return;
if (!mounted) return;
setState(() => onSelected(index));
}
Future<void> _onSave() async {
if (_selectedIds.isEmpty) {
showAppToast('请至少选择一个客户');
return;
}
if (_planTime.isEmpty) {
showAppToast('请选择计划日期');
return;
}
setState(() => _saving = true);
try {
final payload = <String, dynamic>{
'customerIds': List<int>.from(_selectedIds),
'planTime': _planTime,
'subject': _subject.text,
'visitType': _visitType,
'visitMode': _visitMode,
'priority': _priority,
};
if (_planEndTime.isNotEmpty) payload['planEndTime'] = _planEndTime;
final salesmanId = _salesmanId;
if (_isAdmin && salesmanId != null) payload['salesmanId'] = salesmanId;
final res = await ref.read(visitApiProvider).batchCreate(payload);
if (!mounted) return;
final data = res.data;
if (res.isOk && data != null) {
showAppToast('成功创建 ${data.total} 条拜访计划');
Future.delayed(const Duration(milliseconds: 800), () {
if (mounted) context.pop();
});
} else {
showAppToast(res.message.isEmpty ? '创建失败' : res.message);
}
} catch (_) {
if (mounted) showAppToast('创建失败,请重试');
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return XPageShell(
topBar: XTopBar.nav(title: '批量安排拜访', onBack: () => context.pop()),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPlanCard(),
_buildCustomerCard(),
Container(
margin: const EdgeInsets.fromLTRB(0, 24, 0, 40),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(9999),
boxShadow: const [
BoxShadow(
color: Color(0x4D0054CD),
blurRadius: 16,
offset: Offset(0, 6),
),
],
),
child: XPrimaryButton(
label: '创建 ${_selectedIds.length} 条拜访计划',
loading: _saving,
onPressed: _onSave,
),
),
],
),
),
);
}
Widget _buildPlanCard() {
return XCard(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
margin: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(0, 14, 0, 6),
child: Text(
'计划信息',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
),
_formItem(
label: '计划日期',
isRequired: true,
child: _pickerValue(
text: _planTime.isEmpty ? '请选择计划日期' : _planTime,
placeholder: _planTime.isEmpty,
trailingIcon: 'calendar',
onTap: _pickPlanStart,
),
),
_formDivider(),
_formItem(
label: '计划结束',
child: _pickerValue(
text: _planEndTime.isEmpty ? '选填' : _planEndTime,
placeholder: _planEndTime.isEmpty,
trailingIcon: 'calendar',
onTap: _pickPlanEnd,
),
),
_formDivider(),
_formItem(
label: '拜访主题',
child: _textInput(_subject, '默认:批量安排的拜访计划'),
),
_formDivider(),
_formItem(
label: '拜访类型',
child: _pickerValue(
text: _typeLabels[_typeIndex],
placeholder: false,
trailingIcon: 'forward',
onTap: () => _pickOption(
labels: _typeLabels,
selectedIndex: _typeIndex,
onSelected: (i) => _typeIndex = i,
),
),
),
_formDivider(),
_formItem(
label: '拜访方式',
child: _pickerValue(
text: _modeLabels[_modeIndex],
placeholder: false,
trailingIcon: 'forward',
onTap: () => _pickOption(
labels: _modeLabels,
selectedIndex: _modeIndex,
onSelected: (i) => _modeIndex = i,
),
),
),
_formDivider(),
_formItem(
label: '优先级',
child: _pickerValue(
text: _priorityLabels[_priorityIndex],
placeholder: false,
trailingIcon: 'forward',
onTap: () => _pickOption(
labels: _priorityLabels,
selectedIndex: _priorityIndex,
onSelected: (i) => _priorityIndex = i,
),
),
),
if (_isAdmin) ...[
_formDivider(),
_formItem(
label: '负责业务员',
child: _pickerValue(
text: _salesmanIndex >= 0
? _salesmanLabels[_salesmanIndex]
: '默认本人',
placeholder: _salesmanIndex < 0,
trailingIcon: 'forward',
onTap: () => _pickOption(
labels: _salesmanLabels,
selectedIndex: _salesmanIndex,
onSelected: (i) => _salesmanIndex = i,
),
),
),
],
],
),
);
}
Widget _buildCustomerCard() {
return XCard(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(0, 14, 0, 6),
child: Row(
children: [
const Text(
'选择客户',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
const Spacer(),
GestureDetector(
onTap: _selectAll,
child: const Text(
'全选',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
const SizedBox(width: 16),
GestureDetector(
onTap: _clearAll,
child: const Text(
'清空',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
],
),
),
_searchBar(),
Padding(
padding: const EdgeInsets.fromLTRB(0, 4, 0, 8),
child: Row(
children: [
const Text(
'已选 ',
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
),
Text(
'${_selectedIds.length}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
const Text(
' 个客户',
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
),
],
),
),
if (_customerLoading)
_centerText('加载中...')
else if (_filteredCustomers.isEmpty)
_centerText('暂无可选客户')
else
for (final c in _filteredCustomers) _customerItem(c),
],
),
);
}
Widget _formItem({
required String label,
required Widget child,
bool isRequired = false,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
label,
style: AppTypography.labelBold.copyWith(
color: AppColors.onSurfaceVariant,
),
),
if (isRequired)
const Text(
' *',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.statusError,
),
),
],
),
const SizedBox(height: 8),
child,
],
),
);
}
Widget _formDivider() =>
const Divider(height: 1, thickness: 1, color: AppColors.borderRule);
Widget _pickerValue({
required String text,
required bool placeholder,
required String trailingIcon,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Expanded(
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
color: placeholder
? AppColors.textPlaceholder
: AppColors.onSurface,
),
),
),
XIcon(trailingIcon, size: 16, color: AppColors.outline),
],
),
),
);
}
Widget _textInput(TextEditingController controller, String hint) {
return Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(10),
),
child: TextField(
controller: controller,
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: hint,
hintStyle: const TextStyle(
fontSize: 15,
color: AppColors.textPlaceholder,
),
),
),
);
}
Widget _searchBar() {
return Container(
height: 42,
margin: const EdgeInsets.fromLTRB(0, 6, 0, 8),
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const XIcon('search', size: 18, color: AppColors.textMuted),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _keyword,
onChanged: (_) => setState(() {}),
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: '按客户名称、编号筛选...',
hintStyle: TextStyle(
fontSize: 15,
color: AppColors.textPlaceholder,
),
),
),
),
],
),
);
}
Widget _centerText(String text) => Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 14, color: AppColors.textMuted),
),
);
Widget _customerItem(CustomerOption c) {
final selected = _selectedIds.contains(c.id);
return GestureDetector(
onTap: () => _toggle(c.id),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
decoration: BoxDecoration(
color: selected ? AppColors.surfaceContainerLow : AppColors.surfaceBright,
border: Border.all(
color: selected ? AppColors.primaryContainer : AppColors.borderRule,
),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Container(
width: 22,
height: 22,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.primaryContainer : Colors.white,
border: Border.all(
color: selected
? AppColors.primaryContainer
: AppColors.borderRule,
width: 1.5,
),
borderRadius: BorderRadius.circular(6),
),
child: selected
? const XIcon('check', size: 14, color: Colors.white)
: null,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
c.customerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
color: AppColors.onSurface,
),
),
const SizedBox(height: 2),
Text(
c.customerNo,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.dataMono.copyWith(
fontSize: 11,
color: AppColors.textMuted,
),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,604 @@
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 '../../auth/application/session_controller.dart';
import '../../customer/data/user_api.dart';
import '../data/visit_api.dart';
import '../data/visit_models.dart';
/// 拜访详情页,对应 `pages/visit/detail.vue`。
class VisitDetailPage extends ConsumerStatefulWidget {
const VisitDetailPage({super.key, required this.id});
final int id;
@override
ConsumerState<VisitDetailPage> createState() => _VisitDetailPageState();
}
class _VisitDetailPageState extends ConsumerState<VisitDetailPage> {
VisitInfo? _visit;
bool _loading = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final res = await ref.read(visitApiProvider).detail(widget.id);
if (!mounted) return;
if (res.isOk && res.data != null) {
setState(() => _visit = res.data);
} else {
showAppToast(res.message.isNotEmpty ? res.message : '拜访记录不存在');
}
} catch (_) {
showAppToast('加载失败');
} finally {
if (mounted) setState(() => _loading = false);
}
}
String _findLabel(List<ValueOption> options, int? value) {
if (value == null) return '';
return options.where((o) => o.value == value).map((o) => o.label).firstOrNull ??
'';
}
String _planRange(VisitInfo visit) {
if (visit.planTime == null || visit.planTime!.isEmpty) return '未排期';
final start = formatDate(visit.planTime);
if (visit.planEndTime == null || visit.planEndTime!.isEmpty) return start;
return '$start ~ ${formatDate(visit.planEndTime)}';
}
String _receptionText(VisitInfo visit) {
if (visit.receptionPerson == null || visit.receptionPerson!.isEmpty) return '-';
final title = visit.receptionTitle != null && visit.receptionTitle!.isNotEmpty
? '(${visit.receptionTitle})'
: '';
final phone = visit.receptionPhone != null && visit.receptionPhone!.isNotEmpty
? ' · ${visit.receptionPhone}'
: '';
return '${visit.receptionPerson}$title$phone';
}
void _goEdit(bool canEdit) {
if (!canEdit) {
showAppToast('仅待执行的拜访可编辑');
return;
}
context.push('/visit/form?id=${widget.id}').then((_) {
if (mounted) _load();
});
}
void _goExecute() {
context.push('/visit/execute/${widget.id}').then((_) {
if (mounted) _load();
});
}
Future<void> _onCancelVisit() async {
final confirmed = await showAppConfirm(
title: '取消拜访',
content: '确定取消该拜访计划吗?取消后不可再执行。',
);
if (!confirmed) return;
try {
final res = await ref
.read(visitApiProvider)
.updateStatus(widget.id, {'status': VisitStatus.canceled});
if (res.isOk) {
showAppToast('已取消', success: true);
_load();
} else {
showAppToast(res.message.isNotEmpty ? res.message : '操作失败');
}
} catch (_) {
showAppToast('操作失败');
}
}
Future<void> _onReassign() async {
try {
final salesmen = await ref.read(userApiProvider).salesmanOptions();
if (!mounted) return;
if (salesmen.isEmpty) {
showAppToast('暂无可选业务员');
return;
}
final index = await _showSalesmanSheet(
salesmen.map((s) => s.optionLabel).toList(),
);
if (index == null || !mounted) return;
final res = await ref
.read(visitApiProvider)
.reassign(widget.id, {'salesmanId': salesmen[index].id});
if (res.isOk) {
showAppToast('改派成功', success: true);
_load();
} else {
showAppToast(res.message.isNotEmpty ? res.message : '改派失败');
}
} catch (_) {
showAppToast('获取业务员失败');
}
}
Future<int?> _showSalesmanSheet(List<String> labels) {
return showModalBottomSheet<int>(
context: context,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)),
),
builder: (ctx) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
for (var i = 0; i < labels.length; i++)
ListTile(
title: Text(
labels[i],
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface),
),
onTap: () => Navigator.of(ctx).pop(i),
),
],
),
),
);
}
void _previewPhoto(List<String> urls, int index) {
showDialog<void>(
context: context,
barrierColor: Colors.black,
builder: (_) => Dialog(
backgroundColor: Colors.transparent,
insetPadding: EdgeInsets.zero,
child: GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Center(
child: Image.network(urls[index], fit: BoxFit.contain),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final visit = _visit;
final isAdmin = ref.watch(sessionProvider).user?.isAdmin ?? false;
final canEdit = visit?.status == VisitStatus.pending;
return XPageShell(
topBar: XTopBar.nav(
title: '拜访详情',
onBack: () => context.pop(),
trailing: GestureDetector(
onTap: () => _goEdit(canEdit),
behavior: HitTestBehavior.opaque,
child: XIcon(
'edit',
size: 20,
color: canEdit ? AppColors.primary : AppColors.outlineVariant,
),
),
),
child: visit == null
? (Center(
child: _loading
? const Text(
'加载中...',
style: TextStyle(fontSize: 15, color: AppColors.textMuted),
)
: const SizedBox.shrink(),
))
: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
children: _buildContent(visit, canEdit, isAdmin),
),
);
}
List<Widget> _buildContent(VisitInfo visit, bool canEdit, bool isAdmin) {
final customer = visit.customer;
final photoList = visit.photoUrls.where((u) => u.isNotEmpty).toList();
final hasGps =
(visit.checkinLng ?? 0) != 0 && (visit.checkinLat ?? 0) != 0;
final hasExecution =
(visit.checkinTime != null && visit.checkinTime!.isNotEmpty) ||
(visit.checkinStatus ?? 0) > 0 ||
hasGps ||
(visit.spaceDesc != null && visit.spaceDesc!.isNotEmpty);
final hasResult =
(visit.visitTime != null && visit.visitTime!.isNotEmpty) ||
(visit.intentLevel ?? 0) > 0 ||
(visit.intentDesc != null && visit.intentDesc!.isNotEmpty) ||
(visit.demands != null && visit.demands!.isNotEmpty) ||
(visit.visitContent != null && visit.visitContent!.isNotEmpty) ||
(visit.companion != null && visit.companion!.isNotEmpty) ||
(visit.receptionPerson != null && visit.receptionPerson!.isNotEmpty) ||
(visit.resultRating != null && visit.resultRating!.isNotEmpty);
final hasNext =
(visit.nextVisitTime != null && visit.nextVisitTime!.isNotEmpty) ||
(visit.nextAction != null && visit.nextAction!.isNotEmpty);
final priority = visit.priority ?? 0;
final typeText = _findLabel(visitTypeOptions, visit.visitType);
final modeText = _findLabel(visitModeOptions, visit.visitMode);
return [
// 头部
XCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
visit.subject?.isNotEmpty == true ? visit.subject! : '客户拜访',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
),
const SizedBox(width: 8),
XChip(
visitStatusText(visit.status),
tone: visitStatusTone(visit.status),
),
],
),
if (priority > 0 || typeText.isNotEmpty || modeText.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [
if (priority > 0)
XChip(
visitPriorityText(visit.priority),
tone: visitPriorityTone(visit.priority),
),
if (typeText.isNotEmpty) XChip(typeText, tone: ChipTone.muted),
if (modeText.isNotEmpty) XChip(modeText, tone: ChipTone.muted),
],
),
),
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
const Text(
'拜访单号',
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
),
const SizedBox(width: 8),
Text(
visit.visitNo,
style: AppTypography.dataMono.copyWith(
fontSize: 12,
color: AppColors.onSurface,
),
),
],
),
),
],
),
),
// 客户信息
_sectionTitle('groups', AppColors.primary, '客户信息'),
XCard(
onTap: visit.customerId > 0
? () => context.push('/customer/detail/${visit.customerId}')
: null,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: _infoColumn([
_infoItem(
'business',
'客户名称',
customer?.customerName?.isNotEmpty == true
? customer!.customerName!
: '-',
link: true,
),
if (customer?.customerNo?.isNotEmpty == true)
_infoItem('category', '客户编号', customer!.customerNo!, mono: true),
if (customer?.contactPerson?.isNotEmpty == true)
_infoItem('person', '联系人', customer!.contactPerson!),
if (customer?.contactPhone?.isNotEmpty == true)
_infoItem('call', '联系电话', customer!.contactPhone!, mono: true),
if (customer?.address?.isNotEmpty == true)
_infoItem('location', '客户地址', customer!.address!),
]),
),
// 计划安排
_sectionTitle('schedule', AppColors.secondary, '计划安排'),
_plainInfoCard([
_infoItem('calendar', '计划时间', _planRange(visit)),
_infoItem(
'person',
'拜访人',
'${visit.visitPerson.isNotEmpty ? visit.visitPerson : '-'}'
'${visit.visitPhone != null && visit.visitPhone!.isNotEmpty ? ' · ${visit.visitPhone}' : ''}',
),
if (visit.salesmanName != null && visit.salesmanName!.isNotEmpty)
_infoItem(
'verified',
'负责业务员',
'${visit.salesmanName}'
'${visit.salesmanPhone != null && visit.salesmanPhone!.isNotEmpty ? ' · ${visit.salesmanPhone}' : ''}',
),
]),
// 现场执行
if (hasExecution) ...[
_sectionTitle('location', AppColors.tertiary, '现场执行'),
_plainInfoCard([
if (visit.checkinTime != null && visit.checkinTime!.isNotEmpty)
_infoItem('clock', '到场签到', formatDateTime(visit.checkinTime)),
if (hasGps)
_infoItem(
'location',
'GPS 坐标',
'${visit.checkinLat}, ${visit.checkinLng}',
mono: true,
),
if (visit.spaceDesc != null && visit.spaceDesc!.isNotEmpty)
_infoItem('business', '现场情况', visit.spaceDesc!),
]),
],
// 拜访结果
if (hasResult) ...[
_sectionTitle('check-circle', AppColors.primary, '拜访结果'),
_plainInfoCard([
if (visit.visitTime != null && visit.visitTime!.isNotEmpty)
_infoItem('clock', '实际拜访时间', formatDate(visit.visitTime)),
if ((visit.intentLevel ?? 0) > 0)
_infoItem(
'trend-up',
'意向评级',
_findLabel(visitIntentOptions, visit.intentLevel),
),
if (visit.intentDesc != null && visit.intentDesc!.isNotEmpty)
_infoItem('chart', '意向说明', visit.intentDesc!),
if (visit.demands != null && visit.demands!.isNotEmpty)
_infoItem('bolt', '客户需求', visit.demands!),
if (visit.visitContent != null && visit.visitContent!.isNotEmpty)
_infoItem('edit', '拜访内容', visit.visitContent!),
if (visit.companion != null && visit.companion!.isNotEmpty)
_infoItem('person', '随行人员', visit.companion!),
if (visit.receptionPerson != null && visit.receptionPerson!.isNotEmpty)
_infoItem('person', '接待人', _receptionText(visit)),
if (visit.resultRating != null && visit.resultRating!.isNotEmpty)
_infoItem('verified', '结果评价', visit.resultRating!),
]),
],
// 后续计划
if (hasNext) ...[
_sectionTitle('forward', AppColors.secondary, '后续计划'),
_plainInfoCard([
if (visit.nextVisitTime != null && visit.nextVisitTime!.isNotEmpty)
_infoItem('calendar', '下次拜访', formatDate(visit.nextVisitTime)),
if (visit.nextAction != null && visit.nextAction!.isNotEmpty)
_infoItem('forward', '下一步动作', visit.nextAction!),
]),
],
// 现场照片
if (photoList.isNotEmpty) ...[
_sectionTitle('camera', AppColors.primary, '现场照片'),
LayoutBuilder(
builder: (context, constraints) {
final width = (constraints.maxWidth - 10) / 2;
return Wrap(
spacing: 10,
runSpacing: 10,
children: [
for (var i = 0; i < photoList.length; i++)
GestureDetector(
onTap: () => _previewPhoto(photoList, i),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Container(
width: width,
height: 140,
color: AppColors.surfaceVariant,
child: Image.network(photoList[i], fit: BoxFit.cover),
),
),
),
],
);
},
),
],
// 流程信息
_sectionTitle('verified', AppColors.tertiary, '流程信息'),
_plainInfoCard([
if ((visit.surveyOrderId ?? 0) > 0 ||
(visit.projectNo != null && visit.projectNo!.isNotEmpty))
_infoItem(
'construction',
'关联立项',
visit.projectNo != null && visit.projectNo!.isNotEmpty
? visit.projectNo!
: '勘察单 #${visit.surveyOrderId}',
mono: true,
),
_infoItem('clock', '创建时间', formatDateTime(visit.createdAt)),
]),
// 业务员操作
_buildActions(canEdit, isAdmin),
];
}
Widget _buildActions(bool canEdit, bool isAdmin) {
if (!canEdit && !isAdmin) {
return Padding(
padding: const EdgeInsets.only(top: 24),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: const Text(
'当前状态下无需业务员操作,取证复核 / 立项 / 归档请在 Web 后台办理',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
),
),
);
}
return Padding(
padding: const EdgeInsets.only(top: 24),
child: Column(
children: [
if (canEdit)
XPrimaryButton(
label: '开始执行并完成',
onPressed: _goExecute,
pill: false,
height: 48,
fontSize: 15,
margin: const EdgeInsets.only(bottom: 12),
),
if (canEdit)
XPrimaryButton(
label: '取消拜访',
onPressed: _onCancelVisit,
pill: false,
height: 48,
fontSize: 15,
background: AppColors.surface,
foreground: AppColors.statusError,
borderColor: AppColors.borderRule,
margin: const EdgeInsets.only(bottom: 12),
),
if (isAdmin)
XPrimaryButton(
label: '改派业务员',
onPressed: _onReassign,
pill: false,
height: 48,
fontSize: 15,
background: AppColors.surface,
foreground: AppColors.statusError,
borderColor: AppColors.borderRule,
),
],
),
);
}
Widget _sectionTitle(String icon, Color iconColor, String text) {
return Padding(
padding: const EdgeInsets.only(top: 22, bottom: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
XIcon(icon, size: 17, color: iconColor),
const SizedBox(width: 6),
Text(
text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
],
),
);
}
Widget _plainInfoCard(List<Widget> items) {
return XCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: _infoColumn(items),
);
}
Widget _infoColumn(List<Widget> items) {
final children = <Widget>[];
for (var i = 0; i < items.length; i++) {
if (i > 0) {
children.add(
const Divider(height: 1, thickness: 1, color: AppColors.borderRule),
);
}
children.add(items[i]);
}
return Column(children: children);
}
Widget _infoItem(
String icon,
String label,
String value, {
bool mono = false,
bool link = false,
}) {
final color = link ? AppColors.primary : AppColors.onSurface;
final style = mono
? AppTypography.dataMono.copyWith(fontSize: 15, color: color)
: TextStyle(fontSize: 15, color: color);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
children: [
XIcon(icon, size: 18, color: AppColors.textMuted),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
),
Text(value, style: style),
],
),
),
],
),
);
}
}
@@ -0,0 +1,947 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.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_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 '../data/visit_api.dart';
import '../data/visit_models.dart';
/// 拜访执行页,对应 `pages/visit/execute.vue`。
class VisitExecutePage extends ConsumerStatefulWidget {
const VisitExecutePage({super.key, required this.id});
final int id;
@override
ConsumerState<VisitExecutePage> createState() => _VisitExecutePageState();
}
/// 现场照片槽位:key 为对象存储 key(落库),url 为预签名访问地址(预览)。
class _PhotoSlot {
String key = '';
String url = '';
}
class _VisitExecutePageState extends ConsumerState<VisitExecutePage> {
VisitInfo? _visit;
bool _loading = false;
bool _checking = false;
bool _submitting = false;
final _spaceDesc = TextEditingController();
final _visitContent = TextEditingController();
final _demands = TextEditingController();
final _intentDesc = TextEditingController();
int? _intentLevel;
final List<_PhotoSlot> _photoSlots = [_PhotoSlot(), _PhotoSlot()];
NavigatorState? _uploadingNavigator;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_spaceDesc.dispose();
_visitContent.dispose();
_demands.dispose();
_intentDesc.dispose();
super.dispose();
}
VisitApi get _api => ref.read(visitApiProvider);
bool get _checkedIn => _visit?.checkinStatus == 1;
bool get _hasGps =>
(_visit?.checkinLng ?? 0) != 0 && (_visit?.checkinLat ?? 0) != 0;
String get _gpsText => '${_visit?.checkinLat}, ${_visit?.checkinLng}';
String get _planRange {
final v = _visit;
final planTime = v?.planTime;
if (planTime == null || planTime.isEmpty) return '未排期';
final start = formatDate(planTime);
final endTime = v?.planEndTime;
if (endTime == null || endTime.isEmpty) return start;
return '$start ~ ${formatDate(endTime)}';
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final res = await _api.detail(widget.id);
if (!mounted) return;
final d = res.data;
if (!res.isOk || d == null) {
showAppToast(res.message.isEmpty ? '拜访记录不存在' : res.message);
return;
}
if (d.status != VisitStatus.pending) {
showAppToast('该拜访已处理,无需执行');
Future.delayed(const Duration(milliseconds: 600), () {
if (mounted) context.pop();
});
return;
}
_spaceDesc.text = d.spaceDesc ?? '';
_visitContent.text = d.visitContent ?? '';
_demands.text = d.demands ?? '';
_intentDesc.text = d.intentDesc ?? '';
setState(() {
_visit = d;
_intentLevel = d.intentLevel;
});
} catch (_) {
if (mounted) showAppToast('加载失败');
} finally {
if (mounted) setState(() => _loading = false);
}
}
/// 到场签到:获取 GPS 后落库(后端 update 透传 checkinLng/checkinLat/checkinStatus)。
Future<void> _onCheckin() async {
setState(() => _checking = true);
try {
Position? loc;
try {
final permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
loc = null;
} else {
loc = await Geolocator.getCurrentPosition();
}
} catch (_) {
loc = null;
}
if (loc == null) {
showAppToast('定位失败,请检查定位权限');
return;
}
final res = await _api.update(widget.id, {
'checkinLng': double.parse(loc.longitude.toStringAsFixed(6)),
'checkinLat': double.parse(loc.latitude.toStringAsFixed(6)),
'checkinStatus': 1,
'checkinTime': nowDateTimeStr(),
});
if (!mounted) return;
if (res.isOk) {
showAppToast('签到成功', success: true);
await _load();
} else {
showAppToast(res.message.isEmpty ? '签到失败' : res.message);
}
} catch (_) {
if (mounted) showAppToast('签到失败,请重试');
} finally {
if (mounted) setState(() => _checking = false);
}
}
void _onPhotoTap(int index) {
final slot = _photoSlots[index];
if (slot.url.isNotEmpty) {
_previewPhotos(index);
} else {
_pickPhoto(index);
}
}
Future<void> _pickPhoto(int index) async {
final source = await showModalBottomSheet<ImageSource>(
context: context,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(
'拍照',
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface),
),
onTap: () => Navigator.of(ctx).pop(ImageSource.camera),
),
ListTile(
title: Text(
'从相册选择',
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface),
),
onTap: () => Navigator.of(ctx).pop(ImageSource.gallery),
),
],
),
),
);
if (source == null) return;
if (!mounted) return;
final file = await ImagePicker().pickImage(
source: source,
imageQuality: 80,
);
if (file == null) return;
if (!mounted) return;
_showUploading();
try {
// 严格依赖对象存储:上传失败直接抛出,不写入本地槽位。
final up = await ref.read(apiClientProvider).uploadImage(file.path);
if (mounted) {
setState(() {
_photoSlots[index]
..key = up.key
..url = up.url;
});
showAppToast('上传成功', success: true);
}
} catch (e) {
final message = e is ApiException && e.message.isNotEmpty
? e.message
: '照片上传失败';
showAppToast(message);
} finally {
_hideUploading();
}
}
void _removePhoto(int index) {
setState(() {
_photoSlots[index]
..key = ''
..url = '';
});
}
void _previewPhotos(int index) {
final urls = _photoSlots
.where((s) => s.url.isNotEmpty)
.map((s) => s.url)
.toList();
if (urls.isEmpty) return;
final current = urls.indexOf(_photoSlots[index].url);
showDialog<void>(
context: context,
barrierColor: Colors.black,
builder: (ctx) => Dialog.fullscreen(
backgroundColor: Colors.black,
child: Stack(
children: [
GestureDetector(
onTap: () => Navigator.of(ctx).pop(),
child: PageView.builder(
controller: PageController(
initialPage: current < 0 ? 0 : current,
),
itemCount: urls.length,
itemBuilder: (_, i) => Center(
child: Image.network(urls[i], fit: BoxFit.contain),
),
),
),
Positioned(
right: 12,
top: MediaQuery.paddingOf(ctx).top + 12,
child: GestureDetector(
onTap: () => Navigator.of(ctx).pop(),
child: const XIcon('close', size: 24, color: Colors.white),
),
),
],
),
),
);
}
void _showUploading() {
// 提前抓取根导航器,页面被销毁时也能正常关闭上传遮罩。
_uploadingNavigator = Navigator.of(context, rootNavigator: true);
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const _UploadingDialog(),
);
}
void _hideUploading() {
_uploadingNavigator?.pop();
_uploadingNavigator = null;
}
Future<void> _onSubmit() async {
if (!_checkedIn) {
final go = await showAppConfirm(
title: '尚未签到',
content: '还未完成到场签到,确定直接提交吗?',
);
if (!go) return;
}
final photos = _photoSlots
.where((s) => s.key.isNotEmpty)
.map((s) => s.key)
.toList();
setState(() => _submitting = true);
try {
final payload = <String, dynamic>{
'spaceDesc': _spaceDesc.text,
'visitContent': _visitContent.text,
'demands': _demands.text,
'intentDesc': _intentDesc.text,
};
final intentLevel = _intentLevel;
if (intentLevel != null && intentLevel != 0) {
payload['intentLevel'] = intentLevel;
}
if (photos.isNotEmpty) payload['photos'] = photos;
final upd = await _api.update(widget.id, payload);
if (!mounted) return;
if (!upd.isOk) {
showAppToast(upd.message.isEmpty ? '保存失败' : upd.message);
return;
}
final res = await _api.updateStatus(widget.id, {
'status': VisitStatus.done,
});
if (!mounted) return;
if (res.isOk) {
showAppToast('拜访已完成', success: true);
Future.delayed(const Duration(milliseconds: 600), () {
if (mounted) context.pop();
});
} else {
showAppToast(res.message.isEmpty ? '状态更新失败' : res.message);
}
} catch (_) {
if (mounted) showAppToast('提交失败,请重试');
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
final visit = _visit;
return XPageShell(
topBar: XTopBar.nav(title: '拜访执行', onBack: () => context.pop()),
child: visit == null
? (_loading
? const Center(child: XPlaceholder(text: '加载中...'))
: const SizedBox.shrink())
: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTaskCard(visit),
_sectionTitle(
'到场签到',
icon: 'location',
iconColor: AppColors.tertiary,
),
_buildCheckinCard(),
_sectionTitle(
'现场记录',
icon: 'edit',
iconColor: AppColors.secondary,
),
_buildRecordCard(),
_sectionTitle(
'意向评级',
icon: 'trend-up',
iconColor: AppColors.primary,
),
_buildIntentCard(),
_sectionTitle(
'现场照片',
icon: 'camera',
iconColor: AppColors.primary,
hint: '最多 2 张',
),
_buildPhotoGrid(),
_pillButton(
label: '提交并标记完成',
icon: 'check',
background: AppColors.primary,
fontSize: 16,
shadow: true,
loading: _submitting,
margin: const EdgeInsets.fromLTRB(16, 24, 16, 40),
onPressed: _onSubmit,
),
],
),
),
);
}
Widget _sectionTitle(
String text, {
required String icon,
required Color iconColor,
String? hint,
}) {
return Padding(
padding: const EdgeInsets.fromLTRB(0, 22, 0, 10),
child: Row(
children: [
XIcon(icon, size: 17, color: iconColor),
const SizedBox(width: 6),
Text(
text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
if (hint != null) ...[
const SizedBox(width: 6),
Text(
hint,
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
],
],
),
);
}
Widget _buildTaskCard(VisitInfo visit) {
final customer = visit.customer;
final address = customer?.address;
final subject = visit.subject;
return XCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
subject == null || subject.isEmpty ? '客户拜访' : subject,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
const SizedBox(height: 4),
Text(
visit.visitNo,
style: AppTypography.dataMono.copyWith(
color: AppColors.textMuted,
),
),
],
),
),
const SizedBox(width: 10),
XChip(
visitStatusText(visit.status),
tone: visitStatusTone(visit.status),
),
],
),
const SizedBox(height: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_taskLine(
'business',
AppColors.primary,
customer?.customerName ?? '未知客户',
),
if (address != null && address.isNotEmpty) ...[
const SizedBox(height: 8),
_taskLine('location', AppColors.textMuted, address),
],
const SizedBox(height: 8),
_taskLine(
'schedule',
AppColors.statusWarn,
'计划时间:$_planRange',
),
],
),
],
),
);
}
Widget _taskLine(String icon, Color color, String text) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
XIcon(icon, size: 18, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 14,
color: AppColors.onSurfaceVariant,
),
),
),
],
);
}
Widget _buildCheckinCard() {
return XCard(
padding: const EdgeInsets.all(16),
child: _checkedIn ? _buildCheckinDone() : _buildCheckinAction(),
);
}
Widget _buildCheckinDone() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
XIcon('check-circle', size: 20, color: AppColors.tertiary),
SizedBox(width: 6),
Text(
'已签到',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.tertiary,
),
),
],
),
const SizedBox(height: 6),
Text(
'签到时间:${formatDateTime(_visit?.checkinTime)}',
style: const TextStyle(
fontSize: 13,
color: AppColors.onSurfaceVariant,
),
),
if (_hasGps) ...[
const SizedBox(height: 6),
Text(
'坐标:$_gpsText',
style: AppTypography.dataMono.copyWith(
fontSize: 13,
color: AppColors.onSurfaceVariant,
),
),
],
],
);
}
Widget _buildCheckinAction() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_pillButton(
label: '获取定位并签到',
icon: 'location',
background: AppColors.tertiary,
height: 46,
radius: 10,
loading: _checking,
onPressed: _onCheckin,
),
const SizedBox(height: 10),
const Text(
'签到将记录当前时间与 GPS 坐标,请到达客户现场后操作',
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
),
],
);
}
Widget _buildRecordCard() {
return XCard(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Column(
children: [
_textAreaField('现场情况', _spaceDesc, '记录配电房、设备与现场的实际情况...'),
_fieldDivider(),
_textAreaField('沟通纪要', _visitContent, '本次与客户沟通的主要内容...'),
_fieldDivider(),
_textAreaField('客户需求', _demands, '客户提出的需求与关注点...'),
],
),
);
}
Widget _fieldDivider() =>
const Divider(height: 1, thickness: 1, color: AppColors.borderRule);
Widget _textAreaField(
String label,
TextEditingController controller,
String hint,
) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_fieldLabel(label),
const SizedBox(height: 8),
Container(
height: 84,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(10),
),
child: TextField(
controller: controller,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: hint,
hintStyle: const TextStyle(
fontSize: 15,
color: AppColors.textPlaceholder,
),
),
),
),
],
),
);
}
Widget _fieldLabel(String label) => Text(
label,
style: AppTypography.labelBold.copyWith(color: AppColors.onSurfaceVariant),
);
Widget _buildIntentCard() {
return XCard(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Row(
children: [
for (var i = 0; i < visitIntentOptions.length; i++) ...[
if (i > 0) const SizedBox(width: 10),
Expanded(child: _intentChip(visitIntentOptions[i])),
],
],
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_fieldLabel('意向说明'),
const SizedBox(height: 8),
Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(10),
),
child: TextField(
controller: _intentDesc,
style: const TextStyle(
fontSize: 15,
color: AppColors.onSurface,
),
cursorColor: AppColors.primary,
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: '选填,补充判断依据',
hintStyle: TextStyle(
fontSize: 15,
color: AppColors.textPlaceholder,
),
),
),
),
],
),
),
],
),
);
}
Widget _intentChip(ValueOption option) {
final active = _intentLevel == option.value;
return GestureDetector(
onTap: () => setState(() => _intentLevel = option.value),
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: active ? AppColors.surfaceVariant : AppColors.surfaceBright,
border: Border.all(
color: active ? AppColors.primary : AppColors.borderRule,
),
borderRadius: BorderRadius.circular(10),
),
child: Text(
option.label,
style: TextStyle(
fontSize: 13,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
color: active ? AppColors.primary : AppColors.onSurfaceVariant,
),
),
),
);
}
Widget _buildPhotoGrid() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
for (var i = 0; i < _photoSlots.length; i++) ...[
if (i > 0) const SizedBox(width: 10),
Expanded(child: _photoSlot(i)),
],
],
),
const SizedBox(height: 10),
const Row(
children: [
XIcon('warning', size: 14, color: AppColors.outline),
SizedBox(width: 6),
Expanded(
child: Text(
'照片上传依赖对象存储服务,上传失败将无法提交',
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
),
),
],
),
],
);
}
Widget _photoSlot(int index) {
final slot = _photoSlots[index];
final filled = slot.url.isNotEmpty;
return GestureDetector(
onTap: () => _onPhotoTap(index),
child: Container(
height: 120,
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
fit: StackFit.expand,
children: [
if (filled)
Image.network(slot.url, fit: BoxFit.cover)
else
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const XIcon('camera', size: 28, color: AppColors.outline),
const SizedBox(height: 6),
Text(
'现场照片 ${index + 1}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
),
if (filled)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 10),
color: const Color(0x8C0D1C2C),
child: Row(
children: [
Text(
'现场照片 ${index + 1}',
style: const TextStyle(
fontSize: 11,
color: Colors.white,
),
),
const Spacer(),
GestureDetector(
onTap: () => _removePhoto(index),
child: const XIcon(
'delete',
size: 15,
color: Colors.white,
),
),
],
),
),
),
],
),
),
),
);
}
Widget _pillButton({
required String label,
required String icon,
required Color background,
required VoidCallback onPressed,
bool loading = false,
double height = 48,
double radius = 9999,
double fontSize = 15,
bool shadow = false,
EdgeInsetsGeometry? margin,
}) {
return Padding(
padding: margin ?? EdgeInsets.zero,
child: Container(
height: height,
width: double.infinity,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(radius),
boxShadow: shadow
? const [
BoxShadow(
color: Color(0x4D0054CD),
blurRadius: 16,
offset: Offset(0, 6),
),
]
: null,
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(radius),
child: InkWell(
onTap: loading ? null : onPressed,
borderRadius: BorderRadius.circular(radius),
child: Center(
child: loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Colors.white),
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
XIcon(icon, size: 18, color: Colors.white),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
],
),
),
),
),
),
);
}
}
/// `uni.showLoading({ title: '上传中...' })` 的等价实现。
class _UploadingDialog extends StatelessWidget {
const _UploadingDialog();
@override
Widget build(BuildContext context) {
return const Dialog(
backgroundColor: Color(0xE60D1C2C),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Colors.white),
),
),
SizedBox(width: 10),
Text(
'上传中...',
style: TextStyle(fontSize: 14, color: Colors.white),
),
],
),
),
);
}
}
@@ -0,0 +1,550 @@
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_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../../auth/application/session_controller.dart';
import '../../customer/data/customer_api.dart';
import '../../customer/data/customer_models.dart';
import '../../customer/data/user_api.dart';
import '../data/visit_api.dart';
import '../data/visit_models.dart';
/// 拜访新建 / 编辑页,对应 `pages/visit/form.vue`。
class VisitFormPage extends ConsumerStatefulWidget {
const VisitFormPage({super.key, this.id});
/// 为空表示新增,否则为编辑。
final int? id;
@override
ConsumerState<VisitFormPage> createState() => _VisitFormPageState();
}
class _VisitFormPageState extends ConsumerState<VisitFormPage> {
final TextEditingController _subject = TextEditingController();
final TextEditingController _visitPerson = TextEditingController();
final TextEditingController _visitPhone = TextEditingController();
final TextEditingController _visitContent = TextEditingController();
final List<String> _typeLabels = visitTypeOptions.map((o) => o.label).toList();
final List<String> _modeLabels = visitModeOptions.map((o) => o.label).toList();
final List<String> _priorityLabels =
visitPriorityOptions.map((o) => o.label).toList();
List<CustomerOption> _customers = const [];
List<SysUser> _salesmen = const [];
late final bool _isEdit;
late final bool _isAdmin;
int? _customerId;
int _typeIndex = 0;
int _modeIndex = 0;
int _priorityIndex = 2;
int _salesmanIndex = -1;
String _planTime = '';
String _planEndTime = '';
bool _saving = false;
@override
void initState() {
super.initState();
_isEdit = widget.id != null;
final user = ref.read(sessionProvider).user;
_isAdmin = user?.isAdmin ?? false;
_planTime = todayStr();
_visitPerson.text = user?.realName ?? '';
_init();
}
@override
void dispose() {
_subject.dispose();
_visitPerson.dispose();
_visitPhone.dispose();
_visitContent.dispose();
super.dispose();
}
Future<void> _init() async {
await _loadCustomers();
if (_isAdmin) await _loadSalesmen();
if (widget.id != null) await _loadDetail();
}
Future<void> _loadCustomers() async {
try {
final res = await ref.read(customerApiProvider).options();
if (res.isOk && res.data != null && mounted) {
setState(() => _customers = res.data!);
}
} catch (_) {
// 客户列表加载失败由保存前的校验兜底
}
}
Future<void> _loadSalesmen() async {
try {
final list = await ref.read(userApiProvider).salesmanOptions();
if (mounted) setState(() => _salesmen = list);
} catch (_) {
if (mounted) setState(() => _salesmen = const []);
}
}
Future<void> _loadDetail() async {
final res = await ref.read(visitApiProvider).detail(widget.id!);
if (!res.isOk || res.data == null) {
showAppToast(res.message.isNotEmpty ? res.message : '拜访记录不存在');
return;
}
if (!mounted) return;
final detail = res.data!;
setState(() {
_customerId = detail.customerId;
_subject.text = detail.subject ?? '';
_typeIndex = _indexOf(
visitTypeOptions,
detail.visitType ?? visitTypeOptions[0].value,
);
_modeIndex = _indexOf(
visitModeOptions,
detail.visitMode ?? visitModeOptions[0].value,
);
_priorityIndex = _indexOf(
visitPriorityOptions,
detail.priority ?? visitPriorityOptions[2].value,
);
_planTime = detail.planTime != null && detail.planTime!.isNotEmpty
? _dateOnly(detail.planTime!)
: todayStr();
_planEndTime = _dateOnly(detail.planEndTime ?? '');
_visitPerson.text = detail.visitPerson;
_visitPhone.text = detail.visitPhone ?? '';
_visitContent.text = detail.visitContent ?? '';
final index = _salesmen.indexWhere((s) => s.id == detail.salesmanId);
_salesmanIndex = index >= 0 ? index + 1 : -1;
// 编辑时若客户不在可选范围内(如已终止),补充一条占位以正确回显
if (detail.customerId != 0 &&
!_customers.any((c) => c.id == detail.customerId)) {
_customers = [
CustomerOption(
id: detail.customerId,
customerNo: detail.customer?.customerNo ?? '',
customerName: detail.customer?.customerName ?? '当前客户',
status: 0,
),
..._customers,
];
}
});
}
int _indexOf(List<ValueOption> options, int value) {
final index = options.indexWhere((o) => o.value == value);
return index < 0 ? 0 : index;
}
String _dateOnly(String value) =>
value.length >= 10 ? value.substring(0, 10) : value;
CustomerOption? get _selectedCustomer =>
_customers.where((c) => c.id == _customerId).firstOrNull;
String get _customerLabel {
final customer = _selectedCustomer;
if (customer == null) return '';
return '${customer.customerName}'
'${customer.customerNo.isNotEmpty ? ' · ${customer.customerNo}' : ''}';
}
List<String> get _customerLabels => _customers
.map(
(c) =>
'${c.customerName}${c.customerNo.isNotEmpty ? ' · ${c.customerNo}' : ''}',
)
.toList();
void _pickCustomer() {
final labels = _customerLabels;
if (labels.isEmpty) return;
final index = _customers.indexWhere((c) => c.id == _customerId);
_pickIndex(labels, index < 0 ? 0 : index, (i) {
setState(() => _customerId = _customers[i].id);
});
}
Future<void> _pickIndex(
List<String> labels,
int current,
ValueChanged<int> onSelected,
) async {
final index = await showModalBottomSheet<int>(
context: context,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)),
),
builder: (ctx) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
for (var i = 0; i < labels.length; i++)
ListTile(
title: Text(
labels[i],
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface),
),
trailing: i == current
? const XIcon('check', size: 18, color: AppColors.primary)
: null,
onTap: () => Navigator.of(ctx).pop(i),
),
],
),
),
);
if (index != null) onSelected(index);
}
Future<void> _pickDate({required bool isStart}) async {
final initial = parseDate(isStart ? _planTime : _planEndTime) ?? DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked == null) return;
setState(() {
if (isStart) {
_planTime = formatDate(picked);
} else {
_planEndTime = formatDate(picked);
}
});
}
Future<void> _onSave() async {
if (_customerId == null) {
showAppToast('请选择关联客户');
return;
}
if (_planTime.isEmpty) {
showAppToast('请选择计划开始日期');
return;
}
setState(() => _saving = true);
try {
final payload = <String, dynamic>{
'customerId': _customerId,
'subject': _subject.text,
'visitType': visitTypeOptions[_typeIndex].value,
'visitMode': visitModeOptions[_modeIndex].value,
'priority': visitPriorityOptions[_priorityIndex].value,
'planTime': _planTime,
'planEndTime': _planEndTime,
'visitPerson': _visitPerson.text,
'visitPhone': _visitPhone.text,
'visitContent': _visitContent.text,
};
final salesmanId =
_salesmanIndex > 0 ? _salesmen[_salesmanIndex - 1].id : null;
if (_isAdmin && salesmanId != null) payload['salesmanId'] = salesmanId;
final api = ref.read(visitApiProvider);
final res = _isEdit
? await api.update(widget.id!, payload)
: await api.create(payload);
if (!mounted) return;
if (res.isOk) {
showAppToast('保存成功', success: true);
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) context.pop();
} else {
showAppToast(res.message.isNotEmpty ? res.message : '保存失败');
}
} catch (_) {
showAppToast('保存失败,请重试');
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final items = <Widget>[
_formItem(label: '关联客户', isRequired: true, child: _customerPicker()),
_formItem(
label: '拜访主题',
child: _input(_subject, '例如:配电房增容方案沟通'),
),
_formItem(
label: '拜访类型',
child: _optionPicker(
_typeLabels,
_typeIndex,
(i) => setState(() => _typeIndex = i),
),
),
_formItem(
label: '拜访方式',
child: _optionPicker(
_modeLabels,
_modeIndex,
(i) => setState(() => _modeIndex = i),
),
),
_formItem(
label: '优先级',
child: _optionPicker(
_priorityLabels,
_priorityIndex,
(i) => setState(() => _priorityIndex = i),
),
),
_formItem(
label: '计划开始',
isRequired: true,
child: _datePicker(
_planTime,
'请选择计划开始日期',
() => _pickDate(isStart: true),
),
),
_formItem(
label: '计划结束',
child: _datePicker(_planEndTime, '选填', () => _pickDate(isStart: false)),
),
_formItem(label: '拜访人', child: _input(_visitPerson, '默认当前登录用户')),
_formItem(
label: '联系电话',
child: _input(_visitPhone, '选填', number: true),
),
if (_isAdmin) _formItem(label: '负责业务员', child: _salesmanPicker()),
_formItem(
label: '计划说明',
child: _textarea(_visitContent, '本次拜访的目标与重点事项...'),
),
];
final children = <Widget>[];
for (var i = 0; i < items.length; i++) {
if (i > 0) {
children.add(
const Divider(height: 1, thickness: 1, color: AppColors.borderRule),
);
}
children.add(items[i]);
}
return XPageShell(
topBar: XTopBar.nav(
title: _isEdit ? '编辑拜访计划' : '新建拜访计划',
onBack: () => context.pop(),
),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
XCard(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
child: Column(children: children),
),
XPrimaryButton(
label: '保存计划',
loading: _saving,
onPressed: _onSave,
margin: const EdgeInsets.fromLTRB(16, 24, 16, 40),
),
],
),
),
);
}
Widget _formItem({
required String label,
bool isRequired = false,
required Widget child,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: AppTypography.labelBold.copyWith(
color: AppColors.onSurfaceVariant,
),
),
if (isRequired)
Text(
' *',
style: AppTypography.labelBold.copyWith(
color: AppColors.statusError,
),
),
],
),
const SizedBox(height: 8),
child,
],
),
);
}
Widget _customerPicker() {
final label = _customerLabel;
final disabled = _isEdit || _customers.isEmpty;
return GestureDetector(
onTap: disabled ? null : _pickCustomer,
child: _pickerBox(
label.isNotEmpty
? label
: (_customers.isNotEmpty ? '请选择客户' : '暂无客户可选'),
label.isEmpty,
_isEdit
? null
: const XIcon('forward', size: 16, color: AppColors.outline),
),
);
}
Widget _optionPicker(
List<String> labels,
int index,
ValueChanged<int> onSelected,
) {
return GestureDetector(
onTap: () => _pickIndex(labels, index, onSelected),
child: _pickerBox(
labels[index],
false,
const XIcon('forward', size: 16, color: AppColors.outline),
),
);
}
Widget _datePicker(String value, String placeholder, VoidCallback onPick) {
return GestureDetector(
onTap: onPick,
child: _pickerBox(
value.isNotEmpty ? value : placeholder,
value.isEmpty,
const XIcon('calendar', size: 16, color: AppColors.outline),
),
);
}
Widget _salesmanPicker() {
final labels = ['默认本人', ..._salesmen.map((s) => s.optionLabel)];
final index = _salesmanIndex;
final text = index >= 0 && index < labels.length ? labels[index] : '默认本人';
return GestureDetector(
onTap: () => _pickIndex(labels, index < 0 ? 0 : index, (i) {
setState(() => _salesmanIndex = i);
}),
child: _pickerBox(
text,
index < 0,
const XIcon('forward', size: 16, color: AppColors.outline),
),
);
}
Widget _pickerBox(String text, bool placeholder, Widget? trailing) {
return Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: _fieldDecoration,
child: Row(
children: [
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: 15,
color: placeholder
? AppColors.textPlaceholder
: AppColors.onSurface,
),
),
),
?trailing,
],
),
);
}
Widget _input(
TextEditingController controller,
String hint, {
bool number = false,
}) {
return Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: _fieldDecoration,
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
keyboardType: number ? TextInputType.number : TextInputType.text,
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: _inputDecoration(hint),
),
),
],
),
);
}
Widget _textarea(TextEditingController controller, String hint) {
return Container(
height: 92,
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
decoration: _fieldDecoration,
child: TextField(
controller: controller,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: _inputDecoration(hint),
),
);
}
BoxDecoration get _fieldDecoration => BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(10),
);
InputDecoration _inputDecoration(String hint) => InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: hint,
hintStyle: const TextStyle(fontSize: 15, color: AppColors.textPlaceholder),
);
}
@@ -0,0 +1,553 @@
import 'dart:async';
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 '../data/visit_api.dart';
import '../data/visit_models.dart';
/// 拜访列表页,对应 `pages/visit/list.vue`。
class VisitListPage extends ConsumerStatefulWidget {
const VisitListPage({super.key});
@override
ConsumerState<VisitListPage> createState() => _VisitListPageState();
}
class _VisitListPageState extends ConsumerState<VisitListPage> {
static const int _pageSize = 10;
final ScrollController _scrollController = ScrollController();
final TextEditingController _keywordController = TextEditingController();
List<VisitInfo> _list = const [];
VisitStats _stats = VisitStats.empty;
int _total = 0;
int _page = 1;
bool _hasMore = true;
bool _loading = false;
String _keyword = '';
int? _filterStatus;
int? _filterPriority;
String _filterPlanDate = '';
Timer? _searchDebounce;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_reload();
}
@override
void dispose() {
_searchDebounce?.cancel();
_scrollController.dispose();
_keywordController.dispose();
super.dispose();
}
void _onScroll() {
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 200) _loadMore();
}
Future<void> _loadStats() async {
try {
final res = await ref.read(visitApiProvider).stats();
if (res.isOk && res.data != null && mounted) {
setState(() => _stats = res.data!);
}
} catch (_) {
// 统计失败不阻塞列表
}
}
Future<void> _load({bool reset = false}) async {
if (_loading) return;
setState(() => _loading = true);
if (reset) {
_page = 1;
_hasMore = true;
}
try {
final res = await ref
.read(visitApiProvider)
.list(
page: _page,
pageSize: _pageSize,
keyword: _keyword.isEmpty ? null : _keyword,
status: _filterStatus,
priority: _filterPriority,
planStartDate: _filterPlanDate.isEmpty ? null : _filterPlanDate,
planEndDate: _filterPlanDate.isEmpty ? null : _filterPlanDate,
);
if (res.isOk && res.data != null && mounted) {
final data = res.data!;
setState(() {
_list = reset ? data.list : [..._list, ...data.list];
_total = data.total;
_hasMore = _list.length < data.total;
if (_hasMore) _page += 1;
});
}
} catch (_) {
showAppToast('加载拜访记录失败');
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _reload() {
_loadStats();
_load(reset: true);
}
void _loadMore() {
if (_hasMore && !_loading) _load();
}
Future<void> _refresh() async {
await _loadStats();
await _load(reset: true);
}
void _onSearchChanged(String value) {
_keyword = value;
_searchDebounce?.cancel();
_searchDebounce = Timer(
const Duration(milliseconds: 350),
() => _load(reset: true),
);
}
void _setStatus(int? value) {
if (_filterStatus == value) return;
setState(() {
_filterStatus = value;
_filterPlanDate = '';
});
_load(reset: true);
}
void _setPriority(int? value) {
if (_filterPriority == value) return;
setState(() => _filterPriority = value);
_load(reset: true);
}
void _quickStatus(int status) {
setState(() {
_filterPlanDate = '';
_filterPriority = null;
_filterStatus = status;
});
_load(reset: true);
}
void _quickToday() {
setState(() {
_filterStatus = null;
_filterPriority = null;
_filterPlanDate = todayStr();
});
_load(reset: true);
}
void _quickPriority() {
setState(() {
_filterPlanDate = '';
_filterStatus = VisitStatus.pending;
_filterPriority = 1;
});
_load(reset: true);
}
String _findLabel(List<ValueOption> options, int? value) {
if (value == null) return '';
return options.where((o) => o.value == value).map((o) => o.label).firstOrNull ??
'';
}
@override
Widget build(BuildContext context) {
return XPageShell(
topBar: XTopBar.nav(
title: '客户拜访',
onBack: () => context.pop(),
trailing: GestureDetector(
onTap: () => context.push('/visit/batch'),
behavior: HitTestBehavior.opaque,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const XIcon('calendar', size: 20, color: AppColors.primary),
const SizedBox(width: 4),
Text(
'批量安排',
style: AppTypography.labelBold.copyWith(
color: AppColors.primary,
),
),
],
),
),
),
child: Stack(
children: [
RefreshIndicator(
onRefresh: _refresh,
color: AppColors.primary,
child: ListView(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 140),
children: [
_buildKpiGrid(),
const SizedBox(height: 14),
XSearchField(
controller: _keywordController,
hintText: '按单号、主题、拜访人搜索...',
onChanged: _onSearchChanged,
),
const SizedBox(height: 8),
_buildFilters(),
..._buildList(),
],
),
),
Positioned(right: 20, bottom: 40, child: _buildFab()),
],
),
);
}
Widget _buildKpiGrid() {
return LayoutBuilder(
builder: (context, constraints) {
final width = (constraints.maxWidth - 10) / 2;
return Wrap(
spacing: 10,
runSpacing: 10,
children: [
_kpiTile(
width,
'${_stats.pending}',
'待执行',
AppColors.primary,
() => _quickStatus(VisitStatus.pending),
),
_kpiTile(
width,
'${_stats.todayPlan}',
'今日计划',
AppColors.tertiary,
_quickToday,
),
_kpiTile(
width,
'${_stats.urgentPending}',
'加急待办',
AppColors.statusError,
_quickPriority,
),
_kpiTile(
width,
'${_stats.conversionRate}%',
'转化率',
AppColors.secondary,
null,
),
],
);
},
);
}
Widget _kpiTile(
double width,
String value,
String label,
Color valueColor,
VoidCallback? onTap,
) {
return GestureDetector(
onTap: onTap,
child: Container(
width: width,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(AppRadius.lg),
boxShadow: const [
BoxShadow(color: Color(0x0A000000), blurRadius: 8, offset: Offset(0, 2)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: AppTypography.dataMono.copyWith(
fontSize: 22,
fontWeight: FontWeight.w700,
color: valueColor,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
],
),
),
);
}
Widget _buildFilters() {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_filterChip('全部', _filterStatus == null, () => _setStatus(null)),
for (final option in visitStatusOptions)
_filterChip(
option.label,
_filterStatus == option.value,
() => _setStatus(option.value),
),
],
),
),
),
Padding(
padding: const EdgeInsets.only(top: 6, bottom: 2),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_filterChip(
'全部优先级',
_filterPriority == null,
() => _setPriority(null),
),
for (final option in visitPriorityOptions)
_filterChip(
option.label,
_filterPriority == option.value,
() => _setPriority(option.value),
),
],
),
),
),
],
),
);
}
Widget _filterChip(String label, bool selected, VoidCallback onTap) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: XFilterChip(label: label, selected: selected, onTap: onTap),
);
}
List<Widget> _buildList() {
if (_loading && _list.isEmpty) {
return const [XPlaceholder(text: '加载中...')];
}
if (_list.isEmpty) {
return const [XPlaceholder(text: '暂无拜访记录')];
}
return [
for (final visit in _list) _buildCard(visit),
_buildFooter(),
];
}
Widget _buildCard(VisitInfo visit) {
final subject = visit.subject?.isNotEmpty == true
? visit.subject!
: (visit.customer?.customerName?.isNotEmpty == true
? visit.customer!.customerName!
: '未命名拜访');
final customerName = visit.customer?.customerName?.isNotEmpty == true
? visit.customer!.customerName!
: '未知客户';
final typeText = _findLabel(visitTypeOptions, visit.visitType);
return XCard(
clip: true,
margin: const EdgeInsets.only(top: 12),
onTap: () => context.push('/visit/detail/${visit.id}'),
child: Stack(
children: [
Positioned(
left: 0,
top: 0,
bottom: 0,
child: Container(width: 4, color: visitStatusBarColor(visit.status)),
),
Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
subject,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
),
const SizedBox(width: 8),
XChip(
visitStatusText(visit.status),
tone: visitStatusTone(visit.status),
),
],
),
const SizedBox(height: 8),
Row(
children: [
const XIcon('groups', size: 15, color: AppColors.textMuted),
const SizedBox(width: 6),
Text(
customerName,
style: const TextStyle(
fontSize: 14,
color: AppColors.onSurfaceVariant,
),
),
if (typeText.isNotEmpty) ...[
const SizedBox(width: 6),
XChip(typeText, tone: ChipTone.muted),
],
],
),
const SizedBox(height: 8),
Row(
children: [
_metaItem(
'schedule',
visit.planTime != null && visit.planTime!.isNotEmpty
? formatDateLabel(visit.planTime)
: '未排期',
),
const SizedBox(width: 14),
_metaItem(
'person',
visit.visitPerson.isNotEmpty ? visit.visitPerson : '-',
),
],
),
],
),
),
),
Container(
width: 92,
color: AppColors.surfaceBright,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
XChip(
visit.priority == null ? '' : visitPriorityText(visit.priority),
tone: visitPriorityTone(visit.priority),
),
const SizedBox(height: 8),
Text(
visit.visitNo,
style: AppTypography.dataMono.copyWith(
fontSize: 10,
color: AppColors.textMuted,
),
),
],
),
),
],
),
],
),
);
}
Widget _metaItem(String icon, String text) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
XIcon(icon, size: 15, color: AppColors.textMuted),
const SizedBox(width: 4),
Text(
text,
style: const TextStyle(fontSize: 13, color: AppColors.textMuted),
),
],
);
}
Widget _buildFooter() {
return Padding(
padding: const EdgeInsets.only(top: 16, bottom: 4),
child: Center(
child: Text(
_hasMore ? '加载中...' : '共 $_total 条',
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
),
);
}
Widget _buildFab() {
return GestureDetector(
onTap: () => context.push('/visit/form'),
child: Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(14),
boxShadow: const [
BoxShadow(color: Color(0x4D0054CD), blurRadius: 16, offset: Offset(0, 6)),
],
),
child: const Center(child: XIcon('add', size: 24, color: Colors.white)),
),
);
}
}
@@ -0,0 +1,651 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../app/tab_index.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.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 '../../auth/application/session_controller.dart';
import '../../auth/data/auth_models.dart';
import '../../dashboard/data/dashboard_api.dart';
import '../../dashboard/data/dashboard_models.dart';
enum _TaskType { survey, issue }
/// 最近任务的展示模型,字段与源页面 `TaskItem` 一一对应。
class _TaskItem {
const _TaskItem({
required this.id,
required this.title,
required this.desc,
required this.time,
required this.type,
required this.high,
required this.completed,
required this.isLate,
});
final int id;
final String title;
final String desc;
final String time;
final _TaskType type;
final bool high;
final bool completed;
final bool isLate;
}
/// 工作台 Tab,对应 `components/tabs/WorkbenchTab.vue`。
class WorkbenchTab extends ConsumerStatefulWidget {
const WorkbenchTab({super.key});
@override
ConsumerState<WorkbenchTab> createState() => _WorkbenchTabState();
}
class _WorkbenchTabState extends ConsumerState<WorkbenchTab> {
int _activeProjects = 0;
int _newProjectsWeek = 0;
int _pendingTasks = 0;
int _urgentTasks = 0;
int _newAlarms = 0;
List<_TaskItem> _tasks = const [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
Future<void> _load() async {
try {
final res = await ref.read(dashboardApiProvider).stats();
if (!mounted) return;
final dash = res.isOk ? res.data : null;
if (dash == null) return;
setState(() {
_activeProjects = dash.contractTotal;
_newProjectsWeek = dash.customerMonthlyNew;
_pendingTasks = dash.surveyPending;
_urgentTasks = dash.surveyPending;
_newAlarms = dash.surveyPending;
_tasks = dash.surveyList.map(_toTaskItem).toList();
});
} catch (_) {
// 静默刷新:请求失败时保留已渲染的数据
}
}
/// dashboard.surveyList 元素 → 最近任务(判定规则照抄源 `toTaskItem`)。
_TaskItem _toTaskItem(DashboardSurveyItem s) {
// 状态 0待指派 1现场勘察中 2待技术复核 3已归档 5已取消
final canceled = s.status == 5;
final archived = s.status == 3;
final pendingReview = s.status == 2;
final pendingAssign = s.status == 0;
// type 映射:勘察中/待指派 → survey;待复核 → issue;已归档/已取消 → issue
final type = (pendingReview || archived || canceled)
? _TaskType.issue
: _TaskType.survey;
return _TaskItem(
id: s.id,
title: '${s.customerName}的勘察',
desc: s.locale.isNotEmpty ? s.locale : s.owner,
time: s.time,
type: type,
high: pendingAssign,
completed: archived || canceled,
isLate: canceled,
);
}
String _typeLabel(_TaskType type) =>
type == _TaskType.survey ? '勘测' : '问题';
ChipTone _typeTone(_TaskType type) =>
type == _TaskType.survey ? ChipTone.primary : ChipTone.error;
Future<void> _goSurvey() async {
await context.push('/survey');
if (mounted) _load();
}
Future<void> _goVisit() async {
await context.push('/visit');
if (mounted) _load();
}
@override
Widget build(BuildContext context) {
ref.listen(tabIndexProvider, (previous, next) {
if (next == AppTab.workbench) _load();
});
final user = ref.watch(sessionProvider).user;
final avatarText = user?.avatarText ?? '陈';
final displayName = user?.displayName ?? '陈伟';
final roleText = user == null ? '高级销售工程师' : roleLabel(user.roles);
final userNo = user == null
? '工号: XT-8842'
: '工号: ${user.username.toUpperCase()}';
return XPageShell(
topBar: XTopBar.brand(title: '鑫潼智电', trailingIcon: 'notification'),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 120),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildUserCard(avatarText, displayName, roleText, userNo),
_buildStats(),
_buildQuickActions(),
_buildTasks(),
],
),
),
);
}
Widget _buildUserCard(
String avatarText,
String displayName,
String roleText,
String userNo,
) {
return XCard(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
Container(
width: 56,
height: 56,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.surfaceContainerHigh,
shape: BoxShape.circle,
border: Border.all(color: AppColors.primaryFixedDim, width: 2),
),
child: Text(
avatarText,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(displayName, style: AppTypography.titleSm),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
roleText,
style: const TextStyle(
fontSize: 12,
letterSpacing: 1,
color: AppColors.textMuted,
),
),
),
Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
children: [
const XChip(
'在线',
tone: ChipTone.tertiary,
leading: _OnlineDot(),
),
const SizedBox(width: 8),
Text(
userNo,
style: AppTypography.dataMono.copyWith(
color: AppColors.textMuted,
),
),
],
),
),
],
),
),
],
),
);
}
Widget _buildStats() {
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
children: [
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: _statCard(
head: _statHead(
'进行中项目',
const XIcon('domain', size: 20, color: AppColors.primary),
),
value: _statValue('$_activeProjects'),
foot: _statFoot(
children: [
const XIcon(
'trend-up',
size: 14,
color: AppColors.tertiaryContainer,
),
const SizedBox(width: 4),
Text(
'本月新增客户 +$_newProjectsWeek',
style: const TextStyle(
fontSize: 12,
color: AppColors.tertiary,
),
),
],
),
),
),
const SizedBox(width: 12),
Expanded(
child: _statCard(
head: _statHead(
'待办任务',
const XIcon(
'clock',
size: 20,
color: AppColors.statusWarn,
),
),
value: _statValue('$_pendingTasks'),
foot: _statFoot(
children: [
Text(
'$_urgentTasks 项需立即处理',
style: const TextStyle(
fontSize: 12,
color: AppColors.statusWarn,
),
),
],
),
),
),
],
),
),
const SizedBox(height: 12),
XCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_statHead(
'新告警',
const XIcon(
'warning',
size: 20,
color: AppColors.statusError,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_statValue('$_newAlarms', color: AppColors.statusError),
GestureDetector(
onTap: _goSurvey,
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0x14DC2626),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
'查看',
style: AppTypography.labelBold.copyWith(
color: AppColors.statusError,
),
),
),
),
],
),
Padding(
padding: const EdgeInsets.only(top: 6),
child: const Text(
'待指派勘察单',
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
),
),
],
),
),
],
),
);
}
Widget _statCard({
required Widget head,
required Widget value,
required Widget foot,
}) {
return XCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [head, value, foot],
),
);
}
Widget _statHead(String label, Widget icon) {
return Container(
padding: const EdgeInsets.only(bottom: 12),
margin: const EdgeInsets.only(bottom: 8),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: AppColors.borderRule)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: AppTypography.captionSm.copyWith(
color: AppColors.textMuted,
letterSpacing: 1,
),
),
icon,
],
),
);
}
Widget _statValue(String value, {Color? color}) {
return Text(
value,
style: AppTypography.displayLg.copyWith(height: 1, color: color),
);
}
Widget _statFoot({required List<Widget> children}) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(children: children),
);
}
Widget _buildQuickActions() {
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('快捷操作', style: AppTypography.titleSm),
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
Expanded(
child: _quickItem(
'客户录入',
'person-add',
const Color(0xFFE5EFFF),
const Color(0xFF0054CD),
() => ref
.read(tabIndexProvider.notifier)
.select(AppTab.customer),
),
),
Expanded(
child: _quickItem(
'勘查预约',
'calendar',
const Color(0xFFEADDFF),
const Color(0xFF712AE2),
_goSurvey,
),
),
Expanded(
child: _quickItem(
'拜访记录',
'check',
const Color(0x1A00685F),
const Color(0xFF008378),
_goVisit,
),
),
Expanded(
child: _quickItem(
'报表查看',
'chart',
const Color(0x1A1D6FFF),
const Color(0xFF0C4DD1),
() =>
ref.read(tabIndexProvider.notifier).select(AppTab.mine),
),
),
],
),
),
],
),
);
}
Widget _quickItem(
String label,
String icon,
Color background,
Color color,
VoidCallback onTap,
) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Column(
children: [
Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(color: background, shape: BoxShape.circle),
child: XIcon(icon, size: 22, color: color),
),
const SizedBox(height: 8),
Text(label, style: AppTypography.captionSm),
],
),
);
}
Widget _buildTasks() {
// 外层保留卡片阴影,内层裁剪对应源样式的 `overflow: hidden`。
return XCard(
padding: EdgeInsets.zero,
child: ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: AppColors.borderRule)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('最近任务', style: AppTypography.titleSm),
GestureDetector(
onTap: _goSurvey,
behavior: HitTestBehavior.opaque,
child: Row(
children: [
Text(
'查看全部',
style: AppTypography.labelBold.copyWith(
color: AppColors.primary,
),
),
const SizedBox(width: 4),
const XIcon(
'forward',
size: 14,
color: AppColors.primary,
),
],
),
),
],
),
),
for (var i = 0; i < _tasks.length; i++) ...[
if (i > 0)
const Divider(
height: 1,
thickness: 1,
color: AppColors.borderRule,
),
_taskItem(_tasks[i]),
],
],
),
),
);
}
Widget _taskItem(_TaskItem task) {
final isIssue = task.type == _TaskType.issue;
final iconColor = isIssue
? AppColors.statusError
: (task.completed ? AppColors.primary : AppColors.outline);
return GestureDetector(
onTap: _goSurvey,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 4),
child: XIcon(
isIssue ? 'warning' : 'check-circle',
size: 22,
color: iconColor,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
task.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: task.isLate
? const Color(0x14DC2626)
: AppColors.surfaceContainer,
borderRadius: BorderRadius.circular(6),
),
child: Text(
task.time,
style: TextStyle(
fontSize: 12,
color: task.isLate
? AppColors.statusError
: AppColors.textMuted,
),
),
),
],
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
task.desc,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
color: AppColors.textMuted,
),
),
),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
XChip(_typeLabel(task.type), tone: _typeTone(task.type)),
if (task.high) ...[
const SizedBox(width: 8),
const XChip('高优先级', tone: ChipTone.warn),
],
],
),
),
],
),
),
],
),
),
);
}
}
/// 「在线」chip 左侧的状态圆点。
class _OnlineDot extends StatelessWidget {
const _OnlineDot();
@override
Widget build(BuildContext context) {
return Container(
width: 7,
height: 7,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.tertiary,
),
);
}
}