bugger/Sources/Views/MainWindow/BugDetailView.swift

490 lines
16 KiB
Swift

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)
}
}
}
}
}