feat: 完成 Flutter 版智能电网 IoT 管理应用初始化搭建
This commit is contained in:
commit
f61b7bf90b
120 files changed
+15229
No files matched your search
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/app_keys.dart';
|
||||
import '../core/theme/app_theme.dart';
|
||||
import '../core/theme/app_typography.dart';
|
||||
import 'router.dart';
|
||||
|
||||
class SmartGridApp extends ConsumerWidget {
|
||||
const SmartGridApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(routerProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: '鑫潼智电运营管理',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.build(),
|
||||
routerConfig: router,
|
||||
scaffoldMessengerKey: AppKeys.scaffoldMessenger,
|
||||
locale: const Locale('zh', 'CN'),
|
||||
supportedLocales: const [Locale('zh', 'CN'), Locale('en', 'US')],
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
// 对应 App.vue 中挂在 `page` 上的全局字体设置:15px / 行高 1.6 / on-surface。
|
||||
builder: (context, child) => DefaultTextStyle(
|
||||
style: appDefaultTextStyle,
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../core/app_keys.dart';
|
||||
import '../features/auth/application/session_controller.dart';
|
||||
import '../features/auth/presentation/login_page.dart';
|
||||
import '../features/customer/presentation/customer_detail_page.dart';
|
||||
import '../features/customer/presentation/customer_edit_page.dart';
|
||||
import '../features/project/presentation/project_detail_page.dart';
|
||||
import '../features/survey/presentation/survey_form_page.dart';
|
||||
import '../features/survey/presentation/survey_list_page.dart';
|
||||
import '../features/visit/presentation/visit_batch_page.dart';
|
||||
import '../features/visit/presentation/visit_detail_page.dart';
|
||||
import '../features/visit/presentation/visit_execute_page.dart';
|
||||
import '../features/visit/presentation/visit_form_page.dart';
|
||||
import '../features/visit/presentation/visit_list_page.dart';
|
||||
import 'tabs_page.dart';
|
||||
|
||||
/// 会话语义变化时驱动 go_router 重新执行 `redirect`。
|
||||
class _SessionRefresh extends ChangeNotifier {
|
||||
void bump() => notifyListeners();
|
||||
}
|
||||
|
||||
/// 路由表,对应 uni-app 版 `pages.json` 的 12 个页面。
|
||||
///
|
||||
/// 除登录页与 Tab 容器页外,其余均为二级页,统一使用主题里的
|
||||
/// `CupertinoPageTransitionsBuilder`(新页面从右侧滑入),
|
||||
/// 与源项目 `animationType: slide-in-right` 的表现一致。
|
||||
final routerProvider = Provider<GoRouter>((ref) {
|
||||
final refresh = _SessionRefresh();
|
||||
ref.listen(sessionProvider, (_, _) => refresh.bump());
|
||||
ref.onDispose(refresh.dispose);
|
||||
|
||||
int? queryId(GoRouterState state) =>
|
||||
int.tryParse(state.uri.queryParameters['id'] ?? '');
|
||||
|
||||
return GoRouter(
|
||||
navigatorKey: AppKeys.rootNavigator,
|
||||
initialLocation: '/login',
|
||||
refreshListenable: refresh,
|
||||
redirect: (context, state) {
|
||||
final loggedIn = ref.read(sessionProvider).isLoggedIn;
|
||||
final atLogin = state.matchedLocation == '/login';
|
||||
if (!loggedIn) return atLogin ? null : '/login';
|
||||
if (atLogin) return '/tabs';
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
|
||||
GoRoute(path: '/tabs', builder: (context, state) => const TabsPage()),
|
||||
|
||||
// 客户模块
|
||||
GoRoute(
|
||||
path: '/customer/detail/:id',
|
||||
builder: (context, state) =>
|
||||
CustomerDetailPage(id: int.parse(state.pathParameters['id']!)),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/customer/edit',
|
||||
builder: (context, state) => CustomerEditPage(id: queryId(state)),
|
||||
),
|
||||
|
||||
// 拜访模块
|
||||
GoRoute(
|
||||
path: '/visit',
|
||||
builder: (context, state) => const VisitListPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/visit/detail/:id',
|
||||
builder: (context, state) =>
|
||||
VisitDetailPage(id: int.parse(state.pathParameters['id']!)),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/visit/form',
|
||||
builder: (context, state) => VisitFormPage(id: queryId(state)),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/visit/execute/:id',
|
||||
builder: (context, state) =>
|
||||
VisitExecutePage(id: int.parse(state.pathParameters['id']!)),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/visit/batch',
|
||||
builder: (context, state) => const VisitBatchPage(),
|
||||
),
|
||||
|
||||
// 勘测模块
|
||||
GoRoute(
|
||||
path: '/survey',
|
||||
builder: (context, state) => const SurveyListPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/survey/form',
|
||||
builder: (context, state) => SurveyFormPage(
|
||||
id: queryId(state),
|
||||
customerId: int.tryParse(state.uri.queryParameters['customerId'] ?? ''),
|
||||
),
|
||||
),
|
||||
|
||||
// 项目模块(按后端 project-plans 真实字段重做)
|
||||
GoRoute(
|
||||
path: '/project/detail/:id',
|
||||
builder: (context, state) =>
|
||||
ProjectDetailPage(id: int.parse(state.pathParameters['id']!)),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// 底部导航的四个 Tab,对应 `components/XBottomNav.vue` 的 4 个入口。
|
||||
enum AppTab { workbench, customer, project, mine }
|
||||
|
||||
extension AppTabKey on AppTab {
|
||||
/// 与 `XBottomNav.items` 中的 key 一致。
|
||||
String get key => switch (this) {
|
||||
AppTab.workbench => 'workbench',
|
||||
AppTab.customer => 'customer',
|
||||
AppTab.project => 'project',
|
||||
AppTab.mine => 'mine',
|
||||
};
|
||||
}
|
||||
|
||||
/// 当前 Tab。对应 uni-app 版 `utils/tabs.ts` 的 `currentTab` ref + `switchTab`。
|
||||
class TabIndexController extends Notifier<AppTab> {
|
||||
@override
|
||||
AppTab build() => AppTab.workbench;
|
||||
|
||||
void select(AppTab tab) => state = tab;
|
||||
|
||||
/// 按 `XBottomNav` 回传的字符串 key 切换。
|
||||
void selectByKey(String key) {
|
||||
state = AppTab.values.firstWhere((t) => t.key == key, orElse: () => state);
|
||||
}
|
||||
}
|
||||
|
||||
final tabIndexProvider = NotifierProvider<TabIndexController, AppTab>(
|
||||
TabIndexController.new,
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/theme/app_colors.dart';
|
||||
import '../features/customer/presentation/customer_tab.dart';
|
||||
import '../features/mine/presentation/mine_tab.dart';
|
||||
import '../features/project/presentation/project_tab.dart';
|
||||
import '../features/workbench/presentation/workbench_tab.dart';
|
||||
import '../shared/widgets/x_bottom_nav.dart';
|
||||
import 'tab_index.dart';
|
||||
|
||||
/// 4 个 Tab 的常驻容器,对应 `pages/tabs/index.vue`。
|
||||
///
|
||||
/// 源项目用 `v-show` 切换,不销毁重建、保留滚动位置与内部状态;
|
||||
/// 这里用 [IndexedStack] 达到同样效果。
|
||||
class TabsPage extends ConsumerWidget {
|
||||
const TabsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final current = ref.watch(tabIndexProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.bgPage,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: IndexedStack(
|
||||
index: current.index,
|
||||
children: const [
|
||||
WorkbenchTab(),
|
||||
CustomerTab(),
|
||||
ProjectTab(),
|
||||
MineTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: XBottomNav(
|
||||
current: current.key,
|
||||
onChanged: (key) =>
|
||||
ref.read(tabIndexProvider.notifier).selectByKey(key),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 全局 Key:供非 Widget 层(拦截器、工具函数)访问 Navigator / ScaffoldMessenger。
|
||||
abstract final class AppKeys {
|
||||
static final rootNavigator = GlobalKey<NavigatorState>();
|
||||
static final scaffoldMessenger = GlobalKey<ScaffoldMessengerState>();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/// 运行期配置。
|
||||
///
|
||||
/// 与 uni-app 版 `utils/request.ts` 的地址策略一致:App 端直连后端。
|
||||
/// 真机联调时用 `--dart-define=API_BASE_URL=http://<局域网IP>:7001/api` 覆盖默认值。
|
||||
abstract final class Env {
|
||||
static const String apiBaseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://192.168.3.29:7001/api',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../env.dart';
|
||||
import '../storage/token_storage.dart';
|
||||
import 'api_response.dart';
|
||||
|
||||
/// 图片上传结果,对应 uni-app 版 `api/upload.ts` 的 `UploadResult`。
|
||||
class UploadResult {
|
||||
const UploadResult({
|
||||
required this.key,
|
||||
required this.url,
|
||||
required this.name,
|
||||
required this.size,
|
||||
});
|
||||
|
||||
/// 对象存储 key,用于持久化到业务表
|
||||
final String key;
|
||||
|
||||
/// 预签名访问 URL,用于即时预览
|
||||
final String url;
|
||||
final String name;
|
||||
final int size;
|
||||
|
||||
factory UploadResult.fromJson(Map<String, dynamic> json) => UploadResult(
|
||||
key: json['key'] as String? ?? '',
|
||||
url: json['url'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
size: (json['size'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// 统一请求封装,等价于 uni-app 版 `utils/request.ts`:
|
||||
/// - 自动注入 `Authorization: Bearer <token>`
|
||||
/// - 解析 `{ code, message, data }`,非 200 也照常返回给调用方判断
|
||||
/// - 401 且提示「过期」时刷新 token 并重试一次,其余 401 直接登出
|
||||
class ApiClient {
|
||||
ApiClient({required TokenStorage storage, Dio? dio})
|
||||
: _storage = storage,
|
||||
_dio =
|
||||
dio ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
baseUrl: Env.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 15),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
sendTimeout: const Duration(seconds: 30),
|
||||
// 自行判断业务 code,不因 HTTP 状态码抛异常
|
||||
validateStatus: (_) => true,
|
||||
),
|
||||
);
|
||||
|
||||
final TokenStorage _storage;
|
||||
final Dio _dio;
|
||||
|
||||
final _unauthorizedController = StreamController<void>.broadcast();
|
||||
|
||||
/// 会话失效事件:刷新失败或非过期类 401 时触发,由会话层订阅后跳转登录页。
|
||||
Stream<void> get onUnauthorized => _unauthorizedController.stream;
|
||||
|
||||
Dio get raw => _dio;
|
||||
|
||||
// ---- 对外请求方法 ----
|
||||
|
||||
Future<ApiResponse<T>> get<T>(
|
||||
String url, {
|
||||
Map<String, dynamic>? query,
|
||||
T Function(dynamic data)? parse,
|
||||
bool needAuth = true,
|
||||
}) => _send<T>(
|
||||
'GET',
|
||||
url,
|
||||
query: _compact(query),
|
||||
parse: parse,
|
||||
needAuth: needAuth,
|
||||
);
|
||||
|
||||
Future<ApiResponse<T>> post<T>(
|
||||
String url, {
|
||||
Object? body,
|
||||
T Function(dynamic data)? parse,
|
||||
bool needAuth = true,
|
||||
}) => _send<T>('POST', url, body: body, parse: parse, needAuth: needAuth);
|
||||
|
||||
Future<ApiResponse<T>> put<T>(
|
||||
String url, {
|
||||
Object? body,
|
||||
T Function(dynamic data)? parse,
|
||||
bool needAuth = true,
|
||||
}) => _send<T>('PUT', url, body: body, parse: parse, needAuth: needAuth);
|
||||
|
||||
Future<ApiResponse<T>> delete<T>(
|
||||
String url, {
|
||||
T Function(dynamic data)? parse,
|
||||
bool needAuth = true,
|
||||
}) => _send<T>('DELETE', url, parse: parse, needAuth: needAuth);
|
||||
|
||||
/// 上传现场照片到对象存储(multipart/form-data,字段名 `file`)。
|
||||
/// 严格依赖对象存储:失败直接抛异常,由调用方阻断保存并提示用户。
|
||||
Future<UploadResult> uploadImage(String filePath) async {
|
||||
try {
|
||||
final form = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(filePath),
|
||||
});
|
||||
final res = await _dio.post<dynamic>(
|
||||
'/upload/image',
|
||||
data: form,
|
||||
options: Options(headers: _authHeader()),
|
||||
);
|
||||
final json = _asMap(res.data);
|
||||
final code = (json['code'] as num?)?.toInt();
|
||||
final data = json['data'];
|
||||
if (code == 200 && data is Map) {
|
||||
return UploadResult.fromJson(data.cast<String, dynamic>());
|
||||
}
|
||||
throw ApiException(
|
||||
(json['message'] as String?) ?? '图片上传失败',
|
||||
code: code,
|
||||
);
|
||||
} on ApiException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
throw const ApiException('图片上传失败:网络异常');
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() => _unauthorizedController.close();
|
||||
|
||||
// ---- 内部实现 ----
|
||||
|
||||
Future<ApiResponse<T>> _send<T>(
|
||||
String method,
|
||||
String url, {
|
||||
Map<String, dynamic>? query,
|
||||
Object? body,
|
||||
T Function(dynamic data)? parse,
|
||||
required bool needAuth,
|
||||
bool allowRetry = true,
|
||||
}) async {
|
||||
late Response<dynamic> res;
|
||||
try {
|
||||
res = await _dio.request<dynamic>(
|
||||
url,
|
||||
data: body,
|
||||
queryParameters: query,
|
||||
options: Options(
|
||||
method: method,
|
||||
headers: needAuth ? _authHeader() : null,
|
||||
),
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
throw ApiException(e.message ?? '网络请求失败');
|
||||
}
|
||||
|
||||
final json = _asMap(res.data);
|
||||
final response = ApiResponse<T>.fromJson(json, parse);
|
||||
|
||||
if (response.code == 401) {
|
||||
final expired = response.message.contains('过期');
|
||||
if (expired && allowRetry) {
|
||||
final refreshed = await _refreshToken();
|
||||
if (refreshed) {
|
||||
return _send<T>(
|
||||
method,
|
||||
url,
|
||||
query: query,
|
||||
body: body,
|
||||
parse: parse,
|
||||
needAuth: needAuth,
|
||||
allowRetry: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
_unauthorizedController.add(null);
|
||||
throw const ApiException('登录已失效,请重新登录', code: 401);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _authHeader() {
|
||||
final token = _storage.accessToken;
|
||||
return token.isEmpty ? const {} : {'Authorization': 'Bearer $token'};
|
||||
}
|
||||
|
||||
Future<bool>? _refreshing;
|
||||
|
||||
/// 刷新 accessToken;并发调用共用同一个请求,避免刷新风暴。
|
||||
Future<bool> _refreshToken() {
|
||||
return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
|
||||
}
|
||||
|
||||
Future<bool> _doRefresh() async {
|
||||
final refreshToken = _storage.refreshToken;
|
||||
if (refreshToken.isEmpty) return false;
|
||||
try {
|
||||
final res = await _dio.post<dynamic>(
|
||||
'/auth/refresh',
|
||||
data: {'refreshToken': refreshToken},
|
||||
);
|
||||
final json = _asMap(res.data);
|
||||
final data = json['data'];
|
||||
if ((json['code'] as num?)?.toInt() == 200 && data is Map) {
|
||||
final token = data['accessToken'] as String?;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
await _storage.setAccessToken(token);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// 刷新失败按会话失效处理
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _asMap(dynamic data) {
|
||||
if (data is Map) return data.cast<String, dynamic>();
|
||||
if (data is String && data.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(data);
|
||||
if (decoded is Map) return decoded.cast<String, dynamic>();
|
||||
} catch (_) {}
|
||||
}
|
||||
return const {'code': -1, 'message': '响应解析失败'};
|
||||
}
|
||||
|
||||
/// 去掉 null / 空串查询条件。
|
||||
/// uni-app 会把 undefined 序列化成空串(如 `status=`),后端对空串查询条件会 500,
|
||||
/// 因此这里同样只在有值时才拼接参数。
|
||||
static Map<String, dynamic>? _compact(Map<String, dynamic>? src) {
|
||||
if (src == null || src.isEmpty) return null;
|
||||
final out = <String, dynamic>{};
|
||||
src.forEach((k, v) {
|
||||
if (v == null) return;
|
||||
if (v is String && v.isEmpty) return;
|
||||
out[k] = v;
|
||||
});
|
||||
return out.isEmpty ? null : out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/// 后端统一响应体:`{ code, message, data }`。
|
||||
class ApiResponse<T> {
|
||||
const ApiResponse({required this.code, required this.message, this.data});
|
||||
|
||||
final int code;
|
||||
final String message;
|
||||
final T? data;
|
||||
|
||||
bool get isOk => code == 200;
|
||||
|
||||
factory ApiResponse.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
T Function(dynamic data)? parse,
|
||||
) {
|
||||
final raw = json['data'];
|
||||
return ApiResponse<T>(
|
||||
code: (json['code'] as num?)?.toInt() ?? -1,
|
||||
message: (json['message'] as String?) ?? '',
|
||||
data: (raw == null || parse == null) ? null : parse(raw),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 网络/协议层异常。调用方捕获后用 `message` 提示用户。
|
||||
class ApiException implements Exception {
|
||||
const ApiException(this.message, {this.code});
|
||||
|
||||
final String message;
|
||||
final int? code;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'network/api_client.dart';
|
||||
import 'storage/token_storage.dart';
|
||||
|
||||
/// 在 `main()` 中读取 SharedPreferences 后通过 override 注入。
|
||||
final tokenStorageProvider = Provider<TokenStorage>(
|
||||
(ref) => throw UnimplementedError('tokenStorageProvider 必须在 ProviderScope 中 override'),
|
||||
);
|
||||
|
||||
final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
final client = ApiClient(storage: ref.watch(tokenStorageProvider));
|
||||
ref.onDispose(client.dispose);
|
||||
return client;
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 本地持久化。键名与 uni-app 版 `uni.getStorageSync` 使用的键完全一致,
|
||||
/// 便于两端在真机上迁移/对拍时理解同一份数据。
|
||||
class TokenStorage {
|
||||
TokenStorage(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
static const _kAccessToken = 'accessToken';
|
||||
static const _kRefreshToken = 'refreshToken';
|
||||
static const _kUserInfo = 'userInfo';
|
||||
|
||||
static Future<TokenStorage> open() async =>
|
||||
TokenStorage(await SharedPreferences.getInstance());
|
||||
|
||||
String get accessToken => _prefs.getString(_kAccessToken) ?? '';
|
||||
|
||||
String get refreshToken => _prefs.getString(_kRefreshToken) ?? '';
|
||||
|
||||
Map<String, dynamic>? get userInfo {
|
||||
final raw = _prefs.getString(_kUserInfo);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
return jsonDecode(raw) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setAccessToken(String token) =>
|
||||
_prefs.setString(_kAccessToken, token);
|
||||
|
||||
Future<void> setRefreshToken(String token) =>
|
||||
_prefs.setString(_kRefreshToken, token);
|
||||
|
||||
Future<void> setUserInfo(Map<String, dynamic> info) =>
|
||||
_prefs.setString(_kUserInfo, jsonEncode(info));
|
||||
|
||||
Future<void> clearSession() async {
|
||||
await _prefs.remove(_kAccessToken);
|
||||
await _prefs.remove(_kRefreshToken);
|
||||
await _prefs.remove(_kUserInfo);
|
||||
}
|
||||
|
||||
bool get hasSession => accessToken.isNotEmpty;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 设计令牌 - 色彩。
|
||||
/// 逐项对应 uni-app 版 `src/styles/variables.scss` 中的 `:root` CSS 变量。
|
||||
abstract final class AppColors {
|
||||
// Primary
|
||||
static const primary = Color(0xFF0054CD);
|
||||
static const primaryContainer = Color(0xFF136BFB);
|
||||
static const onPrimary = Color(0xFFFFFFFF);
|
||||
static const primaryFixed = Color(0xFFDAE2FF);
|
||||
static const primaryFixedDim = Color(0xFFB2C5FF);
|
||||
static const onPrimaryFixed = Color(0xFF001847);
|
||||
static const onPrimaryFixedVariant = Color(0xFF0040A1);
|
||||
|
||||
// Secondary
|
||||
static const secondary = Color(0xFF712AE2);
|
||||
static const secondaryContainer = Color(0xFF8A4CFC);
|
||||
static const onSecondary = Color(0xFFFFFFFF);
|
||||
static const secondaryFixed = Color(0xFFEADDFF);
|
||||
static const secondaryFixedDim = Color(0xFFD2BBFF);
|
||||
static const onSecondaryFixed = Color(0xFF25005A);
|
||||
static const onSecondaryFixedVariant = Color(0xFF5A00C6);
|
||||
|
||||
// Tertiary
|
||||
static const tertiary = Color(0xFF00685F);
|
||||
static const tertiaryContainer = Color(0xFF008378);
|
||||
static const onTertiary = Color(0xFFFFFFFF);
|
||||
static const tertiaryFixed = Color(0xFF89F5E7);
|
||||
static const tertiaryFixedDim = Color(0xFF6BD8CB);
|
||||
static const onTertiaryFixed = Color(0xFF00201D);
|
||||
static const onTertiaryFixedVariant = Color(0xFF005049);
|
||||
|
||||
// Error
|
||||
static const error = Color(0xFFBA1A1A);
|
||||
static const errorContainer = Color(0xFFFFDAD6);
|
||||
static const onError = Color(0xFFFFFFFF);
|
||||
static const onErrorContainer = Color(0xFF93000A);
|
||||
|
||||
// Surface
|
||||
static const surface = Color(0xFFFFFFFF);
|
||||
static const surfaceBright = Color(0xFFF8F9FF);
|
||||
static const surfaceDim = Color(0xFFCCDBF1);
|
||||
static const surfaceContainer = Color(0xFFE5EFFF);
|
||||
static const surfaceContainerLowest = Color(0xFFFFFFFF);
|
||||
static const surfaceContainerLow = Color(0xFFEEF4FF);
|
||||
static const surfaceContainerHigh = Color(0xFFDBE9FF);
|
||||
static const surfaceContainerHighest = Color(0xFFD5E4FA);
|
||||
static const surfaceVariant = Color(0xFFD5E4FA);
|
||||
|
||||
// Background
|
||||
static const bgPage = Color(0xFFEEF3F8);
|
||||
static const background = Color(0xFFF8F9FF);
|
||||
|
||||
// Text
|
||||
static const onSurface = Color(0xFF0D1C2C);
|
||||
static const onSurfaceVariant = Color(0xFF424655);
|
||||
static const textMuted = Color(0xFF5B6B7E);
|
||||
static const textPlaceholder = Color(0xFF9AA3B2);
|
||||
|
||||
// Outline
|
||||
static const outline = Color(0xFF727787);
|
||||
static const outlineVariant = Color(0xFFC2C6D8);
|
||||
static const borderRule = Color(0xFFDBE4EE);
|
||||
|
||||
// Status
|
||||
static const statusWarn = Color(0xFFE0852B);
|
||||
static const statusError = Color(0xFFDC2626);
|
||||
|
||||
// Inverse
|
||||
static const inverseSurface = Color(0xFF233142);
|
||||
static const inverseOnSurface = Color(0xFFE9F1FF);
|
||||
static const inversePrimary = Color(0xFFB2C5FF);
|
||||
|
||||
// Accent
|
||||
static const accentSubtle = Color(0x141D6FFF);
|
||||
|
||||
// 登录页品牌图标渐变
|
||||
static const brandGradientStart = Color(0xFF1D6FFF);
|
||||
static const brandGradientEnd = Color(0xFF2563EB);
|
||||
}
|
||||
|
||||
/// chip 语义样式:背景色 + 前景色,对应全局样式 `.chip-*` 系列。
|
||||
enum ChipTone { primary, secondary, tertiary, warn, error, muted }
|
||||
|
||||
extension ChipToneStyle on ChipTone {
|
||||
Color get foreground => switch (this) {
|
||||
ChipTone.primary => AppColors.primary,
|
||||
ChipTone.secondary => AppColors.secondary,
|
||||
ChipTone.tertiary => AppColors.tertiary,
|
||||
ChipTone.warn => AppColors.statusWarn,
|
||||
ChipTone.error => AppColors.statusError,
|
||||
ChipTone.muted => AppColors.onSurfaceVariant,
|
||||
};
|
||||
|
||||
Color get background => switch (this) {
|
||||
ChipTone.primary => AppColors.accentSubtle,
|
||||
ChipTone.secondary => const Color(0x14712AE2),
|
||||
ChipTone.tertiary => const Color(0x1400685F),
|
||||
ChipTone.warn => const Color(0x14E0852B),
|
||||
ChipTone.error => const Color(0x14DC2626),
|
||||
ChipTone.muted => AppColors.surfaceVariant,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/painting.dart';
|
||||
|
||||
/// 设计令牌 - 间距与圆角。对应 `variables.scss` 的 `--spacing-*` / `--radius-*`。
|
||||
abstract final class AppSpacing {
|
||||
static const double base = 4;
|
||||
static const double xs = 8;
|
||||
static const double sm = 12;
|
||||
static const double md = 16;
|
||||
static const double lg = 24;
|
||||
static const double xl = 40;
|
||||
static const double gutter = 16;
|
||||
}
|
||||
|
||||
abstract final class AppRadius {
|
||||
static const double sm = 4;
|
||||
static const double regular = 8;
|
||||
static const double lg = 12;
|
||||
static const double xl = 24;
|
||||
static const double full = 9999;
|
||||
}
|
||||
|
||||
abstract final class AppShadows {
|
||||
/// 全局 `.card` 阴影:0 4px 14px rgba(0,0,0,0.07)
|
||||
static const card = [
|
||||
BoxShadow(color: Color(0x12000000), blurRadius: 14, offset: Offset(0, 4)),
|
||||
];
|
||||
|
||||
/// 底部导航条:0 8px 24px rgba(0,0,0,0.10)
|
||||
static const bottomNav = [
|
||||
BoxShadow(color: Color(0x1A000000), blurRadius: 24, offset: Offset(0, 8)),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'app_colors.dart';
|
||||
|
||||
abstract final class AppTheme {
|
||||
/// 全平台统一使用 iOS 风格「新页面从右侧滑入」转场,与 uni-app 版
|
||||
/// pages.json 中 `animationType: slide-in-right` 的表现对齐。
|
||||
static const _transitions = PageTransitionsTheme(
|
||||
builders: {
|
||||
TargetPlatform.android: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.windows: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.linux: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.fuchsia: CupertinoPageTransitionsBuilder(),
|
||||
},
|
||||
);
|
||||
|
||||
static ThemeData build() {
|
||||
final base = ThemeData.light(useMaterial3: true);
|
||||
|
||||
return base.copyWith(
|
||||
scaffoldBackgroundColor: AppColors.bgPage,
|
||||
pageTransitionsTheme: _transitions,
|
||||
colorScheme: base.colorScheme.copyWith(
|
||||
primary: AppColors.primary,
|
||||
onPrimary: AppColors.onPrimary,
|
||||
secondary: AppColors.secondary,
|
||||
onSecondary: AppColors.onSecondary,
|
||||
tertiary: AppColors.tertiary,
|
||||
onTertiary: AppColors.onTertiary,
|
||||
error: AppColors.error,
|
||||
onError: AppColors.onError,
|
||||
surface: AppColors.surface,
|
||||
onSurface: AppColors.onSurface,
|
||||
outline: AppColors.outline,
|
||||
outlineVariant: AppColors.outlineVariant,
|
||||
),
|
||||
textTheme: base.textTheme.apply(
|
||||
bodyColor: AppColors.onSurface,
|
||||
displayColor: AppColors.onSurface,
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: AppColors.borderRule,
|
||||
thickness: 1,
|
||||
space: 1,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: AppColors.surface,
|
||||
foregroundColor: AppColors.onSurface,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
centerTitle: true,
|
||||
systemOverlayStyle: SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarBrightness: Brightness.light,
|
||||
),
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 20,
|
||||
height: 26 / 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
splashFactory: InkSparkle.splashFactory,
|
||||
tooltipTheme: const TooltipThemeData(waitDuration: Duration(milliseconds: 400)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// 设计令牌 - 字号 / 行高 / 字重。
|
||||
/// 逐项对应 uni-app 版 `src/styles/variables.scss` 中的 `$font-*` 变量,
|
||||
/// 行高统一换算成 Flutter 的 `height`(行高 ÷ 字号)。
|
||||
abstract final class AppTypography {
|
||||
static const _sansFallback = <String>[
|
||||
'PingFang SC',
|
||||
'Microsoft YaHei',
|
||||
'Noto Sans CJK SC',
|
||||
];
|
||||
|
||||
static const _monoFallback = <String>[
|
||||
'JetBrains Mono',
|
||||
'SF Mono',
|
||||
'Menlo',
|
||||
'Consolas',
|
||||
];
|
||||
|
||||
/// 12 / 14 / w700
|
||||
static const captionSm = TextStyle(
|
||||
fontSize: 12,
|
||||
height: 14 / 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 17 / 24 / w700
|
||||
static const titleSm = TextStyle(
|
||||
fontSize: 17,
|
||||
height: 24 / 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 15 / 24 / w400
|
||||
static const bodyMd = TextStyle(
|
||||
fontSize: 15,
|
||||
height: 24 / 15,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 13 / 16 / w700
|
||||
static const labelBold = TextStyle(
|
||||
fontSize: 13,
|
||||
height: 16 / 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 12 / 16 / w500,等宽数字场景(编号、金额、时间)
|
||||
static const dataMono = TextStyle(
|
||||
fontSize: 12,
|
||||
height: 16 / 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFamilyFallback: _monoFallback,
|
||||
);
|
||||
|
||||
/// 23 / 28 / w700
|
||||
static const headlineMd = TextStyle(
|
||||
fontSize: 23,
|
||||
height: 28 / 23,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 20 / 26 / w700
|
||||
static const headlineMdMobile = TextStyle(
|
||||
fontSize: 20,
|
||||
height: 26 / 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
|
||||
/// 32 / 40 / w700
|
||||
static const displayLg = TextStyle(
|
||||
fontSize: 32,
|
||||
height: 40 / 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamilyFallback: _sansFallback,
|
||||
);
|
||||
}
|
||||
|
||||
/// 全局默认文字样式,对应 App.vue 里 `page` 上的字体设置。
|
||||
TextStyle get appDefaultTextStyle => const TextStyle(
|
||||
fontSize: 15,
|
||||
height: 1.6,
|
||||
color: AppColors.onSurface,
|
||||
fontFamilyFallback: AppTypography._sansFallback,
|
||||
);
|
||||
@@ -0,0 +1,46 @@
|
||||
/// 日期格式化工具,对应 uni-app 版 `utils/format.ts`(拜访模块多页面复用)。
|
||||
String _pad(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
/// 统一转 DateTime:兼容 `YYYY-MM-DD`、ISO 字符串与时间戳。
|
||||
DateTime? parseDate(Object? v) {
|
||||
if (v == null) return null;
|
||||
if (v is DateTime) return v;
|
||||
if (v is num) return DateTime.fromMillisecondsSinceEpoch(v.toInt());
|
||||
final s = v.toString();
|
||||
if (s.isEmpty) return null;
|
||||
return DateTime.tryParse(s) ?? DateTime.tryParse(s.replaceAll('-', '/'));
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD`
|
||||
String formatDate(Object? v) {
|
||||
final d = parseDate(v);
|
||||
if (d == null) return '-';
|
||||
return '${d.year}-${_pad(d.month)}-${_pad(d.day)}';
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:mm`
|
||||
String formatDateTime(Object? v) {
|
||||
final d = parseDate(v);
|
||||
if (d == null) return '-';
|
||||
return '${formatDate(d)} ${_pad(d.hour)}:${_pad(d.minute)}';
|
||||
}
|
||||
|
||||
/// 相对日期标签:今日 / 明日 / 昨日 / `YYYY-MM-DD`
|
||||
String formatDateLabel(Object? v) {
|
||||
final d = parseDate(v);
|
||||
if (d == null) return '-';
|
||||
final now = DateTime.now();
|
||||
final a = DateTime(d.year, d.month, d.day);
|
||||
final b = DateTime(now.year, now.month, now.day);
|
||||
final diff = a.difference(b).inDays;
|
||||
if (diff == 0) return '今日';
|
||||
if (diff == 1) return '明日';
|
||||
if (diff == -1) return '昨日';
|
||||
return formatDate(d);
|
||||
}
|
||||
|
||||
/// 今天的 `YYYY-MM-DD`
|
||||
String todayStr() => formatDate(DateTime.now());
|
||||
|
||||
/// 当前时刻的 `YYYY-MM-DD HH:mm`
|
||||
String nowDateTimeStr() => formatDateTime(DateTime.now());
|
||||
@@ -0,0 +1,17 @@
|
||||
/// 后端主键为 BIGINT,经 JSON 序列化后是字符串(如 `"1"`)。
|
||||
/// 统一归一化为 int,避免与表单/路由里的 int 型 id 做比较时失效。
|
||||
/// 对应 uni-app 版 `utils/request.ts` 的 `toNumId`。
|
||||
Object? toNumId(Object? v) {
|
||||
if (v == null || v == '') return v;
|
||||
if (v is int) return v;
|
||||
if (v is num) return v.toInt();
|
||||
final n = int.tryParse(v.toString());
|
||||
return n ?? v;
|
||||
}
|
||||
|
||||
int? toIntOrNull(Object? v) {
|
||||
final n = toNumId(v);
|
||||
return n is int ? n : null;
|
||||
}
|
||||
|
||||
int toIntOr(Object? v, int fallback) => toIntOrNull(v) ?? fallback;
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>()),
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'app/app.dart';
|
||||
import 'core/providers.dart';
|
||||
import 'core/storage/token_storage.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// 与源项目一致:浅色背景 + 深色状态栏图标。
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarBrightness: Brightness.light,
|
||||
),
|
||||
);
|
||||
|
||||
final storage = await TokenStorage.open();
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [tokenStorageProvider.overrideWithValue(storage)],
|
||||
child: const SmartGridApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_keys.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
|
||||
/// `uni.showToast({ title, icon })` 的等价实现。
|
||||
/// 不依赖调用方的 BuildContext,可在任意异步回调里安全使用。
|
||||
void showAppToast(String title, {bool success = false}) {
|
||||
final messenger = AppKeys.scaffoldMessenger.currentState;
|
||||
if (messenger == null) return;
|
||||
messenger
|
||||
..clearSnackBars()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
if (success) ...[
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
size: 18,
|
||||
color: AppColors.tertiaryFixed,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.surface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppColors.inverseSurface,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: const EdgeInsets.all(AppSpacing.md),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
duration: Duration(milliseconds: success ? 1500 : 2200),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// `uni.showModal({ title, content })` 的等价实现,返回用户是否点了确认。
|
||||
Future<bool> showAppConfirm({
|
||||
required String title,
|
||||
required String content,
|
||||
String confirmText = '确定',
|
||||
String cancelText = '取消',
|
||||
bool destructive = false,
|
||||
}) async {
|
||||
final navigator = AppKeys.rootNavigator.currentState;
|
||||
if (navigator == null) return false;
|
||||
final result = await showDialog<bool>(
|
||||
context: navigator.context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
title: Text(title, style: AppTypography.titleSm),
|
||||
content: Text(
|
||||
content,
|
||||
style: AppTypography.bodyMd.copyWith(color: AppColors.textMuted),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text(
|
||||
cancelText,
|
||||
style: AppTypography.bodyMd.copyWith(color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: Text(
|
||||
confirmText,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: destructive
|
||||
? AppColors.statusError
|
||||
: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return result ?? false;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
import 'x_icon.dart';
|
||||
|
||||
/// 底部悬浮胶囊导航,对应 `components/XBottomNav.vue`。
|
||||
///
|
||||
/// 距屏幕底部 12px(再叠加安全区),高 56,四周内边距 5,
|
||||
/// 四个 tab 等宽均分;激活项铺满所在分格。
|
||||
class XBottomNav extends StatelessWidget {
|
||||
const XBottomNav({
|
||||
super.key,
|
||||
required this.current,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final String current;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
static const items = <({String key, String label, String icon})>[
|
||||
(key: 'workbench', label: '工作台', icon: 'dashboard'),
|
||||
(key: 'customer', label: '客户', icon: 'groups'),
|
||||
(key: 'project', label: '项目', icon: 'calendar'),
|
||||
(key: 'mine', label: '我的', icon: 'person'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomInset = MediaQuery.paddingOf(context).bottom;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 12 + bottomInset,
|
||||
),
|
||||
child: Container(
|
||||
height: 56,
|
||||
padding: const EdgeInsets.all(5),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: AppColors.borderRule),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
boxShadow: AppShadows.bottomNav,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final item in items)
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (item.key == current) return;
|
||||
onChanged(item.key);
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: item.key == current
|
||||
? AppColors.accentSubtle
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
XIcon(
|
||||
item.icon,
|
||||
size: 22,
|
||||
color: item.key == current
|
||||
? AppColors.primary
|
||||
: AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
item.label,
|
||||
style: AppTypography.captionSm.copyWith(
|
||||
color: item.key == current
|
||||
? AppColors.primary
|
||||
: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
|
||||
/// 全局 `.card` 样式:白底 + 1px 描边 + 圆角 + 阴影。
|
||||
class XCard extends StatelessWidget {
|
||||
const XCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.radius,
|
||||
this.clip = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final double? radius;
|
||||
final bool clip;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final r = BorderRadius.circular(radius ?? AppRadius.lg);
|
||||
final content = Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border.all(color: AppColors.borderRule),
|
||||
borderRadius: r,
|
||||
boxShadow: AppShadows.card,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: margin ?? EdgeInsets.zero,
|
||||
child: onTap == null
|
||||
? (clip ? ClipRRect(borderRadius: r, child: content) : content)
|
||||
: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: r,
|
||||
child: clip
|
||||
? ClipRRect(borderRadius: r, child: content)
|
||||
: content,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
|
||||
/// 全局 `.chip` 样式:胶囊标签,2px 8px 内边距,12/14 粗体。
|
||||
class XChip extends StatelessWidget {
|
||||
const XChip(
|
||||
this.text, {
|
||||
super.key,
|
||||
this.tone = ChipTone.muted,
|
||||
this.leading,
|
||||
this.foreground,
|
||||
this.background,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final ChipTone tone;
|
||||
|
||||
/// 前置装饰(如状态圆点)
|
||||
final Widget? leading;
|
||||
final Color? foreground;
|
||||
final Color? background;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: background ?? tone.background,
|
||||
borderRadius: BorderRadius.circular(9999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 4)],
|
||||
Text(
|
||||
text,
|
||||
style: AppTypography.captionSm.copyWith(
|
||||
color: foreground ?? tone.foreground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 可点选的筛选 chip(客户/项目列表顶部的过滤器)。
|
||||
class XFilterChip extends StatelessWidget {
|
||||
const XFilterChip({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.background = AppColors.surface,
|
||||
this.selectedBackground = AppColors.primaryContainer,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
final Color background;
|
||||
final Color selectedBackground;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? selectedBackground : background,
|
||||
border: Border.all(
|
||||
color: selected ? selectedBackground : AppColors.borderRule,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(9999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: AppTypography.captionSm.copyWith(
|
||||
color: selected ? const Color(0xFFFEFCFF) : AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_dimens.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
import 'x_icon.dart';
|
||||
|
||||
/// 页面骨架:固定顶栏 + 可滚动内容区。
|
||||
///
|
||||
/// 对应 uni-app 版「根容器铺满且不滚动,滚动只发生在内容区 scroll-view」的固定高度布局,
|
||||
/// 保证顶栏永远不动 —— 接近原生 App 的体验。
|
||||
class XPageShell extends StatelessWidget {
|
||||
const XPageShell({
|
||||
super.key,
|
||||
required this.topBar,
|
||||
required this.child,
|
||||
this.backgroundColor = AppColors.bgPage,
|
||||
});
|
||||
|
||||
final Widget topBar;
|
||||
final Widget child;
|
||||
final Color backgroundColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: backgroundColor,
|
||||
body: Column(
|
||||
children: [topBar, Expanded(child: child)],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 搜索框,对应各页面 `.search-bar`。
|
||||
class XSearchField extends StatelessWidget {
|
||||
const XSearchField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.hintText,
|
||||
this.onChanged,
|
||||
this.iconSize = 20,
|
||||
this.height = 44,
|
||||
this.radius = 12,
|
||||
this.background = AppColors.surface,
|
||||
this.iconColor = AppColors.textMuted,
|
||||
this.filled = false,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String hintText;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final double iconSize;
|
||||
final double height;
|
||||
final double radius;
|
||||
final Color background;
|
||||
final Color iconColor;
|
||||
final bool filled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: filled ? background : background,
|
||||
border: filled ? null : Border.all(color: AppColors.borderRule),
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
XIcon('search', size: iconSize, color: iconColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
onChanged: onChanged,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 空态 / 加载中占位,对应 `.center` / `.empty-block`。
|
||||
class XPlaceholder extends StatelessWidget {
|
||||
const XPlaceholder({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.dashed = false,
|
||||
this.padding = const EdgeInsets.symmetric(vertical: 60),
|
||||
});
|
||||
|
||||
final String text;
|
||||
final bool dashed;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.bodyMd.copyWith(color: AppColors.textMuted),
|
||||
);
|
||||
|
||||
if (!dashed) {
|
||||
return Padding(padding: padding, child: Center(child: label));
|
||||
}
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceBright,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.borderRule),
|
||||
),
|
||||
child: label,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 实心主色按钮,对应 `.save-btn` / `.login-btn` 等胶囊按钮。
|
||||
class XPrimaryButton extends StatelessWidget {
|
||||
const XPrimaryButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.loading = false,
|
||||
this.height = 48,
|
||||
this.margin,
|
||||
this.background = AppColors.primary,
|
||||
this.foreground = AppColors.onPrimary,
|
||||
this.fontSize = 16,
|
||||
this.pill = true,
|
||||
this.borderColor,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final bool loading;
|
||||
final double height;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final Color background;
|
||||
final Color foreground;
|
||||
final double fontSize;
|
||||
final bool pill;
|
||||
final Color? borderColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final disabled = onPressed == null || loading;
|
||||
|
||||
return Padding(
|
||||
padding: margin ?? EdgeInsets.zero,
|
||||
child: Opacity(
|
||||
opacity: disabled && loading ? 0.85 : 1,
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
width: double.infinity,
|
||||
child: Material(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(pill ? 9999 : AppRadius.lg),
|
||||
child: InkWell(
|
||||
onTap: disabled ? null : onPressed,
|
||||
borderRadius: BorderRadius.circular(pill ? 9999 : AppRadius.lg),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
pill ? 9999 : AppRadius.lg,
|
||||
),
|
||||
border: borderColor == null
|
||||
? null
|
||||
: Border.all(color: borderColor!),
|
||||
),
|
||||
child: loading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(foreground),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: foreground,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 分组小标题,对应 `.section-title`。
|
||||
class XSectionTitle extends StatelessWidget {
|
||||
const XSectionTitle(
|
||||
this.text, {
|
||||
super.key,
|
||||
this.icon,
|
||||
this.iconColor = AppColors.primary,
|
||||
this.fontSize = 17,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final String? icon;
|
||||
final Color iconColor;
|
||||
final double fontSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (icon == null) {
|
||||
return Text(
|
||||
text,
|
||||
style: AppTypography.titleSm.copyWith(fontSize: fontSize),
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
XIcon(icon!, size: fontSize, color: iconColor),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
text,
|
||||
style: AppTypography.titleSm.copyWith(fontSize: fontSize),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
|
||||
/// 图标组件,逐像素对应 uni-app 版 `components/XIcon.vue` 的 SVG 路径表。
|
||||
///
|
||||
/// 源实现把 SVG 拼成 data URI 交给背景图渲染(为绕过 uni-app 端 svg 命名空间问题),
|
||||
/// Flutter 端直接用 [SvgPicture.string] 渲染同一份路径数据,视觉完全一致。
|
||||
class XIcon extends StatelessWidget {
|
||||
const XIcon(
|
||||
this.name, {
|
||||
super.key,
|
||||
this.size = 20,
|
||||
this.color = AppColors.textMuted,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final double size;
|
||||
final Color color;
|
||||
|
||||
/// 形状与 XIcon.vue 的 `ICONS` 一一对应,未做任何视觉改动。
|
||||
/// `currentColor` 在生成 SVG 时替换为 [color] 的实际色值。
|
||||
static const Map<String, String> paths = {
|
||||
'dashboard':
|
||||
'<rect x="3" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="3" width="7.5" height="7.5" rx="1.5"/><rect x="3" y="13.5" width="7.5" height="7.5" rx="1.5"/><rect x="13.5" y="13.5" width="7.5" height="7.5" rx="1.5"/>',
|
||||
'groups':
|
||||
'<circle cx="9" cy="8.5" r="3.5"/><circle cx="17" cy="9" r="2.8"/><path d="M9 13.5c-3 0-5.5 2-5.5 4.5v1h11V18c0-2.5-2.5-4.5-5.5-4.5z"/><path d="M16.7 13.6c2.4.6 3.8 2.3 3.8 4.4v1h-3.2"/>',
|
||||
'calendar':
|
||||
'<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 9.5h18M8 3v4M16 3v4"/>',
|
||||
'person': '<circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.5-6.5 8-6.5s8 2.5 8 6.5v1H4z"/>',
|
||||
'bolt': '<path d="M13 2L4.5 13.5H11l-2 8.5L19 10h-6.5z"/>',
|
||||
'search':
|
||||
'<circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="2"/><path d="M15.5 15.5L21 21" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
|
||||
'add': '<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/>',
|
||||
'location':
|
||||
'<path d="M12 2C8.1 2 5 5.1 5 9c0 5.2 7 13 7 13s7-7.8 7-13c0-3.9-3.1-7-7-7z"/><circle cx="12" cy="9" r="2.5"/>',
|
||||
'notification':
|
||||
'<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zM18 16v-5.5c0-3.1-1.6-5.6-4.5-6.3V3.5a1.5 1.5 0 0 0-3 0v.7C7.6 4.9 6 7.4 6 10.5V16l-2 2v1h16v-1l-2-2z"/>',
|
||||
'back':
|
||||
'<path d="M15 5l-7 7 7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
||||
'forward':
|
||||
'<path d="M9 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
||||
'more': '<circle cx="6" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="18" cy="12" r="1.6"/>',
|
||||
'domain':
|
||||
'<rect x="4" y="4" width="16" height="16" rx="1.5"/><path d="M9 4v6h6V4M4 10h16M4 15h5M15 15h5M9 20v-5"/>',
|
||||
'clock':
|
||||
'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/>',
|
||||
'warning': '<path d="M12 3L2 20h20zM12 9v5M12 17.5v.1"/>',
|
||||
'camera':
|
||||
'<rect x="3" y="6" width="18" height="14" rx="2"/><path d="M8 6l1.5-2h5L16 6"/><circle cx="12" cy="13" r="3.5"/>',
|
||||
'photo':
|
||||
'<rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.6"/><path d="M3 17l5-5 4 4 3-3 6 6"/>',
|
||||
'send': '<path d="M3 11l18-7-7 18-3-8z"/>',
|
||||
'check':
|
||||
'<path d="M4 12.5l5 5L20 6.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
||||
'construction':
|
||||
'<path d="M14 3l7 7-3 3-2-2-8.5 8.5a2 2 0 0 1-2.8-2.8L13 8.5 11 6.5 13 4z"/>',
|
||||
'verified':
|
||||
'<path d="M12 2l2.5 2.5 3.5-.5 1 3.4 3 2-1 3.6 1 3.6-3 2-1 3.4-3.5-.5L12 22l-3.5-1-3.5.5-.5-3-3-2 1-4-.5-3H3l3-2 1-3.4 3.5.5z"/><path d="M4 12l5 5L20 6.5" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
||||
'edit': '<path d="M16.5 3a2 2 0 0 1 2.8 0l1.7 1.7a2 2 0 0 1 0 2.8L8 20.5 3 22l1.5-5z"/>',
|
||||
'delete': '<path d="M4 7h16M9 7V4h6v3M6 7l1 14h10l1-14M10 11v6M14 11v6"/>',
|
||||
'call':
|
||||
'<path d="M6.6 3c.6 0 1.2.4 1.4 1l1 3c.2.5.1 1.1-.3 1.5L7 9.6c1 1.9 2.5 3.4 4.4 4.4l1.1-1.7c.4-.4 1-.5 1.5-.3l3 1c.6.2 1 .8 1 1.4V18c0 1-1 2-2 2C8 20 4 16 4 5c0-1 1-2 2-2z"/>',
|
||||
'schedule': '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',
|
||||
'filter': '<path d="M4 5h16l-7 8v5l-2 1v-6z"/>',
|
||||
'chart': '<path d="M4 20V4M4 20h16"/><path d="M8 16v-5M13 16V8M18 16v-2"/>',
|
||||
'check-circle':
|
||||
'<circle cx="12" cy="12" r="9"/><path d="M8 12l3 3 5-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
||||
'business':
|
||||
'<rect x="3" y="7" width="18" height="13" rx="1.5"/><path d="M7 7V5a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v2M3 12h18M7 12v5M12 12v5M17 12v5"/>',
|
||||
'category': '<path d="M5 3h6v6H5zM13 3h6v10h-6zM5 11h6v10H5zM13 15h6v6h-6z"/>',
|
||||
'person-add':
|
||||
'<circle cx="9" cy="8" r="3.5"/><path d="M3.5 19.5c0-3 2.5-4.8 5.5-4.8s5.5 1.8 5.5 4.8v.5h-11z"/><path d="M18 8v6M15 11h6" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/>',
|
||||
'trend-up':
|
||||
'<path d="M4 17l5-5 3 3 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/><path d="M13 9h5v5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>',
|
||||
'close':
|
||||
'<path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" fill="none"/>',
|
||||
};
|
||||
|
||||
static final Map<String, String> _svgCache = {};
|
||||
|
||||
static String _hex(Color color) =>
|
||||
'#${(color.toARGB32() & 0xFFFFFF).toRadixString(16).padLeft(6, '0')}';
|
||||
|
||||
/// 生成 (图标, 颜色) 组合对应的 SVG 字符串,带缓存避免重复拼接。
|
||||
static String svgFor(String name, Color color) {
|
||||
final key = '$name|${color.toARGB32()}';
|
||||
return _svgCache.putIfAbsent(key, () {
|
||||
// 兼容旧调用:borg 与 bolt 是同一个闪电图标
|
||||
final body = (paths[name] ?? paths['close']!).replaceAll(
|
||||
'currentColor',
|
||||
_hex(color),
|
||||
);
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
|
||||
'fill="${_hex(color)}">$body</svg>';
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: SvgPicture.string(
|
||||
svgFor(name, color),
|
||||
width: size,
|
||||
height: size,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
import 'x_icon.dart';
|
||||
|
||||
/// 固定顶栏。对应各页面 `.topbar`:高 56 + 状态栏内边距,白底 + 1px 下边框。
|
||||
///
|
||||
/// 两种形态:
|
||||
/// - [XTopBar.brand] 一级 Tab 顶栏:左侧闪电图标 + 品牌/模块名(左对齐),右侧通知图标
|
||||
/// - [XTopBar.nav] 二级页顶栏:左侧返回,中间标题,右侧操作位
|
||||
class XTopBar extends StatelessWidget {
|
||||
const XTopBar._({
|
||||
required this.title,
|
||||
required this.leading,
|
||||
required this.trailing,
|
||||
required this.titleStyle,
|
||||
required this.titleAlign,
|
||||
});
|
||||
|
||||
/// 一级 Tab 顶栏。
|
||||
factory XTopBar.brand({
|
||||
Key? key,
|
||||
required String title,
|
||||
Color iconColor = AppColors.primary,
|
||||
String trailingIcon = 'notification',
|
||||
Color trailingColor = AppColors.onSurfaceVariant,
|
||||
VoidCallback? onTrailingTap,
|
||||
}) {
|
||||
return XTopBar._(
|
||||
title: title,
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
XIcon('bolt', size: 24, color: iconColor),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
title,
|
||||
style: AppTypography.headlineMdMobile.copyWith(
|
||||
color: iconColor,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: GestureDetector(
|
||||
onTap: onTrailingTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: XIcon(trailingIcon, size: 22, color: trailingColor),
|
||||
),
|
||||
titleStyle: AppTypography.headlineMdMobile,
|
||||
titleAlign: TextAlign.left,
|
||||
);
|
||||
}
|
||||
|
||||
/// 二级页顶栏:返回 + 居中标题 + 右侧操作位。
|
||||
factory XTopBar.nav({
|
||||
Key? key,
|
||||
required String title,
|
||||
VoidCallback? onBack,
|
||||
Widget? trailing,
|
||||
}) {
|
||||
return XTopBar._(
|
||||
title: title,
|
||||
leading: GestureDetector(
|
||||
onTap: onBack,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const XIcon('back', size: 22, color: AppColors.primary),
|
||||
Text(
|
||||
'返回',
|
||||
style: AppTypography.labelBold.copyWith(color: AppColors.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
trailing: trailing ?? const SizedBox.shrink(),
|
||||
titleStyle: AppTypography.headlineMdMobile,
|
||||
titleAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
|
||||
final String title;
|
||||
final Widget leading;
|
||||
final Widget trailing;
|
||||
final TextStyle titleStyle;
|
||||
final TextAlign titleAlign;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final topInset = MediaQuery.paddingOf(context).top;
|
||||
final centered = titleAlign == TextAlign.center;
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.only(top: topInset),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border(bottom: BorderSide(color: AppColors.borderRule)),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: centered
|
||||
? Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: leading,
|
||||
),
|
||||
),
|
||||
Text(title, style: titleStyle),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: trailing,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
leading,
|
||||
const Spacer(),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: trailing,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user