// // HomeView.swift // suixinkan // // Created by Codex on 2026/6/22. // import Combine import SwiftUI /// 首页主视图,展示景区、工作状态、快捷操作和权限菜单入口。 struct HomeView: View { @Environment(AccountContext.self) private var accountContext @Environment(PermissionContext.self) private var permissionContext @Environment(AppRouter.self) private var appRouter @Environment(RouterPath.self) private var router @Environment(\.horizontalSizeClass) private var horizontalSizeClass @State 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 let minimalTopRoleIds: Set = [46, 47, 52, 53, 54] private var contentMaxWidth: CGFloat { horizontalSizeClass == .regular ? 760 : .infinity } private var currentRoleId: Int? { permissionContext.currentRole?.id } private var isStoreManager: Bool { currentRoleId == 46 } private var shouldShowWorkStatus: Bool { guard let currentRoleId else { return true } return !minimalTopRoleIds.contains(currentRoleId) } 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 selected = commonUris.compactMap(menuItemForHomeEntry(uri:)) let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected return fallback + [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?.id) { _, _ 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 { secondsUntilReport = 7_200 showReportSuccess = true } 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 { if let src = item.iconSrc, let url = URL(string: src), !src.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { AsyncImage(url: url) { phase in switch phase { case .success(let image): image .resizable() .scaledToFit() default: fallbackMenuIcon(for: item.uri) } } } else { 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 .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() { viewModel.buildMenus( from: permissionContext.rolePermissions, currentRoleId: permissionContext.currentRole?.id ) commonUris = commonMenuStore.load(menuItems: viewModel.menuItems) } } #Preview { NavigationStack { HomeView() .environment(AccountContext()) .environment(PermissionContext()) .environment(AppRouter()) .environment(RouterPath()) } }