490 lines
16 KiB
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|