import AppKit import SwiftUI import UniformTypeIdentifiers struct BugDetailView: View { let bug: Bug let isUnseen: Bool @State private var editService = BugEditService.shared @State private var fixNotesDraft: String = "" @State private var isSavingStatus = false @State private var isSavingFixNotes = false @State private var isUploading = false @State private var editError: Error? @State private var showNeedsReauth = false private var fixNotesDirty: Bool { fixNotesDraft != (bug.fixNotes ?? "") } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { header Divider() metadata if let description = bug.description { Divider() descriptionSection(description) } Divider() fixNotesSection Divider() screenshotsSection } .padding(24) .frame(maxWidth: .infinity, alignment: .leading) } .task { await editService.ensureFieldsLoaded() } .onAppear { fixNotesDraft = bug.fixNotes ?? "" } .onChange(of: bug.id) { _, _ in fixNotesDraft = bug.fixNotes ?? "" } .toolbar { ToolbarItemGroup(placement: .primaryAction) { Button { if let url = bug.feishuURL { AppDelegate.shared?.openWebWindow(url: url, title: bug.title) } } label: { Label("Open in App", systemImage: "rectangle.inset.filled") } .disabled(bug.feishuURL == nil) .help("Render the Feishu record page inside the app (e.g. to advance 流转状态)") Button { copyLink() } label: { Label("Copy Link", systemImage: "link") } .disabled(bug.feishuURL == nil) Button { openInFeishu() } label: { Label("Open in Feishu", systemImage: "arrow.up.forward.app") } .disabled(bug.feishuURL == nil) } } .alert( "Failed to Save", isPresented: Binding( get: { editError != nil }, set: { if !$0 { editError = nil } } ), presenting: editError ) { _ in Button("OK") { editError = nil } } message: { error in Text(error.localizedDescription) } .alert("需要重新授权", isPresented: $showNeedsReauth) { Button("重新连接 Feishu") { TokenManager.shared.clearTokens() AppDelegate.shared?.openPopover() } Button("取消", role: .cancel) {} } message: { Text("保存需要写权限。请重新授权飞书(已升级为可编辑权限范围);若重新授权后仍失败,请确认你在该多维表格中有编辑权限。") } } // MARK: - Sections private var header: some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { PriorityBadge(priority: bug.priority) StatusPill(status: bug.status) if isUnseen { Circle() .fill(.blue) .frame(width: 8, height: 8) } Spacer() } Text(bug.title) .font(.title2) .fontWeight(.semibold) .fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) } } private var metadata: some View { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { metadataLabel("Status") statusPicker } GridRow { metadataLabel("Assignee") Text(bug.assignee).textSelection(.enabled) } if let reporter = bug.reporter { GridRow { metadataLabel("Reporter") Text(reporter).textSelection(.enabled) } } if let customer = bug.customer { GridRow { metadataLabel("Customer") Text(customer).textSelection(.enabled) } } if let module = bug.module { GridRow { metadataLabel("Module") Text(module).textSelection(.enabled) } } if let source = bug.source { GridRow { metadataLabel("Source") Text(source).textSelection(.enabled) } } if let flowStatus = bug.flowStatus { GridRow { metadataLabel("Flow Status") // Workflow (流程) fields can't be written via the API — // advance the flow in Feishu ("Open in Feishu" above). Text(flowStatus).textSelection(.enabled) } } GridRow { metadataLabel("Created") Text(bug.createdAt, style: .date) + Text(" ") + Text(bug.createdAt, style: .time) } GridRow { metadataLabel("Updated") Text(bug.updatedAt, style: .date) + Text(" ") + Text(bug.updatedAt, style: .time) } GridRow { metadataLabel("Age") Text(ageString(bug.age)) } } } private func metadataLabel(_ title: String) -> some View { Text(title) .foregroundStyle(.secondary) .frame(width: 90, alignment: .trailing) } private func descriptionSection(_ description: String) -> some View { VStack(alignment: .leading, spacing: 8) { Text("Description") .font(.headline) Text(description) .font(.body) .foregroundStyle(.primary) .fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) } } // MARK: - Status editing private var statusPicker: some View { HStack(spacing: 6) { Menu { ForEach(editService.statusOptions, id: \.id) { option in Button { changeStatus(to: option.name) } label: { HStack { Circle() .fill(feishuOptionColor(option.color)) .frame(width: 8, height: 8) Text(option.name) if option.name == bug.statusRaw { Image(systemName: "checkmark") } } } } } label: { HStack(spacing: 4) { Text(bug.statusRaw ?? "Set status") .foregroundStyle(bug.statusRaw == nil ? .secondary : .primary) Image(systemName: "chevron.up.chevron.down") .font(.caption2) .foregroundStyle(.secondary) } .padding(.horizontal, 8) .padding(.vertical, 3) .background(Color.secondary.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 5)) } .menuStyle(.borderlessButton) .fixedSize() .disabled(isSavingStatus || editService.statusOptions.isEmpty) if isSavingStatus { ProgressView() .scaleEffect(0.6) .frame(width: 14, height: 14) } } } private func changeStatus(to optionName: String) { guard optionName != bug.statusRaw else { return } isSavingStatus = true Task { do { try await editService.updateStatus(bug, to: optionName) } catch { handleEditError(error) } isSavingStatus = false } } // MARK: - Fix notes editing private var fixNotesSection: some View { VStack(alignment: .leading, spacing: 8) { HStack { Text("Fix Notes") .font(.headline) if isSavingFixNotes { ProgressView() .scaleEffect(0.6) .frame(width: 14, height: 14) } } TextEditor(text: $fixNotesDraft) .font(.body) .frame(minHeight: 60, maxHeight: 200) .scrollContentBackground(.hidden) .padding(6) .background(Color.secondary.opacity(0.06)) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) .strokeBorder(Color.secondary.opacity(0.2)) ) if fixNotesDirty && !isSavingFixNotes { HStack(spacing: 8) { Button("Save") { saveFixNotes() } .keyboardShortcut(.return, modifiers: .command) Button("Cancel") { fixNotesDraft = bug.fixNotes ?? "" } } .controlSize(.small) } } } private func saveFixNotes() { isSavingFixNotes = true Task { do { try await editService.updateFixNotes(bug, text: fixNotesDraft) } catch { handleEditError(error) } isSavingFixNotes = false } } // MARK: - Screenshots private var screenshotsSection: some View { VStack(alignment: .leading, spacing: 8) { Text("Screenshots") .font(.headline) ScrollView(.horizontal) { HStack(spacing: 12) { ForEach(bug.screenshots, id: \.fileToken) { attachment in ScreenshotThumbnail(attachment: attachment) } addScreenshotTile } .padding(.vertical, 2) } } } private var addScreenshotTile: some View { Button(action: pickScreenshots) { VStack(spacing: 4) { if isUploading { ProgressView() } else { Image(systemName: "plus") .font(.title2) .foregroundStyle(.secondary) Text("Add") .font(.caption2) .foregroundStyle(.secondary) } } .frame(width: 60, height: 120) .background(Color.secondary.opacity(0.08)) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) .strokeBorder(Color.secondary.opacity(0.3), style: StrokeStyle(dash: [4])) ) } .buttonStyle(.plain) .disabled(isUploading) .help("Add screenshot") } private func pickScreenshots() { let panel = NSOpenPanel() panel.allowedContentTypes = [.image] panel.allowsMultipleSelection = true panel.canChooseDirectories = false panel.begin { response in guard response == .OK else { return } let images: [(name: String, data: Data, mimeType: String)] = panel.urls.compactMap { url in guard let data = try? Data(contentsOf: url) else { return nil } let ext = url.pathExtension.lowercased() let mime = UTType(filenameExtension: ext)?.preferredMIMEType ?? "image/png" return (url.lastPathComponent, data, mime) } guard !images.isEmpty else { return } isUploading = true Task { do { try await editService.addScreenshots(bug, images: images) } catch { handleEditError(error) } isUploading = false } } } // MARK: - Helpers private func handleEditError(_ error: Error) { if let editError = error as? EditError, editError == .needsReauth { showNeedsReauth = true } else { self.editError = error } } /// Approximate palette for Feishu select-option color indices. private func feishuOptionColor(_ index: Int?) -> Color { let palette: [Color] = [ .gray, .red, .orange, .yellow, .green, .teal, .cyan, .blue, .indigo, .purple, .pink, .brown, .mint ] guard let index else { return .gray } return palette[abs(index) % palette.count] } private func openInFeishu() { guard let url = bug.feishuURL else { return } NSWorkspace.shared.open(url) } private func copyLink() { guard let url = bug.feishuURL else { return } NSPasteboard.general.clearContents() NSPasteboard.general.setString(url.absoluteString, forType: .string) } private func ageString(_ age: TimeInterval) -> String { let days = Int(age / 86_400) let hours = Int(age / 3_600) if days > 0 { return days == 1 ? "1 day" : "\(days) days" } if hours > 0 { return hours == 1 ? "1 hour" : "\(hours) hours" } return "less than an hour" } } /// Thumbnail for a single attachment. Images are downloaded and shown inline; /// tapping opens the file in the system viewer. Failed downloads retry on tap. private struct ScreenshotThumbnail: View { let attachment: BugAttachment @State private var loader = AttachmentLoader.shared var body: some View { VStack(spacing: 4) { ZStack { if let image = loader.image(for: attachment) { Image(nsImage: image) .resizable() .scaledToFit() } else { placeholder } } .frame(width: 180, height: 120) .background(Color.secondary.opacity(0.08)) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color.secondary.opacity(0.2))) Text(attachment.name) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) .frame(width: 180) } .onAppear { if attachment.isImage { loader.load(attachment) } } .onTapGesture { switch loader.state(for: attachment) { case .loaded: loader.loadAndOpen(attachment) case .failed: loader.retry(attachment) case .loading: break case nil: loader.loadAndOpen(attachment) } } .help(attachment.name) } @ViewBuilder private var placeholder: some View { switch loader.state(for: attachment) { case .loading: ProgressView() case .failed: VStack(spacing: 4) { Image(systemName: "exclamationmark.arrow.triangle.2.circlepath") .font(.title3) .foregroundStyle(.orange) Text("Tap to retry") .font(.caption2) .foregroundStyle(.secondary) } default: VStack(spacing: 4) { Image(systemName: attachment.isImage ? "photo" : "doc") .font(.title2) .foregroundStyle(.secondary) if !attachment.isImage { Text("Tap to open") .font(.caption2) .foregroundStyle(.secondary) } } } } }