Files
projectbutler/ProjectButler/Sources/MenuBarView 2.swift

305 lines
9.4 KiB
Swift

import SwiftUI
import AppKit
struct MenuBarView: View {
@EnvironmentObject var state: AppState
@State private var rotationAngle: Double = 0
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("项目管家")
.font(.system(size: 14, weight: .bold))
Spacer()
ServiceDots(
memory: state.memoryOnline,
ports: state.portsOnline,
asr: state.asrOnline
)
Button {
Task {
await state.refresh()
}
} label: {
Image(systemName: "arrow.clockwise")
.font(.system(size: 11))
.rotationEffect(.degrees(rotationAngle))
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.onChange(of: state.isLoading) { _, isLoading in
if isLoading {
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
rotationAngle = 360
}
} else {
withAnimation {
rotationAngle = 0
}
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
Divider()
// Stats row
HStack(spacing: 12) {
StatBadge(
label: "项目",
value: "\(state.projects.count)",
color: .blue
)
StatBadge(
label: "端口",
value: "\(state.portStats.running)/\(state.portStats.total)",
color: .green
)
if state.asrOnline {
StatBadge(
label: "字幕",
value: state.asrStatus.running ? "运行中" : "\(state.asrStatus.done)/\(state.asrStatus.total)",
color: state.asrStatus.running ? .orange : .purple
)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
Divider()
// ASR Progress (if running)
if state.asrOnline && state.asrStatus.total > 0 {
ASRProgressBar(status: state.asrStatus)
.padding(.horizontal, 16)
.padding(.vertical, 6)
Divider()
}
// Search
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(.tertiary)
.font(.system(size: 11))
TextField("搜索项目...", text: $state.searchText)
.textFieldStyle(.plain)
.font(.system(size: 12))
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
Divider()
// Project list
ScrollView {
LazyVStack(spacing: 2) {
ForEach(state.filteredProjects) { project in
ProjectRow(project: project)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
}
.frame(maxHeight: 320)
Divider()
// Footer
HStack {
if let last = state.lastRefresh {
Text("更新: \(last, formatter: timeFormatter)")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
}
Spacer()
Button("退出") {
NSApplication.shared.terminate(nil)
}
.font(.system(size: 11))
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
.frame(width: 360)
}
private var timeFormatter: DateFormatter {
let f = DateFormatter()
f.dateFormat = "HH:mm:ss"
return f
}
}
// MARK: - Components
struct ServiceDots: View {
let memory: Bool
let ports: Bool
let asr: Bool
var body: some View {
HStack(spacing: 4) {
Circle()
.fill(memory ? .green : .red.opacity(0.5))
.frame(width: 6, height: 6)
.help(memory ? "记忆中心: 在线" : "记忆中心: 离线")
Circle()
.fill(ports ? .green : .red.opacity(0.5))
.frame(width: 6, height: 6)
.help(ports ? "端口注册: 在线" : "端口注册: 离线")
Circle()
.fill(asr ? .green : .gray.opacity(0.3))
.frame(width: 6, height: 6)
.help(asr ? "ASR: 在线" : "ASR: 离线")
}
}
}
struct StatBadge: View {
let label: String
let value: String
let color: Color
var body: some View {
VStack(spacing: 2) {
Text(value)
.font(.system(size: 16, weight: .bold, design: .rounded))
.foregroundStyle(color)
Text(label)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.background(color.opacity(0.08), in: RoundedRectangle(cornerRadius: 8))
}
}
struct ASRProgressBar: View {
let status: ASRStatus
var progress: Double {
guard status.total > 0 else { return 0 }
return Double(status.done) / Double(status.total)
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: status.running ? "waveform" : "waveform.slash")
.font(.system(size: 10))
.foregroundStyle(status.running ? .orange : .secondary)
Text("字幕提取")
.font(.system(size: 11, weight: .medium))
Spacer()
Text("\(Int(progress * 100))%")
.font(.system(size: 11, weight: .bold, design: .monospaced))
.foregroundStyle(status.running ? .orange : .secondary)
}
ProgressView(value: progress)
.tint(status.running ? .orange : .blue)
HStack {
Text("\(status.done)/\(status.total) 视频")
Spacer()
if status.chars > 0 {
Text("\(status.chars / 10000)万字")
}
}
.font(.system(size: 9))
.foregroundStyle(.tertiary)
}
}
}
struct ProjectRow: View {
let project: Project
var statusIcon: String {
switch project.status {
case "active", "进行中":
return "circle.fill"
case "done", "完成", "completed":
return "checkmark.circle.fill"
case "paused", "暂停":
return "pause.circle.fill"
default:
return "circle"
}
}
var statusColor: Color {
switch project.status {
case "active", "进行中":
return .green
case "done", "完成", "completed":
return .blue
case "paused", "暂停":
return .orange
default:
return .gray
}
}
var categoryBadge: String {
switch project.category {
case "business":
return "B"
case "research":
return "R"
case "code":
return "C"
default:
return "?"
}
}
var body: some View {
HStack(spacing: 8) {
// Category badge
Text(categoryBadge)
.font(.system(size: 9, weight: .bold, design: .monospaced))
.foregroundStyle(.white)
.frame(width: 18, height: 18)
.background(
project.category == "business" ? Color.purple :
project.category == "research" ? Color.teal : Color.blue,
in: RoundedRectangle(cornerRadius: 4)
)
// Info
VStack(alignment: .leading, spacing: 1) {
Text(project.name)
.font(.system(size: 12, weight: .medium))
.lineLimit(1)
if let what = project.what, !what.isEmpty {
Text(what)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
// Status
Image(systemName: statusIcon)
.font(.system(size: 9))
.foregroundStyle(statusColor)
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(Color.primary.opacity(0.03), in: RoundedRectangle(cornerRadius: 6))
.contentShape(Rectangle())
.onTapGesture {
if let path = project.path {
let expanded = (path as NSString).expandingTildeInPath
NSWorkspace.shared.open(URL(fileURLWithPath: expanded))
}
}
}
}