// // HomeView.swift // suixinkan // // Created by Codex on 2026/6/22. // import Combine import SwiftUI /// 首页主视图,展示景区、工作状态、快捷操作和权限菜单入口。 struct HomeView: View { @EnvironmentObject private var accountContext: AccountContext @EnvironmentObject private var permissionContext: PermissionContext @EnvironmentObject private var appRouter: AppRouter @EnvironmentObject private var router: RouterPath @Environment(\.horizontalSizeClass) private var horizontalSizeClass @StateObject private var viewModel = HomeViewModel() @State private var commonUris: [String] = [] @State private var isOnline = false @State private var reminderMinutes = 0 @State private var secondsUntilReport = 0 @State private var showOnlineDialog = false @State private var showReminderDialog = false @State private var showReportSuccess = false private let commonMenuStore = HomeCommonMenuStore() private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() private var contentMaxWidth: CGFloat { horizontalSizeClass == .regular ? 760 : .infinity } private var isStoreManager: Bool { permissionContext.currentAppRole?.isStoreAdmin == true } private var shouldShowWorkStatus: Bool { guard let role = permissionContext.currentAppRole else { return true } return !AppRoleCode.homeMinimalTopRoles.contains(role) } private var currentScenicName: String { accountContext.currentScenic?.name ?? "请选择景区" } private var countdownDisplay: String { let hours = secondsUntilReport / 3_600 let minutes = (secondsUntilReport % 3_600) / 60 let seconds = secondsUntilReport % 60 return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))" } private var reminderText: String { reminderMinutes == 0 ? "不提醒" : "提前\(reminderMinutes)分钟" } private var tileHeight: CGFloat { horizontalSizeClass == .regular ? 118 : 102 } private var displayMenuItems: [HomeMenuItem] { let items = commonUris.compactMap(menuItemForHomeEntry(uri:)) return items + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)] } var body: some View { VStack(spacing: 0) { topBar ScrollView { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { if shouldShowWorkStatus { statusCard locationReportCard quickActionsRow } if isStoreManager, let store = accountContext.currentStore { storeCard(store) .padding(.bottom, AppMetrics.Spacing.xSmall) } Text("常用应用") .font(.system(size: AppMetrics.FontSize.body, weight: .bold)) .foregroundStyle(Color(hex: 0x333333)) .padding(.top, shouldShowWorkStatus ? AppMetrics.Spacing.xxSmall : AppMetrics.Spacing.xSmall) .padding(.bottom, AppMetrics.Spacing.xxSmall) appGrid } .frame(maxWidth: contentMaxWidth, alignment: .topLeading) .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .padding(.top, AppMetrics.Spacing.xxLarge) .padding(.bottom, AppMetrics.Spacing.pageVertical) .frame(maxWidth: .infinity) } .background(Color(hex: 0xF5F5F5)) } .background(Color(hex: 0xF5F5F5).ignoresSafeArea()) .toolbar(.hidden, for: .navigationBar) .task { rebuildMenusFromCurrentContext() } .onReceive(timer) { _ in guard isOnline, secondsUntilReport > 0 else { return } secondsUntilReport -= 1 } .onChange(of: permissionContext.currentRole?.roleCode) { _ in rebuildMenusFromCurrentContext() } .onChange(of: permissionContext.rolePermissions.count) { _ in rebuildMenusFromCurrentContext() } .alert("切换在线状态", isPresented: $showOnlineDialog) { Button("取消", role: .cancel) {} Button("确定") { isOnline.toggle() if isOnline { secondsUntilReport = 7_200 } } } message: { Text(isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。") } .confirmationDialog("提前提醒时间", isPresented: $showReminderDialog, titleVisibility: .visible) { ForEach([0, 5, 10, 15, 30], id: \.self) { minute in Button(minute == 0 ? "不提醒" : "\(minute)分钟") { reminderMinutes = minute } } Button("取消", role: .cancel) {} } .alert("上报成功", isPresented: $showReportSuccess) { Button("知道了", role: .cancel) {} } message: { Text("上报已成功,距离下次上报时间 \(countdownDisplay)。") } } private var topBar: some View { HStack { Button { router.navigate(to: .home(.scenicSelection)) } label: { HStack(spacing: AppMetrics.Spacing.xSmall) { Text(currentScenicName) .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) .foregroundStyle(Color(hex: 0x333333)) .lineLimit(1) .minimumScaleFactor(0.8) Image(systemName: "chevron.down") .font(.system(size: AppMetrics.FontSize.subheadline, weight: .bold)) .foregroundStyle(.black) } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) } .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .frame(height: 78) .background(.white) } private var statusCard: some View { HStack(spacing: AppMetrics.Spacing.small) { Button { showOnlineDialog = true } label: { Text(isOnline ? "在线" : "离线") .font(.system(size: AppMetrics.FontSize.caption, weight: .medium)) .foregroundStyle(isOnline ? Color(hex: 0xF0FDF4) : Color(hex: 0x7B8EAA)) .padding(.horizontal, AppMetrics.Spacing.small) .padding(.vertical, AppMetrics.Spacing.xxSmall + 2) .background(isOnline ? Color(hex: 0x22C55E) : Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 4)) } .buttonStyle(.plain) Spacer(minLength: AppMetrics.Spacing.xxSmall) Label { Text(countdownDisplay) .font(.system(size: AppMetrics.FontSize.body, weight: .medium)) } icon: { Image(systemName: "clock") .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) } .foregroundStyle(AppDesign.primary) .lineLimit(1) .minimumScaleFactor(0.85) Spacer(minLength: AppMetrics.Spacing.xxSmall) Button { showReminderDialog = true } label: { Label { Text(reminderText) .font(.system(size: AppMetrics.FontSize.body, weight: .medium)) } icon: { Image(systemName: "bell.fill") .font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium)) } .foregroundStyle(AppDesign.primary) .lineLimit(1) .minimumScaleFactor(0.8) } .buttonStyle(.plain) } .padding(15) .frame(minHeight: 84) .frame(maxWidth: .infinity) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } private var locationReportCard: some View { HStack(spacing: AppMetrics.Spacing.medium) { VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) { HStack(spacing: AppMetrics.Spacing.xSmall) { Image(systemName: "arrow.up.square.fill") .font(.system(size: 25, weight: .bold)) .foregroundStyle(AppDesign.primary) Text("立即上报") .font(.system(size: 22, weight: .bold)) .foregroundStyle(Color(hex: 0x333333)) } Text("您已进入打卡范围") .font(.system(size: 15)) .foregroundStyle(Color(hex: 0x999999)) } Spacer() Button { openRoute(HomeMenuRouter.resolve(uri: "location_report", title: "位置上报")) } label: { Image(systemName: "hand.tap.fill") .font(.system(size: 38, weight: .semibold)) .foregroundStyle(.white) .frame(width: 92, height: 92) .background( LinearGradient( colors: [Color(hex: 0x0073FF), Color(hex: 0x5CA8FF)], startPoint: .top, endPoint: .bottom ), in: Circle() ) } .buttonStyle(.plain) .accessibilityLabel("上报位置") } .padding(15) .frame(minHeight: 132) .frame(maxWidth: .infinity) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } private var quickActionsRow: some View { HStack(spacing: AppMetrics.Spacing.small) { quickAction(icon: "qrcode", title: "立即收款") { openRoute(HomeMenuRouter.resolve(uri: "payment_collection", title: "立即收款")) } quickAction(icon: "checklist.checked", title: "提交任务") { openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务")) } quickAction(icon: isOnline ? "wifi" : "wifi.slash", title: isOnline ? "在线" : "离线", active: isOnline) { showOnlineDialog = true } } } private func quickAction(icon: String, title: String, active: Bool = false, action: @escaping () -> Void) -> some View { Button(action: action) { VStack(spacing: AppMetrics.Spacing.xSmall) { Image(systemName: icon) .font(.system(size: 30, weight: .semibold)) .foregroundStyle(AppDesign.primary) .frame(height: 34) Text(title) .font(.system(size: AppMetrics.FontSize.body)) .foregroundStyle(Color.black) .lineLimit(1) .minimumScaleFactor(0.8) } .frame(maxWidth: .infinity) .frame(height: tileHeight) .background(active ? Color(hex: 0xE3F2FD) : .white, in: RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) } private func storeCard(_ store: BusinessScope) -> some View { HStack(alignment: .top, spacing: 14) { VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { Text(store.name) .font(.system(size: AppMetrics.FontSize.title2, weight: .bold)) .foregroundStyle(.black) .lineLimit(1) if let scenic = accountContext.currentScenic { Text(scenic.name) .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(Color(hex: 0x7B8EAA)) .lineLimit(2) } } Spacer() Text("营业中") .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(Color(hex: 0x22C55E)) .padding(.horizontal, AppMetrics.Spacing.xSmall + 2) .padding(.vertical, AppMetrics.Spacing.xxxSmall) .background(Color(hex: 0xF0FDF4), in: RoundedRectangle(cornerRadius: 4)) } .padding(AppMetrics.Spacing.medium) .frame(maxWidth: .infinity) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } private var appGrid: some View { LazyVGrid(columns: menuColumns, spacing: 15) { ForEach(displayMenuItems) { item in Button { openMenu(item) } label: { VStack(spacing: AppMetrics.Spacing.small) { menuIconView(for: item) .frame(width: 30, height: 30) Text(item.title) .font(.system(size: AppMetrics.FontSize.body)) .foregroundStyle(Color(hex: 0x4B5563)) .lineLimit(1) .minimumScaleFactor(0.75) } .frame(maxWidth: .infinity) .frame(height: tileHeight) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) } } } private var menuColumns: [GridItem] { Array(repeating: GridItem(.flexible(), spacing: 15), count: 3) } @ViewBuilder private func menuIconView(for item: HomeMenuItem) -> some View { RemoteImage(urlString: item.iconSrc, contentMode: .fit) { fallbackMenuIcon(for: item.uri) } } private func fallbackMenuIcon(for uri: String) -> some View { Image(systemName: iconName(for: uri)) .font(.system(size: 24, weight: .semibold)) .foregroundStyle(AppDesign.primary) .frame(width: 30, height: 30) } private func iconName(for uri: String) -> String { HomeIconCatalog.iconName(for: uri) } private func menuItemForHomeEntry(uri: String) -> HomeMenuItem? { let availableURIs = Set(viewModel.menuItems.map(\.uri)) let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) guard let existing = viewModel.menuItems.first(where: { $0.uri == resolvedUri }) else { return nil } return HomeMenuItem( title: HomeMenuRouter.displayTitle(for: resolvedUri, fallback: existing.title), uri: resolvedUri, iconSrc: existing.iconSrc ) } private func openMenu(_ item: HomeMenuItem) { openRoute(HomeMenuRouter.resolve(uri: item.uri, title: item.title)) } private func openRoute(_ route: HomeMenuResolvedRoute) { switch route { case .tab(let tab): appRouter.select(tab) case .orders(let entry): appRouter.selectOrders(entry: entry) case .orderPush(let route): appRouter.select(.orders) appRouter.router(for: .orders).navigate(to: .orders(route)) case .destination(let route): router.navigate(to: .home(route)) case .unsupported(let uri, let title, _): router.navigate(to: .home(.modulePlaceholder(uri: uri, title: title))) case .placeholder(let uri, let title): HomeRouteDiagnostics.recordUnknown(uri: uri, title: title) router.navigate(to: .home(.modulePlaceholder(uri: uri, title: title))) } } private func rebuildMenusFromCurrentContext() { let roleCode = permissionContext.currentAppRole?.rawValue let legacyRoleId = permissionContext.currentRole?.id viewModel.buildMenus( from: permissionContext.rolePermissions, currentRoleCode: roleCode ) commonUris = commonMenuStore.load( menuItems: viewModel.menuItems, roleCode: roleCode, accountScope: accountContext.accountCachePrefix, legacyRoleId: legacyRoleId, topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode) ) } } #Preview { NavigationStack { HomeView() .environmentObject(AccountContext()) .environmentObject(PermissionContext()) .environmentObject(AppRouter()) .environmentObject(RouterPath()) } }