auto-save 2026-04-01 09:03 (+7, ~1)
This commit is contained in:
157
ProjectButler/Sources/AppState 2.swift
Normal file
157
ProjectButler/Sources/AppState 2.swift
Normal file
@@ -0,0 +1,157 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
class AppState: ObservableObject {
|
||||
// API endpoints
|
||||
let memoryAPI = "http://localhost:3202/api/memories"
|
||||
let portAPI = "http://localhost:3201/api/ports"
|
||||
let portStatsAPI = "http://localhost:3201/api/ports/stats"
|
||||
let asrStatusAPI = "http://localhost:4125/api/asr-status"
|
||||
|
||||
// Data
|
||||
@Published var projects: [Project] = []
|
||||
@Published var portStats: PortStats = PortStats()
|
||||
@Published var asrStatus: ASRStatus = ASRStatus()
|
||||
@Published var isLoading = false
|
||||
@Published var lastRefresh: Date?
|
||||
@Published var searchText = ""
|
||||
|
||||
// Services status
|
||||
@Published var memoryOnline = false
|
||||
@Published var portsOnline = false
|
||||
@Published var asrOnline = false
|
||||
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
|
||||
init() {
|
||||
startAutoRefresh()
|
||||
}
|
||||
|
||||
deinit {
|
||||
refreshTask?.cancel()
|
||||
}
|
||||
|
||||
private func startAutoRefresh() {
|
||||
refreshTask = Task { @MainActor [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Initial refresh
|
||||
await self.refresh()
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 30_000_000_000)
|
||||
if !Task.isCancelled {
|
||||
await self.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
isLoading = true
|
||||
|
||||
async let p: Void = fetchProjects()
|
||||
async let s: Void = fetchPortStats()
|
||||
async let a: Void = fetchASRStatus()
|
||||
|
||||
_ = await (p, s, a)
|
||||
|
||||
isLoading = false
|
||||
lastRefresh = Date()
|
||||
}
|
||||
|
||||
var filteredProjects: [Project] {
|
||||
if searchText.isEmpty { return projects }
|
||||
let q = searchText.lowercased()
|
||||
return projects.filter {
|
||||
$0.name.lowercased().contains(q) ||
|
||||
$0.slug.lowercased().contains(q) ||
|
||||
($0.what ?? "").lowercased().contains(q)
|
||||
}
|
||||
}
|
||||
|
||||
var activeProjects: [Project] {
|
||||
projects.filter { $0.status == "active" || $0.status == "进行中" }
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
|
||||
private func fetchProjects() async {
|
||||
guard let url = URL(string: memoryAPI) else { return }
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
let decoded = try JSONDecoder().decode([Project].self, from: data)
|
||||
projects = decoded
|
||||
memoryOnline = true
|
||||
} catch {
|
||||
memoryOnline = false
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchPortStats() async {
|
||||
guard let url = URL(string: portStatsAPI) else { return }
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
let decoded = try JSONDecoder().decode(PortStats.self, from: data)
|
||||
portStats = decoded
|
||||
portsOnline = true
|
||||
} catch {
|
||||
portsOnline = false
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchASRStatus() async {
|
||||
guard let url = URL(string: asrStatusAPI) else { return }
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
let decoded = try JSONDecoder().decode(ASRStatus.self, from: data)
|
||||
asrStatus = decoded
|
||||
asrOnline = true
|
||||
} catch {
|
||||
asrOnline = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
struct Project: Codable, Identifiable {
|
||||
var id: String { slug }
|
||||
let slug: String
|
||||
let name: String
|
||||
let path: String?
|
||||
let status: String?
|
||||
let category: String?
|
||||
let what: String?
|
||||
let updatedAt: String?
|
||||
let eventCount: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case slug, name, path, status, category, what
|
||||
case updatedAt = "updated_at"
|
||||
case eventCount = "event_count"
|
||||
}
|
||||
}
|
||||
|
||||
struct PortStats: Codable {
|
||||
var total: Int = 0
|
||||
var project: Int = 0
|
||||
var adhoc: Int = 0
|
||||
var legacy: Int = 0
|
||||
var running: Int = 0
|
||||
var nextBlock: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case total, project, adhoc, legacy, running
|
||||
case nextBlock = "next_block"
|
||||
}
|
||||
}
|
||||
|
||||
struct ASRStatus: Codable {
|
||||
var total: Int = 0
|
||||
var done: Int = 0
|
||||
var failed: Int = 0
|
||||
var chars: Int = 0
|
||||
var running: Bool = false
|
||||
}
|
||||
154
ProjectButler/Sources/AppState.swift
Normal file
154
ProjectButler/Sources/AppState.swift
Normal file
@@ -0,0 +1,154 @@
|
||||
import SwiftUI
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
class AppState: ObservableObject {
|
||||
// API endpoints
|
||||
let memoryAPI = "http://localhost:3202/api/memories"
|
||||
let portAPI = "http://localhost:3201/api/ports"
|
||||
let portStatsAPI = "http://localhost:3201/api/ports/stats"
|
||||
let asrStatusAPI = "http://localhost:4125/api/asr-status"
|
||||
|
||||
// Data
|
||||
@Published var projects: [Project] = []
|
||||
@Published var portStats: PortStats = PortStats()
|
||||
@Published var asrStatus: ASRStatus = ASRStatus()
|
||||
@Published var isLoading = false
|
||||
@Published var lastRefresh: Date?
|
||||
@Published var searchText = ""
|
||||
|
||||
// Services status
|
||||
@Published var memoryOnline = false
|
||||
@Published var portsOnline = false
|
||||
@Published var asrOnline = false
|
||||
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
|
||||
init() {
|
||||
startAutoRefresh()
|
||||
}
|
||||
|
||||
deinit {
|
||||
refreshTask?.cancel()
|
||||
}
|
||||
|
||||
private func startAutoRefresh() {
|
||||
refreshTask = Task { @MainActor in
|
||||
// Initial refresh
|
||||
await refresh()
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 30_000_000_000)
|
||||
if !Task.isCancelled {
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
isLoading = true
|
||||
async let p: Void = fetchProjects()
|
||||
async let s: Void = fetchPortStats()
|
||||
async let a: Void = fetchASRStatus()
|
||||
_ = await (p, s, a)
|
||||
isLoading = false
|
||||
lastRefresh = Date()
|
||||
}
|
||||
|
||||
var filteredProjects: [Project] {
|
||||
if searchText.isEmpty { return projects }
|
||||
let q = searchText.lowercased()
|
||||
return projects.filter {
|
||||
$0.name.lowercased().contains(q) ||
|
||||
$0.slug.lowercased().contains(q) ||
|
||||
($0.what ?? "").lowercased().contains(q)
|
||||
}
|
||||
}
|
||||
|
||||
var activeProjects: [Project] {
|
||||
projects.filter { $0.status == "active" || $0.status == "进行中" }
|
||||
}
|
||||
|
||||
// MARK: - Fetch
|
||||
|
||||
private func fetchProjects() async {
|
||||
guard let url = URL(string: memoryAPI) else { return }
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
let decoded = try JSONDecoder().decode([Project].self, from: data)
|
||||
projects = decoded
|
||||
memoryOnline = true
|
||||
} catch {
|
||||
memoryOnline = false
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchPortStats() async {
|
||||
guard let url = URL(string: portStatsAPI) else { return }
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
let decoded = try JSONDecoder().decode(PortStats.self, from: data)
|
||||
portStats = decoded
|
||||
portsOnline = true
|
||||
} catch {
|
||||
portsOnline = false
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchASRStatus() async {
|
||||
guard let url = URL(string: asrStatusAPI) else { return }
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
let decoded = try JSONDecoder().decode(ASRStatus.self, from: data)
|
||||
asrStatus = decoded
|
||||
asrOnline = true
|
||||
} catch {
|
||||
asrOnline = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
struct Project: Codable, Identifiable {
|
||||
var id: String { slug }
|
||||
let slug: String
|
||||
let name: String
|
||||
let path: String?
|
||||
let status: String?
|
||||
let category: String?
|
||||
let what: String?
|
||||
let updatedAt: String?
|
||||
let eventCount: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case slug, name, path, status, category, what
|
||||
case updatedAt = "updated_at"
|
||||
case eventCount = "event_count"
|
||||
}
|
||||
}
|
||||
|
||||
struct PortStats: Codable {
|
||||
var total: Int = 0
|
||||
var project: Int = 0
|
||||
var adhoc: Int = 0
|
||||
var legacy: Int = 0
|
||||
var running: Int = 0
|
||||
var nextBlock: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case total, project, adhoc, legacy, running
|
||||
case nextBlock = "next_block"
|
||||
}
|
||||
}
|
||||
|
||||
struct ASRStatus: Codable {
|
||||
var total: Int = 0
|
||||
var done: Int = 0
|
||||
var failed: Int = 0
|
||||
var chars: Int = 0
|
||||
var running: Bool = false
|
||||
}
|
||||
304
ProjectButler/Sources/MenuBarView 2.swift
Normal file
304
ProjectButler/Sources/MenuBarView 2.swift
Normal file
@@ -0,0 +1,304 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
269
ProjectButler/Sources/MenuBarView.swift
Normal file
269
ProjectButler/Sources/MenuBarView.swift
Normal file
@@ -0,0 +1,269 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
ProjectButler/Sources/Package.swift
Normal file
19
ProjectButler/Sources/Package.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ProjectButler",
|
||||
platforms: [.macOS(.v13)],
|
||||
products: [
|
||||
.executable(
|
||||
name: "ProjectButler",
|
||||
targets: ["ProjectButler"]
|
||||
)
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "ProjectButler",
|
||||
path: "Sources"
|
||||
)
|
||||
]
|
||||
)
|
||||
19
ProjectButler/Sources/ProjectButlerApp 2.swift
Normal file
19
ProjectButler/Sources/ProjectButlerApp 2.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct ProjectButlerApp: App {
|
||||
@StateObject private var appState = AppState()
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra("项目管家", systemImage: "tray.full.fill") {
|
||||
MenuBarView()
|
||||
.environmentObject(appState)
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
|
||||
Settings {
|
||||
SettingsView()
|
||||
.environmentObject(appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
19
ProjectButler/Sources/ProjectButlerApp.swift
Normal file
19
ProjectButler/Sources/ProjectButlerApp.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct ProjectButlerApp: App {
|
||||
@StateObject private var appState = AppState()
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra("项目管家", systemImage: "tray.full.fill") {
|
||||
MenuBarView()
|
||||
.environmentObject(appState)
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
|
||||
Settings {
|
||||
SettingsView()
|
||||
.environmentObject(appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
ProjectButler/Sources/SettingsView 2.swift
Normal file
63
ProjectButler/Sources/SettingsView 2.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var state: AppState
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
GeneralSettings()
|
||||
.tabItem {
|
||||
Label("通用", systemImage: "gear")
|
||||
}
|
||||
AboutView()
|
||||
.tabItem {
|
||||
Label("关于", systemImage: "info.circle")
|
||||
}
|
||||
}
|
||||
.frame(width: 400, height: 200)
|
||||
}
|
||||
}
|
||||
|
||||
struct GeneralSettings: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("API 地址") {
|
||||
LabeledContent("记忆中心") {
|
||||
Text("localhost:3202").foregroundStyle(.secondary)
|
||||
}
|
||||
LabeledContent("端口注册") {
|
||||
Text("localhost:3201").foregroundStyle(.secondary)
|
||||
}
|
||||
LabeledContent("ASR 状态") {
|
||||
Text("localhost:4125").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Section("刷新") {
|
||||
LabeledContent("自动刷新") {
|
||||
Text("每 30 秒").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
struct AboutView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "tray.full.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.blue)
|
||||
Text("项目管家")
|
||||
.font(.title2.bold())
|
||||
Text("macOS 菜单栏应用\n统管项目记忆 / 端口 / ASR 进度")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("v1.0 · 2026")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
51
ProjectButler/Sources/SettingsView.swift
Normal file
51
ProjectButler/Sources/SettingsView.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var state: AppState
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
GeneralSettings()
|
||||
.tabItem { Label("通用", systemImage: "gear") }
|
||||
AboutView()
|
||||
.tabItem { Label("关于", systemImage: "info.circle") }
|
||||
}
|
||||
.frame(width: 400, height: 200)
|
||||
}
|
||||
}
|
||||
|
||||
struct GeneralSettings: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("API 地址") {
|
||||
LabeledContent("记忆中心") { Text("localhost:3202").foregroundStyle(.secondary) }
|
||||
LabeledContent("端口注册") { Text("localhost:3201").foregroundStyle(.secondary) }
|
||||
LabeledContent("ASR 状态") { Text("localhost:4125").foregroundStyle(.secondary) }
|
||||
}
|
||||
Section("刷新") {
|
||||
LabeledContent("自动刷新") { Text("每 30 秒").foregroundStyle(.secondary) }
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
struct AboutView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "tray.full.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.blue)
|
||||
Text("项目管家")
|
||||
.font(.title2.bold())
|
||||
Text("macOS 菜单栏应用\n统管项目记忆 / 端口 / ASR 进度")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("v1.0 · 2026")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user