270 lines
8.8 KiB
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))
|
|
}
|
|
}
|
|
}
|
|
}
|