// // LiveManagementViews.swift // suixinkan // // Created by Codex on 2026/6/25. // import SwiftUI /// 直播管理首页,展示直播列表和控制入口。 struct LiveManagementView: View { @Environment(AccountContext.self) private var accountContext @Environment(LiveAPI.self) private var liveAPI @Environment(ToastCenter.self) private var toastCenter @Environment(\.globalLoading) private var globalLoading @State private var viewModel = LiveManagementViewModel() @State private var showAddPage = false var body: some View { VStack(spacing: 0) { summarySection ScrollView { LazyVStack(spacing: AppMetrics.Spacing.medium) { if viewModel.items.isEmpty { ContentUnavailableView("暂无直播", systemImage: "dot.radiowaves.left.and.right") .frame(maxWidth: .infinity, minHeight: 280) } else { ForEach(viewModel.items) { item in LiveManagementCard( item: item, onCopyURL: { UIPasteboard.general.string = item.pushUrl toastCenter.show("推流地址已复制") }, onControl: { Task { await control(item) } }, onFinish: { Task { await finish(item) } }, detail: { LiveDetailView(initialDetail: item) { Task { await reload(showLoading: false) } } } ) .onAppear { guard item.id == viewModel.items.last?.id else { return } Task { await viewModel.loadMore(api: liveAPI, scenicId: accountContext.currentScenic?.id) } } } } if viewModel.loadingMore { ProgressView() .padding(.vertical, AppMetrics.Spacing.small) } Spacer(minLength: 80) } .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .padding(.vertical, AppMetrics.Spacing.medium) } Button { showAddPage = true } label: { Label("添加直播", systemImage: "plus.circle.fill") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .frame(maxWidth: .infinity) .frame(height: AppMetrics.ControlSize.primaryButtonHeight) } .buttonStyle(.borderedProminent) .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .padding(.vertical, AppMetrics.Spacing.medium) .background(.white) } .background(Color(hex: 0xF5F7FA)) .navigationTitle("直播管理") .navigationBarTitleDisplayMode(.inline) .refreshable { await reload(showLoading: false) } .task(id: accountContext.currentScenic?.id) { await reload(showLoading: viewModel.items.isEmpty) } .navigationDestination(isPresented: $showAddPage) { LiveAddView { showAddPage = false Task { await reload(showLoading: false) } } } } private var summarySection: some View { HStack(spacing: AppMetrics.Spacing.small) { LiveSummaryChip(title: "总直播", value: "\(viewModel.items.count)", color: AppDesign.primary) LiveSummaryChip(title: "进行中", value: "\(viewModel.liveRunningCount)", color: AppDesign.success) LiveSummaryChip(title: "已结束", value: "\(viewModel.liveFinishedCount)", color: Color(hex: 0x64748B)) } .padding(AppMetrics.Spacing.medium) .background(.white) } private func reload(showLoading: Bool) async { await globalLoading.withOptionalLoading(showLoading && accountContext.currentScenic?.id != nil) { await viewModel.reload(api: liveAPI, scenicId: accountContext.currentScenic?.id, showLoading: false) } if let message = viewModel.errorMessage { toastCenter.show(message) } } private func control(_ item: LiveEntity) async { do { try await viewModel.control(api: liveAPI, item: item, scenicId: accountContext.currentScenic?.id) } catch { toastCenter.show(error.localizedDescription) } } private func finish(_ item: LiveEntity) async { do { try await viewModel.finish(api: liveAPI, item: item, scenicId: accountContext.currentScenic?.id) } catch { toastCenter.show(error.localizedDescription) } } } private struct LiveSummaryChip: View { let title: String let value: String let color: Color var body: some View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) Text(value) .font(.system(size: AppMetrics.FontSize.body, weight: .bold)) .foregroundStyle(color) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, AppMetrics.Spacing.small) .padding(.vertical, AppMetrics.Spacing.small) .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) } } private struct LiveManagementCard: View { let item: LiveEntity let onCopyURL: () -> Void let onControl: () -> Void let onFinish: () -> Void @ViewBuilder let detail: () -> Detail var body: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { HStack(spacing: AppMetrics.Spacing.medium) { RemoteImage(urlString: item.coverImg, contentMode: .fill) { RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input) .fill(Color(hex: 0xE8EEF8)) .overlay { Image(systemName: "play.rectangle.fill") .font(.system(size: 28, weight: .semibold)) .foregroundStyle(AppDesign.primary.opacity(0.75)) } } .frame(width: 118, height: 72) .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) VStack(alignment: .leading, spacing: 5) { Text(item.title.liveNonEmpty ?? "暂无标题") .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .foregroundStyle(AppDesign.textPrimary) .lineLimit(1) Text("观看:\(item.viewsCount)") .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) Text("时长:\(item.duration.liveDurationText)") .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.textSecondary) Text(item.displayStatus) .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) .foregroundStyle(statusColor) } Spacer(minLength: 0) } HStack(spacing: AppMetrics.Spacing.small) { NavigationLink(destination: detail) { LiveSmallAction(title: "详情", color: AppDesign.primary) } .buttonStyle(.plain) Button(action: onControl) { LiveSmallAction(title: item.status == 2 ? "暂停" : "开始", color: Color(hex: 0xFF7B00)) } .disabled(item.status == 3) Button(action: onFinish) { LiveSmallAction(title: "结束", color: Color(hex: 0xEF4444)) } .disabled(item.status == 3) Button(action: onCopyURL) { LiveSmallAction(title: "复制地址", color: AppDesign.success) } .disabled(item.pushUrl.liveTrimmed.isEmpty) } } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } private var statusColor: Color { switch item.status { case 2: AppDesign.success case 3: Color(hex: 0x64748B) default: AppDesign.primary } } } private struct LiveSmallAction: View { let title: String let color: Color var body: some View { Text(title) .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) .foregroundStyle(color) .frame(maxWidth: .infinity) .frame(height: 32) .background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) } } /// 添加直播页面。 struct LiveAddView: View { @Environment(AccountContext.self) private var accountContext @Environment(LiveAPI.self) private var liveAPI @Environment(ToastCenter.self) private var toastCenter @Environment(\.dismiss) private var dismiss @State private var title = "" @State private var coverURL = "" @State private var submitting = false let onCreated: () -> Void var body: some View { VStack(spacing: 0) { ScrollView { VStack(spacing: AppMetrics.Spacing.medium) { previewSection formSection } .padding(AppMetrics.Spacing.pageHorizontal) } Button { Task { await submit() } } label: { HStack { if submitting { ProgressView().tint(.white) } Text(submitting ? "创建中..." : "确认添加") } .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) .frame(maxWidth: .infinity) .frame(height: AppMetrics.ControlSize.primaryButtonHeight) } .buttonStyle(.borderedProminent) .disabled(!canSubmit) .padding(AppMetrics.Spacing.pageHorizontal) .background(.white) } .background(Color(hex: 0xF5F7FA)) .navigationTitle("添加直播") .navigationBarTitleDisplayMode(.inline) } private var canSubmit: Bool { !submitting && !title.liveTrimmed.isEmpty && coverURL.liveTrimmed.liveIsHTTPURL } private var previewSection: some View { ZStack(alignment: .bottomLeading) { if coverURL.liveTrimmed.liveIsHTTPURL { RemoteImage(urlString: coverURL.liveTrimmed, contentMode: .fill) { previewPlaceholder } } else { previewPlaceholder } VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { Text("直播封面") .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) .foregroundStyle(.white.opacity(0.9)) Text(title.liveTrimmed.isEmpty ? "输入标题后预览展示效果" : title.liveTrimmed) .font(.system(size: AppMetrics.FontSize.title2, weight: .bold)) .foregroundStyle(.white) .lineLimit(2) } .padding(AppMetrics.Spacing.medium) .frame(maxWidth: .infinity, alignment: .leading) .background(LinearGradient(colors: [.clear, .black.opacity(0.72)], startPoint: .top, endPoint: .bottom)) } .frame(height: 210) .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } private var previewPlaceholder: some View { LinearGradient(colors: [Color(hex: 0x0F274A), AppDesign.primary], startPoint: .topLeading, endPoint: .bottomTrailing) .overlay { Image(systemName: "play.rectangle.fill") .font(.system(size: 46, weight: .semibold)) .foregroundStyle(.white.opacity(0.72)) } } private var formSection: some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { Text("直播信息") .font(.system(size: AppMetrics.FontSize.title3, weight: .bold)) TextField("请输入直播标题", text: $title) .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) TextField("请输入 http/https 图片地址", text: $coverURL) .textInputAutocapitalization(.never) .autocorrectionDisabled(true) .keyboardType(.URL) .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) if !coverURL.liveTrimmed.isEmpty && !coverURL.liveTrimmed.liveIsHTTPURL { Text("封面图地址需以 http:// 或 https:// 开头") .font(.system(size: AppMetrics.FontSize.caption)) .foregroundStyle(AppDesign.warning) } } .padding(AppMetrics.Spacing.medium) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } private func submit() async { guard canSubmit else { return } guard let scenicId = accountContext.currentScenic?.id else { toastCenter.show("当前账号缺少景区信息") return } submitting = true defer { submitting = false } do { try await liveAPI.liveCreate(LiveCreateRequest(scenicId: String(scenicId), title: title.liveTrimmed, coverImg: coverURL.liveTrimmed)) onCreated() dismiss() } catch { toastCenter.show(error.localizedDescription) } } } /// 直播详情页面,展示推流信息和控制动作。 struct LiveDetailView: View { @Environment(LiveAPI.self) private var liveAPI @Environment(ToastCenter.self) private var toastCenter @Environment(\.globalLoading) private var globalLoading @State private var viewModel: LiveDetailViewModel @State private var copied = false let onChanged: () -> Void init(initialDetail: LiveEntity, onChanged: @escaping () -> Void) { _viewModel = State(initialValue: LiveDetailViewModel(detail: initialDetail)) self.onChanged = onChanged } var body: some View { VStack(spacing: 0) { ScrollView { VStack(spacing: AppMetrics.Spacing.medium) { coverSection infoSection pushURLSection pushModeSection Spacer(minLength: 80) } .padding(AppMetrics.Spacing.pageHorizontal) } bottomBar } .background(Color(hex: 0xF5F7FA)) .navigationTitle("直播信息") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("刷新") { Task { await refresh(showLoading: true) } } .disabled(viewModel.loading || viewModel.actionInFlight) } } .task { await refresh(showLoading: false) } } private var coverSection: some View { ZStack(alignment: .bottomLeading) { RemoteImage(urlString: viewModel.detail.coverImg, contentMode: .fill) { LinearGradient(colors: [Color(hex: 0x0F274A), AppDesign.primary], startPoint: .topLeading, endPoint: .bottomTrailing) .overlay { Image(systemName: "dot.radiowaves.left.and.right") .font(.system(size: 42, weight: .semibold)) .foregroundStyle(.white.opacity(0.72)) } } VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { Text(viewModel.detail.displayStatus) .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) .foregroundStyle(.white) .padding(.horizontal, AppMetrics.Spacing.small) .frame(height: 26) .background(statusColor.opacity(0.92), in: Capsule()) Text(viewModel.detail.title.liveNonEmpty ?? "暂无标题") .font(.system(size: AppMetrics.FontSize.title, weight: .bold)) .foregroundStyle(.white) .lineLimit(2) } .padding(AppMetrics.Spacing.medium) .frame(maxWidth: .infinity, alignment: .leading) .background(LinearGradient(colors: [.clear, .black.opacity(0.72)], startPoint: .top, endPoint: .bottom)) } .frame(height: 210) .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } private var infoSection: some View { liveDetailCard("直播信息") { LiveInfoRow(title: "标题", value: viewModel.detail.title.liveNonEmpty ?? "--") LiveInfoRow(title: "状态", value: viewModel.detail.displayStatus) LiveInfoRow(title: "直播时长", value: viewModel.detail.duration.liveDurationText) LiveInfoRow(title: "观看人次", value: "\(viewModel.detail.viewsCount)") } } private var pushURLSection: some View { liveDetailCard("推流地址") { Text(viewModel.detail.pushUrl.liveNonEmpty ?? "--") .font(.system(size: AppMetrics.FontSize.subheadline)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(AppMetrics.Spacing.medium) .background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) Button { UIPasteboard.general.string = viewModel.detail.pushUrl copied = true toastCenter.show("推流地址已复制") } label: { Label(copied ? "已复制推流地址" : "复制推流地址", systemImage: "doc.on.doc") .frame(maxWidth: .infinity, alignment: .leading) } .disabled(viewModel.detail.pushUrl.liveTrimmed.isEmpty) } } private var pushModeSection: some View { liveDetailCard("推流模式") { HStack(spacing: AppMetrics.Spacing.small) { pushModeButton("清晰度优先", mode: 1) pushModeButton("流畅度优先", mode: 2) } } } private var bottomBar: some View { HStack(spacing: AppMetrics.Spacing.medium) { Button { Task { await control() } } label: { Text(viewModel.detail.status == 2 ? "暂停直播" : "开始直播") .frame(maxWidth: .infinity) .frame(height: AppMetrics.ControlSize.primaryButtonHeight) } .buttonStyle(.borderedProminent) .disabled(viewModel.detail.status == 3 || viewModel.actionInFlight) Button(role: .destructive) { Task { await finish() } } label: { Text("结束直播") .frame(maxWidth: .infinity) .frame(height: AppMetrics.ControlSize.primaryButtonHeight) } .buttonStyle(.bordered) .disabled(viewModel.detail.status == 3 || viewModel.actionInFlight) } .padding(AppMetrics.Spacing.pageHorizontal) .background(.white) } private var statusColor: Color { switch viewModel.detail.status { case 2: AppDesign.success case 3: Color(hex: 0x64748B) default: AppDesign.primary } } private func liveDetailCard(_ title: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { Text(title) .font(.system(size: AppMetrics.FontSize.title3, weight: .bold)) .foregroundStyle(AppDesign.textPrimary) content() } .padding(AppMetrics.Spacing.medium) .frame(maxWidth: .infinity, alignment: .leading) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) } private func pushModeButton(_ title: String, mode: Int) -> some View { Button { Task { await setPushMode(mode) } } label: { Text(title) .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) .foregroundStyle(viewModel.detail.manualPushMode == mode ? .white : AppDesign.textSecondary) .frame(maxWidth: .infinity) .frame(height: 46) .background(viewModel.detail.manualPushMode == mode ? AppDesign.primary : Color(hex: 0xEEF2F7), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input)) } .disabled(viewModel.actionInFlight || viewModel.detail.manualPushMode == mode) .buttonStyle(.plain) } private func refresh(showLoading: Bool) async { await globalLoading.withOptionalLoading(showLoading) { await viewModel.refresh(api: liveAPI, showLoading: false) } if let message = viewModel.errorMessage { toastCenter.show(message) } } private func control() async { do { try await viewModel.control(api: liveAPI) onChanged() } catch { toastCenter.show(error.localizedDescription) } } private func finish() async { do { try await viewModel.finish(api: liveAPI) onChanged() } catch { toastCenter.show(error.localizedDescription) } } private func setPushMode(_ mode: Int) async { do { try await viewModel.setPushMode(api: liveAPI, mode: mode) onChanged() } catch { toastCenter.show(error.localizedDescription) } } } private struct LiveInfoRow: View { let title: String let value: String var body: some View { HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) { Text(title) .foregroundStyle(AppDesign.textSecondary) .frame(width: 72, alignment: .leading) Text(value) .foregroundStyle(AppDesign.textPrimary) .multilineTextAlignment(.trailing) .frame(maxWidth: .infinity, alignment: .trailing) } .font(.system(size: AppMetrics.FontSize.subheadline)) } } private extension String { var liveIsHTTPURL: Bool { let lower = liveTrimmed.lowercased() return lower.hasPrefix("http://") || lower.hasPrefix("https://") } }