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

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

No files matched your search

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