Initial commit
This commit is contained in:
443
suixinkan/Features/Home/Views/HomeView.swift
Normal file
443
suixinkan/Features/Home/Views/HomeView.swift
Normal file
@ -0,0 +1,443 @@
|
||||
//
|
||||
// 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<Int> = [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 .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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user