Files
projectbutler/ProjectButler/Sources/MenuBarView.swift

270 lines
8.8 KiB
Swift

import SwiftUI
import SwiftUI
import AppKit
struct MenuBarView: View {
@EnvironmentObject var state: AppState
@State private var isRotating = false
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(action: {
Task {
await state.refresh()
}
}) {
Image(systemName: state.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise")
.font(.system(size: 11))
.rotationEffect(.degrees(isRotating ? 360 : 0))
}
.onChange(of: state.isLoading) { _, newValue in
withAnimation(newValue ? .linear(duration: 1).repeatForever(autoreverses: false) : .default) {
isRotating = newValue
}
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
.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))
}
}
}
}