init: init proj

This commit is contained in:
2026-02-26 22:15:35 +08:00
commit 7ef5348f65
43 changed files with 3085 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
import SwiftUI
struct BrowseView: View {
let category: ContentCategory
@Bindable var viewModel: BrowseViewModel
var body: some View {
Group {
if !viewModel.hasData && viewModel.error == nil {
VStack(spacing: 0) {
FilterBarView(filter: $viewModel.filter) {
Task { await viewModel.applyFilter(viewModel.filter) }
}
.padding(.vertical, 8)
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
} else {
ScrollView {
VStack(spacing: 0) {
FilterBarView(filter: $viewModel.filter) {
Task { await viewModel.applyFilter(viewModel.filter) }
}
.padding(.vertical, 8)
if let error = viewModel.error {
errorView(error)
} else if viewModel.items.isEmpty {
emptyView
} else {
ContentGridView(items: viewModel.items) {
Task { await viewModel.loadMore() }
}
.padding(.horizontal)
.padding(.top, 8)
if viewModel.isLoadingMore {
ProgressView()
.padding()
}
}
}
}
}
}
.navigationTitle(category.displayName)
.navigationDestination(for: ContentItem.self) { item in
DetailView(item: item)
}
.refreshable {
await viewModel.loadContent()
}
.task {
viewModel.category = category
await viewModel.loadContentIfNeeded()
}
}
private func errorView(_ message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text(message)
.foregroundStyle(.secondary)
Button("重试") {
Task { await viewModel.loadContent() }
}
}
.frame(maxWidth: .infinity, minHeight: 300)
}
private var emptyView: some View {
VStack(spacing: 12) {
Image(systemName: "film.stack")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("暂无内容")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, minHeight: 300)
}
}

View File

@@ -0,0 +1,75 @@
import SwiftUI
struct ContentCardView: View {
let item: ContentItem
private let cardWidth: CGFloat = 140
private let aspectRatio: CGFloat = 2.0 / 3.0
var body: some View {
VStack(alignment: .leading, spacing: 6) {
//
ZStack(alignment: .topLeading) {
posterImage
.frame(width: cardWidth, height: cardWidth / aspectRatio)
.clipShape(RoundedRectangle(cornerRadius: 8))
//
if let rating = item.rating, rating > 0 {
Text(String(format: "%.1f", rating))
.font(.caption2)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.orange.gradient, in: RoundedRectangle(cornerRadius: 4))
.padding(6)
.frame(maxWidth: .infinity, alignment: .topTrailing)
}
//
if let badge = item.badges.first {
Text(badge)
.font(.caption2)
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.blue.gradient, in: RoundedRectangle(cornerRadius: 4))
.padding(6)
}
}
//
Text(item.title)
.font(.caption)
.fontWeight(.medium)
.lineLimit(2)
.frame(width: cardWidth, alignment: .leading)
//
if item.year > 0 {
Text("\(String(item.year))")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(width: cardWidth)
}
@ViewBuilder
private var posterImage: some View {
CachedAsyncImage(url: item.posterURL) {
posterPlaceholder
}
}
private var posterPlaceholder: some View {
Rectangle()
.fill(.quaternary)
.overlay {
Image(systemName: "film")
.font(.title)
.foregroundStyle(.tertiary)
}
}
}

View File

@@ -0,0 +1,31 @@
import SwiftUI
struct ContentGridView: View {
let items: [ContentItem]
var onNearEnd: (() -> Void)?
private let columns = [
GridItem(.adaptive(minimum: 130, maximum: 180), spacing: 12)
]
var body: some View {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(items) { item in
NavigationLink(value: item) {
ContentCardView(item: item)
}
.buttonStyle(.plain)
.onAppear {
if let onNearEnd, isNearEnd(item) {
onNearEnd()
}
}
}
}
}
private func isNearEnd(_ item: ContentItem) -> Bool {
guard let index = items.firstIndex(where: { $0.id == item.id }) else { return false }
return index >= items.count - 4
}
}

View File

@@ -0,0 +1,55 @@
import SwiftUI
struct FilterBarView: View {
@Binding var filter: FilterState
var onFilterChanged: () -> Void
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
filterMenu("排序", options: FilterState.defaultSorts, selection: $filter.sort)
filterMenu("类型", options: FilterState.defaultGenres, selection: $filter.genre)
filterMenu("地区", options: FilterState.defaultRegions, selection: $filter.region)
filterMenu("年份", options: FilterState.defaultYears, selection: $filter.year)
}
.padding(.horizontal)
}
}
private func filterMenu(_ title: String, options: [FilterOption], selection: Binding<String>) -> some View {
Menu {
ForEach(options) { option in
Button {
selection.wrappedValue = option.value
onFilterChanged()
} label: {
HStack {
Text(option.name)
if selection.wrappedValue == option.value {
Image(systemName: "checkmark")
}
}
}
}
} label: {
HStack(spacing: 4) {
Text(selectedName(for: selection.wrappedValue, in: options) ?? title)
.font(.subheadline)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
selection.wrappedValue.isEmpty
? Color.secondary.opacity(0.12)
: Color.accentColor.opacity(0.15),
in: Capsule()
)
.foregroundStyle(selection.wrappedValue.isEmpty ? Color.primary : Color.accentColor)
}
}
private func selectedName(for value: String, in options: [FilterOption]) -> String? {
guard !value.isEmpty else { return nil }
return options.first { $0.value == value }?.name
}
}