迁移:SPM → Xcode 构建,String Catalog 替代 .strings,清理 web/server/docker 废弃代码

- 项目从 SPM (Sources/VODManager/) 迁移到 Xcode 构建系统 (VODManager.xcodeproj)
- 源码目录从 Sources/VODManager/ 迁移至 VODManager/VODManager/
- 用 Localizable.xcstrings 替代旧的 .lproj/Localizable.strings 文件
- 删除已废弃的 apps/web、apps/server、docker 目录及相关文档
- RTL 侧边栏修复:切语言时用 .id() 强制重建 NavigationSplitView
- 视频库无账号时不发起 API 请求和重试
- 无账号时显示 ContentUnavailableView 提示
This commit is contained in:
2026-06-30 15:46:06 +06:00
parent 05b247bed6
commit d94054dd1a
81 changed files with 3141 additions and 12522 deletions
-7
View File
@@ -1,7 +0,0 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/
-18
View File
@@ -1,18 +0,0 @@
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "VODManager",
platforms: [.macOS(.v14)],
products: [
.executable(name: "VODManager", targets: ["VODManager"]),
],
targets: [
.executableTarget(
name: "VODManager",
path: "Sources/VODManager"
),
]
)
-57
View File
@@ -1,57 +0,0 @@
# 火炬 VOD 管理器 - macOS 原生版
基于 SwiftUI + Swift Package Manager 的阿里云 VOD 桌面管理客户端。
## 特性
- 原生 macOS SwiftUI 界面
- 直接调用阿里云 VOD OpenAPI,无需 Node 后端
- 多账号管理、切换
- 视频列表、搜索、详情
- 批量下载到用户指定目录
- 自定义下载文件名(自动清理非法字符)
- 下载任务历史
- 操作日志
## 技术栈
- Swift 6 + SwiftUI
- GRDBSQLite
- CryptoKit(阿里云 POP 签名)
- URLSession(网络请求与下载)
## 开发要求
- macOS 14+
- Xcode 15+
- Swift 5.9+
## 本地运行
```bash
cd apps/macos/VODManager
swift build
swift run
```
或在 Xcode 中打开 `Package.swift`
## App Store 上架准备
1. 在 Xcode 中创建正式的 macOS App 工程(或打开 Swift Package)。
2. 配置 Signing & Capabilities
- App Sandbox 开启
- 启用 entitlements 文件 `VODManager.entitlements`
- Team 与 Bundle Identifier
3. 提交 Mac App Store 审核。
### Entitlements 说明
- `com.apple.security.app-sandbox`:沙盒必须开启
- `com.apple.security.network.client`:访问阿里云 API
- `com.apple.security.files.user-selected.read-write`:用户选择下载目录
- `com.apple.security.files.downloads.read-write`:写入 Downloads 目录
## 与 Web 版的关系
Web 版(`apps/web` + `apps/server`)已废弃,不再维护。所有新功能在 macOS 原生版中实现。
@@ -1,114 +0,0 @@
import Foundation
import SwiftData
@MainActor
class DownloadManager: ObservableObject {
static let shared = DownloadManager()
@Published var tasks: [DownloadTask] = []
private var session: URLSession!
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 300
config.timeoutIntervalForResource = 86400
session = URLSession(configuration: config)
}
func loadTasks() async {
tasks = await DatabaseManager.shared.allDownloadTasks()
}
func download(
video: VideoItem,
playInfo: PlayInfo,
to directory: URL,
filename: String? = nil
) async -> DownloadTask {
let safeTitle = sanitizeFilename(video.title)
let ext = (playInfo.format ?? "mp4").lowercased()
let name = filename ?? "\(safeTitle)_\(playInfo.definition).\(ext)"
let destination = directory.appendingPathComponent(name)
let task = DownloadTask(
videoId: video.videoId,
title: video.title,
definition: playInfo.definition,
sourceURL: playInfo.playURL,
localPath: destination.path,
status: .pending,
progress: 0,
errorMessage: nil,
createdAt: Date()
)
await DatabaseManager.shared.insert(task)
await loadTasks()
guard let url = URL(string: playInfo.playURL) else {
task.status = .failed
task.errorMessage = "Invalid URL"
await DatabaseManager.shared.save(task)
await loadTasks()
return task
}
task.status = .downloading
await DatabaseManager.shared.save(task)
do {
let (tempURL, _) = try await session.download(from: url)
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.moveItem(at: tempURL, to: destination)
task.status = .completed
task.progress = 1
} catch {
task.status = .failed
task.errorMessage = error.localizedDescription
}
await DatabaseManager.shared.save(task)
await loadTasks()
return task
}
func batchDownload(
items: [(video: VideoItem, playInfo: PlayInfo)],
to directory: URL,
maxConcurrent: Int = 2
) async {
await withTaskGroup(of: Void.self) { group in
var iterator = items.makeIterator()
var running = 0
while let item = iterator.next() {
group.addTask {
_ = await self.download(video: item.video, playInfo: item.playInfo, to: directory)
}
running += 1
if running >= maxConcurrent {
await group.next()
running -= 1
}
}
while running > 0 {
await group.next()
running -= 1
}
}
}
private func sanitizeFilename(_ filename: String) -> String {
let invalidCharacters = CharacterSet(charactersIn: "/\\?%*|:\"<>")
return filename
.components(separatedBy: invalidCharacters)
.joined(separator: "_")
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "..", with: ".")
}
}
@@ -1,100 +0,0 @@
import SwiftUI
import SwiftData
struct ContentView: View {
@StateObject private var accountStore = AccountStore.shared
var body: some View {
NavigationSplitView {
SidebarView()
.frame(minWidth: 180)
} detail: {
Text("请在左侧选择功能")
.font(.title2)
.foregroundStyle(.secondary)
}
.environmentObject(accountStore)
.task {
await accountStore.load()
}
}
}
struct SidebarView: View {
@EnvironmentObject var accountStore: AccountStore
@State private var showAccountPicker = false
var body: some View {
VStack(spacing: 0) {
List {
Section("媒体库") {
NavigationLink(destination: VideoListView()) {
Label("视频库", systemImage: "film")
}
}
Section("下载") {
NavigationLink(destination: DownloadTaskListView()) {
Label("下载任务", systemImage: "arrow.down.circle")
}
}
Section("管理") {
NavigationLink(destination: AccountListView()) {
Label("账号配置", systemImage: "key")
}
NavigationLink(destination: LogListView()) {
Label("操作日志", systemImage: "doc.text")
}
}
}
if let account = accountStore.activeAccount {
Divider()
VStack(spacing: 8) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text(account.name)
.font(.headline)
.lineLimit(1)
Spacer()
}
HStack {
Text(account.region)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
if accountStore.accounts.count > 1 {
Menu {
ForEach(accountStore.accounts) { acct in
Button {
Task { await accountStore.switchActive(acct) }
} label: {
HStack {
Text(acct.name)
if acct.id == account.id {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Label("切换账号", systemImage: "arrow.triangle.2.circlepath")
.font(.caption)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(12)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 8)
.padding(.bottom, 8)
}
}
.navigationTitle("火炬VOD管理器")
}
}
@@ -1,34 +0,0 @@
import SwiftUI
import SwiftData
struct DownloadTaskListView: View {
@Query(sort: \DownloadTask.createdAt, order: .reverse) private var tasks: [DownloadTask]
var body: some View {
List(tasks) { task in
HStack {
VStack(alignment: .leading) {
Text(task.title)
.lineLimit(1)
Text("\(task.definition) · \(task.status.rawValue)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if task.status == .downloading {
ProgressView(value: task.progress)
.frame(width: 120)
}
if let path = task.localPath {
Button("打开") {
NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: "")
}
.buttonStyle(.borderless)
}
}
}
}
}
@@ -0,0 +1,342 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXFileReference section */
3DBB047B2FF3BBD70086E8DC /* VODManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VODManager.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
3DBB047D2FF3BBD70086E8DC /* VODManager */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = VODManager;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
3DBB04782FF3BBD70086E8DC /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
3DBB04722FF3BBD70086E8DC = {
isa = PBXGroup;
children = (
3DBB047D2FF3BBD70086E8DC /* VODManager */,
3DBB047C2FF3BBD70086E8DC /* Products */,
);
sourceTree = "<group>";
};
3DBB047C2FF3BBD70086E8DC /* Products */ = {
isa = PBXGroup;
children = (
3DBB047B2FF3BBD70086E8DC /* VODManager.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
3DBB047A2FF3BBD70086E8DC /* VODManager */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3DBB04862FF3BBD80086E8DC /* Build configuration list for PBXNativeTarget "VODManager" */;
buildPhases = (
3DBB04772FF3BBD70086E8DC /* Sources */,
3DBB04782FF3BBD70086E8DC /* Frameworks */,
3DBB04792FF3BBD70086E8DC /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
3DBB047D2FF3BBD70086E8DC /* VODManager */,
);
name = VODManager;
packageProductDependencies = (
);
productName = VODManager;
productReference = 3DBB047B2FF3BBD70086E8DC /* VODManager.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
3DBB04732FF3BBD70086E8DC /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2660;
LastUpgradeCheck = 2660;
TargetAttributes = {
3DBB047A2FF3BBD70086E8DC = {
CreatedOnToolsVersion = 26.6;
};
};
};
buildConfigurationList = 3DBB04762FF3BBD70086E8DC /* Build configuration list for PBXProject "VODManager" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
"zh-Hans",
ug,
);
mainGroup = 3DBB04722FF3BBD70086E8DC;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 3DBB047C2FF3BBD70086E8DC /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
3DBB047A2FF3BBD70086E8DC /* VODManager */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
3DBB04792FF3BBD70086E8DC /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
3DBB04772FF3BBD70086E8DC /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
3DBB04842FF3BBD80086E8DC /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = A4XHABX9V7;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.5;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
3DBB04852FF3BBD80086E8DC /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = A4XHABX9V7;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 26.5;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
3DBB04872FF3BBD80086E8DC /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = VODManager/VODManager.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = A4XHABX9V7;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.meshel.VODManager;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
3DBB04882FF3BBD80086E8DC /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = VODManager/VODManager.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = A4XHABX9V7;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.video";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = cn.meshel.VODManager;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
3DBB04762FF3BBD70086E8DC /* Build configuration list for PBXProject "VODManager" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3DBB04842FF3BBD80086E8DC /* Debug */,
3DBB04852FF3BBD80086E8DC /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
3DBB04862FF3BBD80086E8DC /* Build configuration list for PBXNativeTarget "VODManager" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3DBB04872FF3BBD80086E8DC /* Debug */,
3DBB04882FF3BBD80086E8DC /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 3DBB04732FF3BBD70086E8DC /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 905 KiB

@@ -0,0 +1,68 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "AppIcon.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
File diff suppressed because it is too large Load Diff
@@ -118,9 +118,9 @@ enum FileConflictStrategy: String, CaseIterable, Identifiable {
var label: String {
switch self {
case .skip: return "跳过已存在文件"
case .overwrite: return "覆盖已有文件"
case .rename: return "自动重命名(加序号)"
case .skip: return String(localized: "conflict.skip")
case .overwrite: return String(localized: "conflict.overwrite")
case .rename: return String(localized: "conflict.rename")
}
}
}
@@ -1,3 +1,4 @@
import Combine
import Foundation
import SwiftData
@@ -6,6 +6,7 @@ class DatabaseManager {
static let shared = DatabaseManager()
let container: ModelContainer
let context: ModelContext
private init() {
let schema = Schema([Account.self, OperationLog.self, DownloadTask.self])
@@ -18,28 +19,22 @@ class DatabaseManager {
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}
var context: ModelContext {
ModelContext(container)
context = ModelContext(container)
}
func insert(_ model: any PersistentModel) async {
let ctx = context
ctx.insert(model)
try? ctx.save()
context.insert(model)
try? context.save()
}
func delete(_ model: any PersistentModel) async {
let ctx = context
ctx.delete(model)
try? ctx.save()
context.delete(model)
try? context.save()
}
func save(_ model: any PersistentModel) async {
let ctx = context
ctx.insert(model)
try? ctx.save()
context.insert(model)
try? context.save()
}
func allAccounts() async -> [Account] {
@@ -69,4 +64,21 @@ class DatabaseManager {
let descriptor = FetchDescriptor<DownloadTask>(sortBy: [SortDescriptor(\.createdAt, order: .reverse)])
return (try? context.fetch(descriptor)) ?? []
}
func deleteDownloadTask(_ task: DownloadTask) async {
context.delete(task)
try? context.save()
}
func clearCompletedDownloadTasks() async {
let descriptor = FetchDescriptor<DownloadTask>(
predicate: #Predicate { $0.statusRaw == "completed" || $0.statusRaw == "failed" }
)
if let tasks = try? context.fetch(descriptor) {
for task in tasks {
context.delete(task)
}
try? context.save()
}
}
}
@@ -0,0 +1,222 @@
import Combine
import Foundation
import SwiftData
// MARK: -
private class DownloadHelper: NSObject, URLSessionDownloadDelegate {
private var continuation: CheckedContinuation<URL, Error>?
private let progressHandler: @Sendable (Double) -> Void
private var didComplete = false
init(progressHandler: @escaping @Sendable (Double) -> Void) {
self.progressHandler = progressHandler
}
func download(from url: URL) async throws -> URL {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 300
config.timeoutIntervalForResource = 86400
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
self.continuation = continuation
let task = session.downloadTask(with: url)
task.resume()
}
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL
) {
guard !didComplete else { return }
didComplete = true
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("vod_dl_\(UUID().uuidString)")
do {
try FileManager.default.moveItem(at: location, to: tempURL)
continuation?.resume(returning: tempURL)
} catch {
continuation?.resume(throwing: error)
}
continuation = nil
session.invalidateAndCancel()
}
func urlSession(
_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didWriteData bytesWritten: Int64,
totalBytesWritten: Int64,
totalBytesExpectedToWrite: Int64
) {
guard totalBytesExpectedToWrite > 0 else { return }
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
progressHandler(progress)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
guard !didComplete else { return }
if let error {
didComplete = true
continuation?.resume(throwing: error)
continuation = nil
session.invalidateAndCancel()
}
}
}
// MARK: - DownloadManager
@MainActor
class DownloadManager: ObservableObject {
static let shared = DownloadManager()
@Published var tasks: [DownloadTask] = []
private var session: URLSession!
private init() {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 300
config.timeoutIntervalForResource = 86400
session = URLSession(configuration: config)
}
func loadTasks() async {
tasks = await DatabaseManager.shared.allDownloadTasks()
}
func batchDownload(
items: [(video: VideoItem, playInfo: PlayInfo)],
to directory: URL,
conflictStrategy: FileConflictStrategy = .skip,
maxConcurrent: Int = 2
) async {
await withTaskGroup(of: Void.self) { group in
for item in items {
group.addTask {
_ = await DownloadManager.downloadSingle(
video: item.video,
playInfo: item.playInfo,
to: directory,
conflictStrategy: conflictStrategy
)
}
}
await group.waitForAll()
}
await loadTasks()
}
func cancelAll() async {
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
session.getTasksWithCompletionHandler { _, _, downloadTasks in
downloadTasks.forEach { $0.cancel() }
continuation.resume()
}
}
await loadTasks()
}
// MARK: - MainActor
nonisolated private static func downloadSingle(
video: VideoItem,
playInfo: PlayInfo,
to directory: URL,
conflictStrategy: FileConflictStrategy = .skip
) async -> DownloadTask {
let safeTitle = sanitizeFilename(video.title)
let ext = (playInfo.format ?? "mp4").lowercased()
let baseName = "\(safeTitle)_\(playInfo.definition)"
var name = "\(baseName).\(ext)"
var destination = directory.appendingPathComponent(name)
if FileManager.default.fileExists(atPath: destination.path) {
switch conflictStrategy {
case .skip:
let task = DownloadTask(
videoId: video.videoId,
title: video.title,
definition: playInfo.definition,
sourceURL: playInfo.playURL,
localPath: destination.path,
status: .completed,
progress: 1,
errorMessage: nil,
createdAt: Date()
)
await DatabaseManager.shared.insert(task)
return task
case .overwrite:
try? FileManager.default.removeItem(at: destination)
case .rename:
var counter = 1
while FileManager.default.fileExists(atPath: destination.path) {
name = "\(baseName)_\(counter).\(ext)"
destination = directory.appendingPathComponent(name)
counter += 1
}
}
}
let task = DownloadTask(
videoId: video.videoId,
title: video.title,
definition: playInfo.definition,
sourceURL: playInfo.playURL,
localPath: destination.path,
status: .pending,
progress: 0,
errorMessage: nil,
createdAt: Date()
)
await DatabaseManager.shared.insert(task)
guard let url = URL(string: playInfo.playURL) else {
task.status = .failed
task.errorMessage = "无效的下载链接"
await DatabaseManager.shared.save(task)
return task
}
task.status = .downloading
await DatabaseManager.shared.save(task)
let helper = DownloadHelper { [task] progress in
let clamped = min(max(progress, 0), 1)
Task { @MainActor in
task.progress = clamped
}
}
do {
let tempURL = try await helper.download(from: url)
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.moveItem(at: tempURL, to: destination)
task.status = .completed
task.progress = 1
} catch {
task.status = .failed
task.errorMessage = error.localizedDescription
}
await DatabaseManager.shared.save(task)
return task
}
nonisolated private static func sanitizeFilename(_ filename: String) -> String {
let invalidCharacters = CharacterSet(charactersIn: "/\\?%*|:\"<>")
return filename
.components(separatedBy: invalidCharacters)
.joined(separator: "_")
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "..", with: ".")
}
}
@@ -0,0 +1,44 @@
import SwiftUI
enum AppLanguage: String, CaseIterable, Identifiable {
case zhHans = "zh-Hans"
case en = "en"
case ug = "ug"
var id: String { rawValue }
var displayName: String {
switch self {
case .zhHans: return "简体中文"
case .en: return "English"
case .ug: return "ئۇيغۇرچە"
}
}
var layoutDirection: LayoutDirection {
switch self {
case .ug: return .rightToLeft
default: return .leftToRight
}
}
var locale: Locale {
Locale(identifier: rawValue)
}
}
extension AppLanguage {
static var current: AppLanguage {
let raw = UserDefaults.standard.string(forKey: "appLanguage") ?? ""
return AppLanguage(rawValue: raw) ?? Self.systemDefault
}
static var systemDefault: AppLanguage {
let lang = Locale.current.language.languageCode?.identifier ?? "zh-Hans"
return AppLanguage(rawValue: lang) ?? .zhHans
}
static func setCurrent(_ language: AppLanguage) {
UserDefaults.standard.set(language.rawValue, forKey: "appLanguage")
}
}
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
</dict>
</plist>
@@ -11,7 +11,7 @@ struct VODManagerApp: App {
.frame(minWidth: 1000, minHeight: 700)
}
.windowResizability(.contentSize)
.modelContainer(DatabaseManager.shared.container)
.modelContext(DatabaseManager.shared.context)
.commands {
CommandGroup(replacing: .newItem) { }
}
@@ -12,7 +12,7 @@ struct AccountListView: View {
VStack {
HStack {
Spacer()
Button("添加账号") {
Button("account.add") {
showAddForm = true
}
.buttonStyle(.borderedProminent)
@@ -30,7 +30,7 @@ struct AccountListView: View {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.borderless)
.help("复制错误信息")
.help("error.copy_help")
Spacer()
}
.padding(.horizontal)
@@ -49,22 +49,22 @@ struct AccountListView: View {
Spacer()
if account.isActive {
Text("当前使用")
Text("account.active")
.font(.caption)
.foregroundStyle(.green)
} else {
Button("切换") {
Button("btn.switch") {
Task { await accountStore.switchActive(account) }
}
.buttonStyle(.borderless)
}
Button("编辑") {
Button("btn.edit") {
editingAccount = account
}
.buttonStyle(.borderless)
Button("删除") {
Button("btn.delete") {
Task { await accountStore.deleteAccount(account) }
}
.buttonStyle(.borderless)
@@ -97,37 +97,37 @@ struct AccountFormView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(account == nil ? "添加账号" : "编辑账号")
Text(account == nil ? "account.add_title" : "account.edit_title")
.font(.title2)
VStack(alignment: .leading, spacing: 12) {
Text("名称")
TextField("用于区分不同账号", text: $name)
Text("account.name")
TextField("account.name_placeholder", text: $name)
.textFieldStyle(.roundedBorder)
Text("AccessKey ID")
TextField("如 LTAI...", text: $accessKeyId)
Text("account.access_key_id")
TextField("account.access_key_id_placeholder", text: $accessKeyId)
.textFieldStyle(.roundedBorder)
Text(account == nil ? "AccessKey Secret" : "AccessKey Secret(留空则不修改)")
SecureField("如 8fKx...", text: $accessKeySecret)
Text(account == nil ? "account.access_key_secret" : "account.access_key_secret_edit")
SecureField("account.access_key_secret_placeholder", text: $accessKeySecret)
.textFieldStyle(.roundedBorder)
Text("Region")
TextField("如 cn-shanghai", text: $region)
Text("account.region")
TextField("account.region_placeholder", text: $region)
.textFieldStyle(.roundedBorder)
Text("Endpoint(可选)")
TextField("默认 vod.{region}.aliyuncs.com", text: $endpoint)
Text("account.endpoint")
TextField("account.endpoint_placeholder", text: $endpoint)
.textFieldStyle(.roundedBorder)
}
HStack {
Spacer()
Button("取消") {
Button("btn.cancel") {
dismiss()
}
Button("保存") {
Button("btn.save") {
Task { await save() }
}
.buttonStyle(.borderedProminent)
@@ -0,0 +1,121 @@
import SwiftUI
struct ContentView: View {
@StateObject private var accountStore = AccountStore.shared
@AppStorage("appLanguage") private var appLanguage: String = AppLanguage.systemDefault.rawValue
private var currentLanguage: AppLanguage {
AppLanguage(rawValue: appLanguage) ?? .zhHans
}
var body: some View {
SidebarView()
.environmentObject(accountStore)
.environment(\.locale, currentLanguage.locale)
.environment(\.layoutDirection, currentLanguage.layoutDirection)
.id(appLanguage)
.task {
await accountStore.load()
}
}
}
struct SidebarView: View {
@EnvironmentObject var accountStore: AccountStore
@AppStorage("appLanguage") private var appLanguage: String = AppLanguage.systemDefault.rawValue
var body: some View {
NavigationSplitView {
VStack(spacing: 0) {
List {
Section("sidebar.media") {
NavigationLink(destination: VideoListView()) {
Label("nav.video_library", systemImage: "film")
}
}
Section("sidebar.download") {
NavigationLink(destination: DownloadTaskListView()) {
Label("nav.download_tasks", systemImage: "arrow.down.circle")
}
}
Section("sidebar.manage") {
NavigationLink(destination: AccountListView()) {
Label("nav.account_config", systemImage: "key")
}
NavigationLink(destination: LogListView()) {
Label("nav.operation_logs", systemImage: "doc.text")
}
}
}
Divider()
HStack(spacing: 4) {
Text("🌐")
Picker("", selection: $appLanguage) {
ForEach(AppLanguage.allCases) { lang in
Text(lang.displayName).tag(lang.rawValue)
}
}
.pickerStyle(.menu)
.frame(width: 120)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
if let account = accountStore.activeAccount {
VStack(spacing: 8) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text(account.name)
.font(.headline)
.lineLimit(1)
Spacer()
}
HStack {
Text(account.region)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
}
if accountStore.accounts.count > 1 {
Menu {
ForEach(accountStore.accounts) { acct in
Button {
Task { await accountStore.switchActive(acct) }
} label: {
HStack {
Text(acct.name)
if acct.id == account.id {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Label("account.switch_title", systemImage: "arrow.triangle.2.circlepath")
.font(.caption)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(12)
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10))
.padding(.horizontal, 8)
.padding(.bottom, 8)
}
}
.navigationTitle("app.title")
} detail: {
Text("detail.hint")
.font(.title2)
.foregroundStyle(.secondary)
}
}
}
@@ -9,24 +9,27 @@ struct DownloadPanelView: View {
@State private var downloadDirectory: URL?
@State private var isDownloading = false
@State private var errorMessage: String?
@State private var conflictStrategy: FileConflictStrategy = .skip
@State private var showConflictAlert = false
@State private var conflictingFiles: [String] = []
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("批量下载")
Text("download.title")
.font(.title2)
HStack {
Text("下载目录:")
Text("download.directory")
if let dir = downloadDirectory {
Text(dir.path)
.lineLimit(1)
.truncationMode(.middle)
} else {
Text("未选择")
Text("download.not_selected")
.foregroundStyle(.secondary)
}
Spacer()
Button("选择目录…") {
Button("download.select_directory") {
selectDirectory()
}
}
@@ -38,10 +41,10 @@ struct DownloadPanelView: View {
.lineLimit(1)
Spacer()
if item.playInfos.isEmpty {
Text("无可用播放地址")
Text("download.no_play_url")
.foregroundStyle(.secondary)
} else {
Picker("清晰度", selection: Binding(
Picker("download.definition", selection: Binding(
get: { selectedDefinitions[item.video.videoId] ?? item.playInfos.first?.definition ?? "" },
set: { selectedDefinitions[item.video.videoId] = $0 }
)) {
@@ -66,20 +69,29 @@ struct DownloadPanelView: View {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.borderless)
.help("复制错误信息")
.help("error.copy_help")
Spacer()
}
.padding(.horizontal)
}
HStack {
Picker("download.conflict_strategy", selection: $conflictStrategy) {
ForEach(FileConflictStrategy.allCases) { strategy in
Text(strategy.label).tag(strategy)
}
}
.frame(width: 220)
.disabled(isDownloading)
Spacer()
Button("取消") {
Button("btn.cancel") {
dismiss()
}
.disabled(isDownloading)
Button("开始下载") {
Button("download.start") {
Task { await startDownloads() }
}
.disabled(downloadDirectory == nil || isDownloading)
@@ -97,6 +109,23 @@ struct DownloadPanelView: View {
}
selectedDefinitions = defs
}
.alert("download.conflict_title", isPresented: $showConflictAlert) {
Button("download.conflict_skip") {
conflictStrategy = .skip
executeDownload()
}
Button("download.conflict_overwrite") {
conflictStrategy = .overwrite
executeDownload()
}
Button("download.conflict_rename") {
conflictStrategy = .rename
executeDownload()
}
Button("btn.cancel", role: .cancel) {}
} message: {
Text(verbatim: String(format: String(localized: "download.conflict_message"), conflictingFiles.joined(separator: "\n")))
}
}
private func selectDirectory() {
@@ -104,7 +133,7 @@ struct DownloadPanelView: View {
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = "选择"
panel.prompt = String(localized: "picker.choose")
if panel.runModal() == .OK, let url = panel.url {
downloadDirectory = url
}
@@ -112,8 +141,37 @@ struct DownloadPanelView: View {
private func startDownloads() async {
guard let directory = downloadDirectory else { return }
let tasks = buildTaskList()
guard !tasks.isEmpty else { return }
let conflicts = findConflictingFiles(tasks: tasks, directory: directory)
if !conflicts.isEmpty {
conflictingFiles = conflicts
showConflictAlert = true
return
}
executeDownload()
}
private func executeDownload() {
guard let directory = downloadDirectory else { return }
isDownloading = true
let tasks = buildTaskList()
dismiss()
Task {
await downloadManager.batchDownload(
items: tasks,
to: directory,
conflictStrategy: conflictStrategy
)
}
}
private func buildTaskList() -> [(video: VideoItem, playInfo: PlayInfo)] {
var tasks: [(video: VideoItem, playInfo: PlayInfo)] = []
for item in items {
guard let definition = selectedDefinitions[item.video.videoId],
@@ -122,10 +180,33 @@ struct DownloadPanelView: View {
}
tasks.append((item.video, playInfo))
}
return tasks
}
await downloadManager.batchDownload(items: tasks, to: directory)
isDownloading = false
dismiss()
private func findConflictingFiles(
tasks: [(video: VideoItem, playInfo: PlayInfo)],
directory: URL
) -> [String] {
var conflicts: [String] = []
for (video, playInfo) in tasks {
let safeTitle = sanitizeFilename(video.title)
let ext = (playInfo.format ?? "mp4").lowercased()
let name = "\(safeTitle)_\(playInfo.definition).\(ext)"
let path = directory.appendingPathComponent(name)
if FileManager.default.fileExists(atPath: path.path) {
conflicts.append(name)
}
}
return conflicts
}
private func sanitizeFilename(_ filename: String) -> String {
let invalidCharacters = CharacterSet(charactersIn: "/\\?%*|:\"<>")
return filename
.components(separatedBy: invalidCharacters)
.joined(separator: "_")
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "..", with: ".")
}
private func formatSize(_ bytes: Int64?) -> String {
@@ -0,0 +1,119 @@
import SwiftUI
import SwiftData
struct DownloadTaskListView: View {
@EnvironmentObject var accountStore: AccountStore
@Query(sort: \DownloadTask.createdAt, order: .reverse) private var tasks: [DownloadTask]
var body: some View {
VStack(spacing: 0) {
if tasks.isEmpty {
ContentUnavailableView(
"download_task.empty_title",
systemImage: "arrow.down.circle",
description: Text("download_task.empty_desc")
)
} else {
HStack {
Text(verbatim: String(format: String(localized: "download_task.total"), tasks.count))
.foregroundStyle(.secondary)
Spacer()
Button("download_task.clear_completed") {
Task { await DatabaseManager.shared.clearCompletedDownloadTasks() }
}
.buttonStyle(.bordered)
}
.padding(.horizontal)
.padding(.vertical, 8)
List {
ForEach(tasks) { task in
DownloadTaskRow(task: task)
}
}
}
}
.navigationTitle("nav.download_tasks")
}
}
struct DownloadTaskRow: View {
let task: DownloadTask
@State private var showDeleteConfirm = false
var body: some View {
HStack {
statusIcon
VStack(alignment: .leading) {
Text(task.title)
.font(.headline)
}
Spacer()
statusText
if task.status == .completed {
Button("btn.open") {
if let path = task.localPath {
NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: "")
}
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(.vertical, 4)
.swipeActions(edge: .trailing) {
Button {
showDeleteConfirm = true
} label: {
Label("btn.delete", systemImage: "trash")
}
}
.alert("download_task.delete_confirm_title", isPresented: $showDeleteConfirm) {
Button("btn.cancel", role: .cancel) {}
Button("btn.delete", role: .destructive) {
Task { await DatabaseManager.shared.deleteDownloadTask(task) }
}
} message: {
Text(verbatim: String(format: String(localized: "download_task.delete_confirm_msg"), task.title))
}
}
@ViewBuilder
private var statusIcon: some View {
switch task.status {
case .pending:
Image(systemName: "clock.fill")
.foregroundColor(.gray)
case .downloading:
Image(systemName: "arrow.down.circle.fill")
.foregroundColor(.blue)
case .completed:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
case .failed:
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(.red)
}
}
@ViewBuilder
private var statusText: some View {
switch task.status {
case .pending:
Text("download_task.status.pending")
case .downloading:
ProgressView(value: task.progress)
.frame(width: 80)
case .completed:
Text("download_task.status.completed")
case .failed:
VStack(alignment: .trailing) {
Text("download_task.status.failed")
Text(task.errorMessage ?? "")
.font(.caption2)
.foregroundStyle(.red)
}
}
}
}
@@ -15,7 +15,7 @@ struct LogListView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Text("目标:\(log.targetIds)")
Text(verbatim: String(format: String(localized: "log.target"), log.targetIds))
.font(.caption)
.foregroundStyle(.secondary)
if let details = log.details {
@@ -18,27 +18,33 @@ struct VideoListView: View {
private var pageSize: Int { pageSizes[pageSizeIndex] }
@State private var pageNo = 1
private var totalPages: Int { max(1, (filteredVideos.count + pageSize - 1) / pageSize) }
private var currentPageSelectedCount: Int {
let currentPageIds = Set(displayVideos.map(\.videoId))
return selectedVideos.intersection(currentPageIds).count
}
var body: some View {
if accountStore.activeAccount == nil {
ContentUnavailableView(
"error.no_active_account",
systemImage: "person.slash",
description: Text("account.add")
)
} else {
content
}
}
@ViewBuilder
private var content: some View {
VStack(spacing: 0) {
HStack(spacing: 12) {
TextField("搜索本地缓存标题", text: $keyword)
TextField("video.search_placeholder", text: $keyword)
.textFieldStyle(.roundedBorder)
.frame(minWidth: 200)
.onChange(of: keyword) { _, _ in
applyFilter()
}
Button("搜索") {
Button("btn.search") {
applyFilter()
}
.keyboardShortcut(.return, modifiers: [.command])
Button("重置") {
Button("btn.reset") {
keyword = ""
applyFilter()
}
@@ -51,14 +57,14 @@ struct VideoListView: View {
Image(systemName: "arrow.clockwise")
}
.disabled(isLoading)
.help("从服务器刷新")
.help("video.refresh_help")
Button("批量下载") {
Button("video.batch_download") {
Task { await prepareDownload() }
}
.disabled(selectedVideos.isEmpty || isLoading)
Button("全选") {
Button("video.select_all") {
if selectedVideos.count == filteredVideos.count {
selectedVideos.removeAll()
} else {
@@ -66,7 +72,7 @@ struct VideoListView: View {
}
}
Button("批量删除") {
Button("video.batch_delete") {
Task { await batchDelete() }
}
.disabled(selectedVideos.isEmpty)
@@ -86,7 +92,7 @@ struct VideoListView: View {
Image(systemName: "doc.on.doc")
}
.buttonStyle(.borderless)
.help("复制错误信息")
.help("error.copy_help")
Spacer()
}
.padding(.horizontal)
@@ -118,7 +124,7 @@ struct VideoListView: View {
}
.width(min: 30, ideal: 30, max: 30)
TableColumn("标题") { video in
TableColumn("video.table.title") { video in
HStack {
AsyncImage(url: video.coverURL.flatMap(URL.init)) { image in
image.resizable().scaledToFill()
@@ -134,29 +140,29 @@ struct VideoListView: View {
}
.width(min: 200, ideal: 300)
TableColumn("视频 ID") { video in
TableColumn("video.table.video_id") { video in
Text(video.videoId)
.font(.caption)
.foregroundStyle(.secondary)
}
.width(min: 120, ideal: 180)
TableColumn("时长") { video in
TableColumn("video.table.duration") { video in
Text(formatDuration(video.duration))
}
.width(min: 60, ideal: 80)
TableColumn("大小") { video in
TableColumn("video.table.size") { video in
Text(formatSize(video.size))
}
.width(min: 80, ideal: 100)
TableColumn("状态") { video in
TableColumn("video.table.status") { video in
Text(video.status ?? "-")
}
.width(min: 60, ideal: 80)
TableColumn("创建时间") { video in
TableColumn("video.table.created_at") { video in
Text(video.creationTime ?? "-")
}
.width(min: 120, ideal: 160)
@@ -168,11 +174,11 @@ struct VideoListView: View {
.frame(minHeight: 300)
HStack {
Text("\(filteredVideos.count) 条,已选 \(selectedVideos.count)")
Text(verbatim: String(format: String(localized: "video.total_selected"), filteredVideos.count, selectedVideos.count))
.foregroundStyle(.secondary)
if !selectedVideos.isEmpty {
Button("取消选择") {
Button("video.deselect_all") {
selectedVideos.removeAll()
}
.buttonStyle(.borderless)
@@ -181,7 +187,7 @@ struct VideoListView: View {
Spacer()
Picker("每页", selection: $pageSizeIndex) {
Picker("video.page_size", selection: $pageSizeIndex) {
ForEach(0..<pageSizes.count, id: \.self) { i in
Text("\(pageSizes[i])").tag(i)
}
@@ -192,13 +198,13 @@ struct VideoListView: View {
updateDisplay()
}
Button("上一页") {
Button("video.prev_page") {
pageNo -= 1
updateDisplay()
}
.disabled(pageNo <= 1)
Text("\(pageNo)/\(totalPages)")
Button("下一页") {
Text(verbatim: String(format: String(localized: "video.page_info"), pageNo, totalPages))
Button("video.next_page") {
pageNo += 1
updateDisplay()
}
@@ -207,6 +213,7 @@ struct VideoListView: View {
.padding()
}
.task {
guard accountStore.activeAccount != nil else { return }
if allVideos.isEmpty {
await refreshFromAPI()
}
@@ -217,6 +224,7 @@ struct VideoListView: View {
displayVideos = []
selectedVideos = []
errorMessage = nil
guard newId != nil else { return }
Task { await refreshFromAPI() }
}
.sheet(isPresented: $showDownloadPanel) {
@@ -254,14 +262,16 @@ struct VideoListView: View {
errorMessage = nil
let maxRetries = 3
for attempt in 1...maxRetries {
loadingStatus = attempt == 1 ? "正在加载视频列表..." : "重试中... (第 \(attempt)/\(maxRetries) 次)"
loadingStatus = attempt == 1
? String(localized: "loading.video_list")
: String(format: String(localized: "loading.retry"), attempt, maxRetries)
var collected: [VideoItem] = []
var page = 1
let size = 100
var total = 0
do {
while true {
loadingStatus = "正在加载视频列表... (第 \(page) 页)"
loadingStatus = String(format: String(localized: "loading.video_list_page"), page)
let response = try await VODClient.shared.searchMedia(
pageNo: page,
pageSize: size
@@ -285,7 +295,7 @@ struct VideoListView: View {
let delay = UInt64(attempt) * 1_000_000_000
try? await Task.sleep(nanoseconds: delay)
} else {
errorMessage = "加载失败(已重试 \(maxRetries) 次):\(error.localizedDescription)"
errorMessage = String(format: String(localized: "loading.failed_retry"), maxRetries, error.localizedDescription)
}
}
}
@@ -300,7 +310,7 @@ struct VideoListView: View {
allVideos.removeAll { ids.contains($0.videoId) }
applyFilter()
} catch {
errorMessage = "删除失败:\(error.localizedDescription)"
errorMessage = String(format: String(localized: "loading.delete_failed"), error.localizedDescription)
}
}
@@ -312,11 +322,11 @@ struct VideoListView: View {
var errors: [String] = []
let selected = filteredVideos.filter { selectedVideos.contains($0.videoId) }
for (index, video) in selected.enumerated() {
loadingStatus = "正在获取下载地址 (\(index + 1)/\(selected.count))「\(video.title)」..."
loadingStatus = String(format: String(localized: "loading.fetch_play_url"), index + 1, selected.count, video.title)
let playInfos = await fetchPlayInfoWithRetry(videoId: video.videoId, title: video.title, maxRetries: 3)
results.append((video, playInfos))
if playInfos.isEmpty {
errors.append("\(video.title)」无可用播放地址")
errors.append(String(format: String(localized: "error.no_play_url"), video.title))
}
}
loadingStatus = ""
@@ -336,15 +346,11 @@ struct VideoListView: View {
return response.playInfos
}
if attempt < maxRetries {
print("[Download]「\(title)」第 \(attempt) 次获取到空播放地址,\(attempt)s 后重试...")
try? await Task.sleep(for: .seconds(attempt))
}
} catch {
if attempt < maxRetries {
print("[Download]「\(title)」第 \(attempt) 次获取失败: \(error)\(attempt)s 后重试...")
try? await Task.sleep(for: .seconds(attempt))
} else {
print("[Download]「\(title)\(maxRetries) 次尝试均失败: \(error)")
}
}
}
-9
View File
@@ -1,9 +0,0 @@
PORT=3001
NODE_ENV=development
JWT_SECRET=your-super-secret-jwt-key
JWT_EXPIRES_IN=7d
APP_ENCRYPTION_KEY=your-32-char-encryption-key
DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_PASSWORD=admin
DB_PATH=./data/vod-manager.db
VOD_ENDPOINT_TEMPLATE=vod.{region}.aliyuncs.com
-14
View File
@@ -1,14 +0,0 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"env": {
"node": true,
"es2022": true
},
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }]
}
}
-41
View File
@@ -1,41 +0,0 @@
{
"name": "@vod-manager/server",
"version": "0.1.0",
"description": "Backend server for Aliyun VOD Media Library Manager",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src --ext .ts",
"test": "vitest run"
},
"dependencies": {
"@alicloud/pop-core": "^1.7.13",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"pino": "^9.1.0",
"pino-pretty": "^11.1.0",
"sqlite": "^5.1.1",
"sqlite3": "^5.1.7",
"uuid": "^9.0.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.12.12",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.11.0",
"@typescript-eslint/parser": "^7.11.0",
"eslint": "^8.57.0",
"tsx": "^4.11.0",
"typescript": "^5.4.5",
"vitest": "^1.6.0"
}
}
-22
View File
@@ -1,22 +0,0 @@
import dotenv from 'dotenv';
import path from 'path';
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
export const config = {
port: parseInt(process.env.PORT || '3001', 10),
nodeEnv: process.env.NODE_ENV || 'development',
jwtSecret: process.env.JWT_SECRET || 'vod-manager-dev-secret-change-me',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d',
encryptionKey: process.env.APP_ENCRYPTION_KEY || '',
defaultAdminUsername: process.env.DEFAULT_ADMIN_USERNAME || 'admin',
defaultAdminPassword: process.env.DEFAULT_ADMIN_PASSWORD || 'admin',
dbPath: process.env.DB_PATH || path.resolve(process.cwd(), 'data', 'vod-manager.db'),
vodEndpointTemplate: process.env.VOD_ENDPOINT_TEMPLATE || 'vod.{region}.aliyuncs.com',
};
export function validateConfig(): void {
if (config.nodeEnv === 'production' && config.jwtSecret === 'vod-manager-dev-secret-change-me') {
throw new Error('JWT_SECRET must be set in production');
}
}
-66
View File
@@ -1,66 +0,0 @@
import fs from 'fs';
import path from 'path';
import sqlite3 from 'sqlite3';
import { open, Database } from 'sqlite';
import { config } from './config';
let db: Database<sqlite3.Database, sqlite3.Statement> | null = null;
export async function getDb(): Promise<Database<sqlite3.Database, sqlite3.Statement>> {
if (db) return db;
const dbDir = path.dirname(config.dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
db = await open({
filename: config.dbPath,
driver: sqlite3.Database,
});
await initSchema();
return db;
}
async function initSchema(): Promise<void> {
const database = await getDb();
await database.exec(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
access_key_id TEXT NOT NULL,
access_key_secret TEXT NOT NULL,
region TEXT NOT NULL,
endpoint TEXT,
is_active INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_accounts_active ON accounts(is_active);
CREATE TABLE IF NOT EXISTS operation_logs (
id TEXT PRIMARY KEY,
user_id TEXT,
username TEXT,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_ids TEXT,
details TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_logs_created ON operation_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_logs_action ON operation_logs(action);
`);
}
-55
View File
@@ -1,55 +0,0 @@
import express, { Request, Response } from 'express';
import cors from 'cors';
import path from 'path';
import pino from 'pino';
import { config, validateConfig } from './config';
import { getDb } from './db';
import { ensureDefaultAdmin } from './services/users';
import authRoutes from './routes/auth';
import configRoutes from './routes/config';
import videoRoutes from './routes/videos';
import categoryRoutes from './routes/categories';
import logRoutes from './routes/logs';
const logger = pino({
transport: config.nodeEnv === 'development' ? { target: 'pino-pretty' } : undefined,
});
async function main() {
validateConfig();
await getDb();
await ensureDefaultAdmin();
const app = express();
app.use(cors());
app.use(express.json());
app.use('/api/auth', authRoutes);
app.use('/api/accounts', configRoutes);
app.use('/api/videos', videoRoutes);
app.use('/api/categories', categoryRoutes);
app.use('/api/logs', logRoutes);
// Serve static frontend in production
if (config.nodeEnv === 'production') {
const staticPath = path.resolve(__dirname, '../../web/dist');
app.use(express.static(staticPath));
app.get('*', (_req: Request, res: Response) => {
res.sendFile(path.join(staticPath, 'index.html'));
});
}
app.use((err: Error, _req: Request, res: Response, _next: express.NextFunction) => {
logger.error(err);
res.status(500).json({ code: 500, message: err.message || 'Internal server error' });
});
app.listen(config.port, () => {
logger.info(`Server listening on port ${config.port}`);
});
}
main().catch((err) => {
logger.error(err);
process.exit(1);
});
-45
View File
@@ -1,45 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { config } from '../config';
import { JwtPayload } from '../types';
export interface AuthenticatedRequest extends Request {
user?: JwtPayload;
}
export function authMiddleware(
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ code: 401, message: 'Unauthorized' });
return;
}
const token = authHeader.slice(7);
try {
const decoded = jwt.verify(token, config.jwtSecret) as JwtPayload;
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ code: 401, message: 'Invalid token' });
}
}
export function requireAdmin(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
if (!req.user || req.user.role !== 'admin') {
res.status(403).json({ code: 403, message: 'Forbidden: admin required' });
return;
}
next();
}
export function requireWrite(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
if (!req.user || req.user.role === 'readonly') {
res.status(403).json({ code: 403, message: 'Forbidden: read-only user' });
return;
}
next();
}
-50
View File
@@ -1,50 +0,0 @@
import { Router } from 'express';
import jwt, { SignOptions } from 'jsonwebtoken';
import { config } from '../config';
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth';
import { asyncHandler } from '../utils/asyncHandler';
import { findUserByUsername, verifyPassword } from '../services/users';
const router = Router();
router.post('/login', asyncHandler(async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
res.status(400).json({ code: 400, message: 'Username and password are required' });
return;
}
const user = await findUserByUsername(username);
if (!user || !(await verifyPassword(user, password))) {
res.status(401).json({ code: 401, message: 'Invalid credentials' });
return;
}
const signOptions: SignOptions = { expiresIn: config.jwtExpiresIn as SignOptions['expiresIn'] };
const token = jwt.sign(
{ userId: user.id, username: user.username, role: user.role },
config.jwtSecret,
signOptions
);
res.json({
code: 200,
data: {
token,
user: {
id: user.id,
username: user.username,
role: user.role,
},
},
});
}));
router.get('/me', authMiddleware, (req: AuthenticatedRequest, res) => {
res.json({
code: 200,
data: req.user,
});
});
export default router;
-19
View File
@@ -1,19 +0,0 @@
import { Router } from 'express';
import { authMiddleware } from '../middleware/auth';
import { asyncHandler } from '../utils/asyncHandler';
import { getActiveAccountAndClient } from '../utils/account';
import { getCategories } from '../services/vod';
const router = Router();
router.get(
'/',
authMiddleware,
asyncHandler(async (_req, res) => {
const { client } = await getActiveAccountAndClient();
const categories = await getCategories(client);
res.json({ code: 200, data: categories });
})
);
export default router;
-115
View File
@@ -1,115 +0,0 @@
import { Router } from 'express';
import { z } from 'zod';
import {
listAccounts,
createAccount,
updateAccount,
deleteAccount,
switchActiveAccount,
} from '../services/config';
import { authMiddleware, requireAdmin } from '../middleware/auth';
import { asyncHandler } from '../utils/asyncHandler';
const router = Router();
const accountSchema = z.object({
name: z.string().min(1),
accessKeyId: z.string().min(1),
accessKeySecret: z.string().min(1),
region: z.string().min(1),
endpoint: z.string().optional(),
});
router.get(
'/',
authMiddleware,
asyncHandler(async (_req, res) => {
const accounts = await listAccounts();
res.json({ code: 200, data: accounts });
})
);
router.post(
'/',
authMiddleware,
requireAdmin,
asyncHandler(async (req, res) => {
const parsed = accountSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ code: 400, message: parsed.error.message });
return;
}
const account = await createAccount(parsed.data);
res.json({
code: 200,
data: {
id: account.id,
name: account.name,
accessKeyId: account.accessKeyId,
region: account.region,
endpoint: account.endpoint,
isActive: account.isActive,
createdAt: account.createdAt,
updatedAt: account.updatedAt,
},
});
})
);
router.put(
'/:id',
authMiddleware,
requireAdmin,
asyncHandler(async (req, res) => {
const parsed = accountSchema.partial().safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ code: 400, message: parsed.error.message });
return;
}
const account = await updateAccount(req.params.id, parsed.data);
res.json({
code: 200,
data: {
id: account.id,
name: account.name,
accessKeyId: account.accessKeyId,
region: account.region,
endpoint: account.endpoint,
isActive: account.isActive,
createdAt: account.createdAt,
updatedAt: account.updatedAt,
},
});
})
);
router.delete(
'/:id',
authMiddleware,
requireAdmin,
asyncHandler(async (req, res) => {
await deleteAccount(req.params.id);
res.json({ code: 200, data: null });
})
);
router.post(
'/:id/switch',
authMiddleware,
requireAdmin,
asyncHandler(async (req, res) => {
const account = await switchActiveAccount(req.params.id);
res.json({
code: 200,
data: {
id: account.id,
name: account.name,
isActive: account.isActive,
},
});
})
);
export default router;
-29
View File
@@ -1,29 +0,0 @@
import { Router } from 'express';
import { z } from 'zod';
import { authMiddleware } from '../middleware/auth';
import { asyncHandler } from '../utils/asyncHandler';
import { listLogs } from '../services/logs';
const router = Router();
router.get(
'/',
authMiddleware,
asyncHandler(async (req, res) => {
const schema = z.object({
pageNo: z.coerce.number().optional().default(1),
pageSize: z.coerce.number().max(100).optional().default(20),
action: z.string().optional(),
});
const parsed = schema.safeParse(req.query);
if (!parsed.success) {
res.status(400).json({ code: 400, message: parsed.error.message });
return;
}
const result = await listLogs(parsed.data);
res.json({ code: 200, data: result });
})
);
export default router;
-190
View File
@@ -1,190 +0,0 @@
import { Router } from 'express';
import { z } from 'zod';
import { authMiddleware, AuthenticatedRequest, requireWrite } from '../middleware/auth';
import { asyncHandler } from '../utils/asyncHandler';
import { getActiveAccountAndClient } from '../utils/account';
import {
deleteVideos,
getPlayInfo,
getVideoInfo,
searchMedia,
updateVideoInfo,
} from '../services/vod';
import { createLog } from '../services/logs';
const router = Router();
const searchSchema = z.object({
keyword: z.string().optional(),
cateId: z.coerce.number().optional(),
status: z.string().optional(),
pageNo: z.coerce.number().optional().default(1),
pageSize: z.coerce.number().max(100).optional().default(20),
startTime: z.string().optional(),
endTime: z.string().optional(),
sortBy: z.string().optional(),
sortOrder: z.enum(['Asc', 'Desc']).optional().default('Desc'),
});
router.get(
'/',
authMiddleware,
asyncHandler(async (req, res) => {
const parsed = searchSchema.safeParse(req.query);
if (!parsed.success) {
res.status(400).json({ code: 400, message: parsed.error.message });
return;
}
const { client } = await getActiveAccountAndClient();
const result = await searchMedia(client, parsed.data);
res.json({ code: 200, data: result });
})
);
router.get(
'/:id',
authMiddleware,
asyncHandler(async (req, res) => {
const { client } = await getActiveAccountAndClient();
const video = await getVideoInfo(client, req.params.id);
res.json({ code: 200, data: video });
})
);
router.post(
'/:id/play-info',
authMiddleware,
asyncHandler(async (req, res) => {
const { definition } = req.body || {};
const { client } = await getActiveAccountAndClient();
const playInfo = await getPlayInfo(client, req.params.id, definition);
res.json({ code: 200, data: playInfo });
})
);
router.post(
'/batch-delete',
authMiddleware,
requireWrite,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const schema = z.object({ videoIds: z.array(z.string()).min(1).max(100) });
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ code: 400, message: parsed.error.message });
return;
}
const { client } = await getActiveAccountAndClient();
await deleteVideos(client, parsed.data.videoIds);
await createLog({
userId: req.user?.userId,
username: req.user?.username,
action: 'BATCH_DELETE',
targetType: 'video',
targetIds: parsed.data.videoIds,
details: { count: parsed.data.videoIds.length },
});
res.json({ code: 200, data: { deleted: parsed.data.videoIds.length } });
})
);
router.post(
'/batch-download',
authMiddleware,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const schema = z.object({
videoIds: z.array(z.string()).min(1).max(100),
definition: z.string().optional(),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ code: 400, message: parsed.error.message });
return;
}
const { client } = await getActiveAccountAndClient();
const results: {
videoId: string;
title?: string;
urls: { definition: string; url: string }[];
}[] = [];
for (const videoId of parsed.data.videoIds) {
try {
const [video, playInfo] = await Promise.all([
getVideoInfo(client, videoId),
getPlayInfo(client, videoId, parsed.data.definition),
]);
results.push({
videoId,
title: video.title,
urls: playInfo.map((info) => ({
definition: info.definition,
url: info.playURL,
})),
});
} catch (error) {
results.push({
videoId,
title: undefined,
urls: [],
});
}
}
await createLog({
userId: req.user?.userId,
username: req.user?.username,
action: 'BATCH_DOWNLOAD',
targetType: 'video',
targetIds: parsed.data.videoIds,
details: { count: parsed.data.videoIds.length, definition: parsed.data.definition },
});
res.json({ code: 200, data: results });
})
);
router.post(
'/batch-update',
authMiddleware,
requireWrite,
asyncHandler(async (req: AuthenticatedRequest, res) => {
const schema = z.object({
videoIds: z.array(z.string()).min(1).max(100),
title: z.string().optional(),
description: z.string().optional(),
tags: z.string().optional(),
cateId: z.number().optional(),
});
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ code: 400, message: parsed.error.message });
return;
}
const { videoIds, ...updates } = parsed.data;
const { client } = await getActiveAccountAndClient();
for (const videoId of videoIds) {
await updateVideoInfo(client, videoId, updates);
}
await createLog({
userId: req.user?.userId,
username: req.user?.username,
action: 'BATCH_UPDATE',
targetType: 'video',
targetIds: videoIds,
details: { updates },
});
res.json({ code: 200, data: { updated: videoIds.length } });
})
);
export default router;
-114
View File
@@ -1,114 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../db';
import { Account, AccountInput } from '../types';
import { encrypt } from '../utils/crypto';
const ACCOUNT_COLUMNS =
'id, name, access_key_id as accessKeyId, access_key_secret as accessKeySecret, region, endpoint, is_active as isActive, created_at as createdAt, updated_at as updatedAt';
export async function listAccounts(): Promise<Omit<Account, 'accessKeySecret'>[]> {
const db = await getDb();
const rows = await db.all<Account[]>(`SELECT ${ACCOUNT_COLUMNS} FROM accounts ORDER BY created_at DESC`);
return rows.map((row) => ({
id: row.id,
name: row.name,
accessKeyId: row.accessKeyId,
accessKeySecret: '',
region: row.region,
endpoint: row.endpoint,
isActive: row.isActive,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
}
export async function getAccountById(id: string): Promise<Account | undefined> {
const db = await getDb();
return db.get<Account>(`SELECT ${ACCOUNT_COLUMNS} FROM accounts WHERE id = ?`, id);
}
export async function getActiveAccount(): Promise<Account | undefined> {
const db = await getDb();
return db.get<Account>(`SELECT ${ACCOUNT_COLUMNS} FROM accounts WHERE is_active = 1 LIMIT 1`);
}
export async function createAccount(input: AccountInput): Promise<Account> {
const db = await getDb();
const id = uuidv4();
const now = new Date().toISOString();
await db.run(
`INSERT INTO accounts (id, name, access_key_id, access_key_secret, region, endpoint, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
input.name,
input.accessKeyId,
encrypt(input.accessKeySecret),
input.region,
input.endpoint || null,
0,
now,
now,
]
);
const account = await getAccountById(id);
if (!account) throw new Error('Failed to create account');
return account;
}
export async function updateAccount(id: string, input: Partial<AccountInput>): Promise<Account> {
const db = await getDb();
const account = await getAccountById(id);
if (!account) throw new Error('Account not found');
const updates: string[] = [];
const values: unknown[] = [];
if (input.name !== undefined) {
updates.push('name = ?');
values.push(input.name);
}
if (input.accessKeyId !== undefined) {
updates.push('access_key_id = ?');
values.push(input.accessKeyId);
}
if (input.accessKeySecret !== undefined) {
updates.push('access_key_secret = ?');
values.push(encrypt(input.accessKeySecret));
}
if (input.region !== undefined) {
updates.push('region = ?');
values.push(input.region);
}
if (input.endpoint !== undefined) {
updates.push('endpoint = ?');
values.push(input.endpoint || null);
}
updates.push('updated_at = ?');
values.push(new Date().toISOString());
values.push(id);
await db.run(`UPDATE accounts SET ${updates.join(', ')} WHERE id = ?`, values);
const updated = await getAccountById(id);
if (!updated) throw new Error('Failed to update account');
return updated;
}
export async function deleteAccount(id: string): Promise<void> {
const db = await getDb();
await db.run('DELETE FROM accounts WHERE id = ?', id);
}
export async function switchActiveAccount(id: string): Promise<Account> {
const db = await getDb();
await db.run('UPDATE accounts SET is_active = 0');
await db.run('UPDATE accounts SET is_active = 1 WHERE id = ?', id);
const account = await getAccountById(id);
if (!account) throw new Error('Account not found');
return account;
}
-72
View File
@@ -1,72 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../db';
import { OperationLog } from '../types';
export async function createLog(params: {
userId?: string;
username?: string;
action: string;
targetType: string;
targetIds?: string[];
details?: Record<string, unknown>;
}): Promise<OperationLog> {
const db = await getDb();
const id = uuidv4();
const now = new Date().toISOString();
await db.run(
`INSERT INTO operation_logs (id, user_id, username, action, target_type, target_ids, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
params.userId || null,
params.username || null,
params.action,
params.targetType,
params.targetIds ? params.targetIds.join(',') : null,
params.details ? JSON.stringify(params.details) : null,
now,
]
);
const log = await db.get<OperationLog>(
'SELECT id, user_id as userId, username, action, target_type as targetType, target_ids as targetIds, details, created_at as createdAt FROM operation_logs WHERE id = ?',
id
);
if (!log) throw new Error('Failed to create log');
return log;
}
export async function listLogs(options: {
pageNo?: number;
pageSize?: number;
action?: string;
}): Promise<{ total: number; logs: OperationLog[]; pageNo: number; pageSize: number }> {
const db = await getDb();
const pageNo = options.pageNo || 1;
const pageSize = Math.min(options.pageSize || 20, 100);
let whereClause = '';
const params: unknown[] = [];
if (options.action) {
whereClause = 'WHERE action = ?';
params.push(options.action);
}
const countRow = await db.get<{ total: number }>(
`SELECT COUNT(*) as total FROM operation_logs ${whereClause}`,
params
);
const logs = await db.all<OperationLog[]>(
`SELECT id, user_id as userId, username, action, target_type as targetType, target_ids as targetIds, details, created_at as createdAt FROM operation_logs ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
[...params, pageSize, (pageNo - 1) * pageSize]
);
return {
total: countRow?.total || 0,
pageNo,
pageSize,
logs,
};
}
-48
View File
@@ -1,48 +0,0 @@
import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../db';
import { User } from '../types';
export async function findUserByUsername(username: string): Promise<User | undefined> {
const db = await getDb();
return db.get<User>(
'SELECT id, username, password_hash as passwordHash, role, created_at as createdAt FROM users WHERE username = ?',
username
);
}
export async function createUser(params: {
username: string;
password: string;
role?: 'admin' | 'readonly';
}): Promise<User> {
const db = await getDb();
const id = uuidv4();
const hash = await bcrypt.hash(params.password, 10);
const now = new Date().toISOString();
await db.run(
'INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)',
[id, params.username, hash, params.role || 'admin', now]
);
const user = await findUserByUsername(params.username);
if (!user) throw new Error('Failed to create user');
return user;
}
export async function verifyPassword(user: User, password: string): Promise<boolean> {
return bcrypt.compare(password, user.passwordHash);
}
export async function ensureDefaultAdmin(): Promise<void> {
const db = await getDb();
const count = await db.get<{ total: number }>('SELECT COUNT(*) as total FROM users');
if ((count?.total || 0) === 0) {
await createUser({
username: process.env.DEFAULT_ADMIN_USERNAME || 'admin',
password: process.env.DEFAULT_ADMIN_PASSWORD || 'admin',
role: 'admin',
});
}
}
-170
View File
@@ -1,170 +0,0 @@
import RPCClient from '@alicloud/pop-core';
import { Account, PlayInfo, VideoItem } from '../types';
import { decrypt } from '../utils/crypto';
export interface SearchParams {
keyword?: string;
cateId?: number;
pageNo?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'Asc' | 'Desc';
}
export interface SearchResult {
total: number;
pageNo: number;
pageSize: number;
videos: VideoItem[];
}
export function createVodClient(account: Account): RPCClient {
let endpoint = account.endpoint || `vod.${account.region}.aliyuncs.com`;
if (!endpoint.startsWith('http://') && !endpoint.startsWith('https://')) {
endpoint = `https://${endpoint}`;
}
return new RPCClient({
accessKeyId: account.accessKeyId,
accessKeySecret: decrypt(account.accessKeySecret),
endpoint,
apiVersion: '2017-03-21',
});
}
export async function searchMedia(client: RPCClient, params: SearchParams): Promise<SearchResult> {
const requestParams: Record<string, unknown> = {
PageNo: params.pageNo || 1,
PageSize: Math.min(params.pageSize || 20, 100),
SearchType: 'video',
Fields: 'Title,CoverURL,CreationTime,Status,Duration,Size,CateId,CateName,Tags',
SortBy: params.sortBy || 'CreationTime',
SortOrder: params.sortOrder || 'Desc',
};
const searchParams: string[] = [];
if (params.keyword) {
searchParams.push(`Title='${escapeSearch(params.keyword)}'`);
}
if (params.cateId) {
searchParams.push(`CateId=${params.cateId}`);
}
if (searchParams.length > 0) {
requestParams.Match = searchParams.join(' and ');
}
const action = 'SearchMedia';
const response = (await client.request(action, requestParams, { method: 'POST' })) as Record<
string,
unknown
>;
const mediaListRaw = (response.MediaList as Record<string, unknown>[]) || [];
const mediaList = mediaListRaw.map((item) => {
const video = (item.Video as Record<string, unknown>) || {};
return {
videoId: (video.VideoId || item.MediaId) as string,
title: video.Title as string,
description: video.Description as string,
duration: video.Duration as number,
size: video.Size as number,
coverURL: video.CoverURL as string,
status: video.Status as string,
cateId: video.CateId as number,
cateName: video.CateName as string,
tags: video.Tags as string,
creationTime: (video.CreationTime || item.CreationTime) as string,
modificationTime: video.ModificationTime as string,
};
});
return {
total: (response.Total as number) || 0,
pageNo: params.pageNo || 1,
pageSize: params.pageSize || 20,
videos: mediaList,
};
}
export async function getVideoInfo(client: RPCClient, videoId: string): Promise<VideoItem> {
const response = (await client.request(
'GetVideoInfo',
{ VideoId: videoId },
{ method: 'POST' }
)) as Record<string, unknown>;
const video = response.Video as Record<string, unknown>;
return {
videoId: video.VideoId as string,
title: video.Title as string,
description: video.Description as string,
duration: video.Duration as number,
size: video.Size as number,
coverURL: video.CoverURL as string,
status: video.Status as string,
cateId: video.CateId as number,
cateName: video.CateName as string,
tags: video.Tags as string,
creationTime: video.CreationTime as string,
modificationTime: video.ModificationTime as string,
};
}
export async function getPlayInfo(
client: RPCClient,
videoId: string,
definition?: string
): Promise<PlayInfo[]> {
const params: Record<string, unknown> = { VideoId: videoId };
if (definition) {
params.Definition = definition;
}
const response = (await client.request('GetPlayInfo', params, {
method: 'POST',
})) as Record<string, unknown>;
const playInfoList = (response.PlayInfoList as Record<string, unknown>) || {};
const list = (playInfoList.PlayInfo as Record<string, unknown>[]) || [];
return list.map((item) => ({
playURL: item.PlayURL as string,
definition: item.Definition as string,
duration: item.Duration as string,
size: item.Size as number,
format: item.Format as string,
}));
}
export async function deleteVideos(client: RPCClient, videoIds: string[]): Promise<void> {
await client.request(
'DeleteVideo',
{ VideoIds: videoIds.join(',') },
{ method: 'POST' }
);
}
export async function updateVideoInfo(
client: RPCClient,
videoId: string,
updates: { title?: string; description?: string; tags?: string; cateId?: number }
): Promise<void> {
const params: Record<string, unknown> = { VideoId: videoId };
if (updates.title !== undefined) params.Title = updates.title;
if (updates.description !== undefined) params.Description = updates.description;
if (updates.tags !== undefined) params.Tags = updates.tags;
if (updates.cateId !== undefined) params.CateId = updates.cateId;
await client.request('UpdateVideoInfo', params, { method: 'POST' });
}
export async function getCategories(client: RPCClient): Promise<Record<string, unknown>[]> {
const response = (await client.request('GetCategories', { PageSize: 100 }, {
method: 'POST',
})) as Record<string, unknown>;
const subCategory = (response.SubCategory as Record<string, unknown>) || {};
return (subCategory.SubCategory as Record<string, unknown>[]) || [];
}
function escapeSearch(value: string): string {
return value.replace(/'/g, "\\'");
}
-67
View File
@@ -1,67 +0,0 @@
export interface User {
id: string;
username: string;
passwordHash: string;
role: 'admin' | 'readonly';
createdAt: string;
}
export interface Account {
id: string;
name: string;
accessKeyId: string;
accessKeySecret: string;
region: string;
endpoint?: string;
isActive: number;
createdAt: string;
updatedAt: string;
}
export interface AccountInput {
name: string;
accessKeyId: string;
accessKeySecret: string;
region: string;
endpoint?: string;
}
export interface OperationLog {
id: string;
userId: string | null;
username: string | null;
action: string;
targetType: string;
targetIds: string;
details: string;
createdAt: string;
}
export interface JwtPayload {
userId: string;
username: string;
role: string;
}
export interface VideoItem {
videoId: string;
title: string;
description?: string;
duration?: number;
size?: number;
coverURL?: string;
status?: string;
cateId?: number;
cateName?: string;
tags?: string;
creationTime?: string;
modificationTime?: string;
}
export interface PlayInfo {
playURL: string;
definition: string;
duration: string;
size: number;
format: string;
}
-24
View File
@@ -1,24 +0,0 @@
declare module '@alicloud/pop-core' {
interface RPCClientOptions {
accessKeyId: string;
accessKeySecret: string;
endpoint: string;
apiVersion: string;
}
interface RequestOptions {
method?: 'GET' | 'POST';
timeout?: number;
}
class RPCClient {
constructor(options: RPCClientOptions);
request<T = Record<string, unknown>>(
action: string,
params: Record<string, unknown>,
options?: RequestOptions
): Promise<T>;
}
export default RPCClient;
}
-17
View File
@@ -1,17 +0,0 @@
import { Account } from '../types';
import { createVodClient } from '../services/vod';
import { getActiveAccount } from '../services/config';
import RPCClient from '@alicloud/pop-core';
export async function getActiveAccountAndClient(): Promise<{
account: Account;
client: RPCClient;
}> {
const account = await getActiveAccount();
if (!account) {
throw new Error('No active account configured');
}
const client = createVodClient(account);
return { account, client };
}
-9
View File
@@ -1,9 +0,0 @@
import { Request, Response, NextFunction, RequestHandler } from 'express';
export function asyncHandler(
fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>
): RequestHandler {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
-15
View File
@@ -1,15 +0,0 @@
import { describe, it, expect } from 'vitest';
import { encrypt, decrypt } from './crypto';
describe('crypto utils', () => {
it('should encrypt and decrypt text when key is set', () => {
process.env.APP_ENCRYPTION_KEY = 'my-32-char-encryption-key-12345';
const text = 'super-secret-access-key';
const encrypted = encrypt(text);
expect(encrypted).not.toBe(text);
expect(encrypted).toContain('enc:');
const decrypted = decrypt(encrypted);
expect(decrypted).toBe(text);
});
});
-44
View File
@@ -1,44 +0,0 @@
import crypto from 'crypto';
import { config } from '../config';
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16;
function getKey(): Buffer | null {
if (!config.encryptionKey) return null;
return crypto.scryptSync(config.encryptionKey, 'vod-manager-salt', 32);
}
export function encrypt(text: string): string {
const key = getKey();
if (!key) return `plain:${text}`;
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return `enc:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
}
export function decrypt(text: string): string {
if (text.startsWith('plain:')) return text.slice(6);
if (!text.startsWith('enc:')) return text;
const key = getKey();
if (!key) {
throw new Error('APP_ENCRYPTION_KEY is required to decrypt secrets');
}
const parts = text.split(':');
if (parts.length !== 4) throw new Error('Invalid encrypted text format');
const iv = Buffer.from(parts[1], 'hex');
const authTag = Buffer.from(parts[2], 'hex');
const encrypted = Buffer.from(parts[3], 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
}
-20
View File
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"moduleResolution": "node",
"sourceMap": true,
"declaration": true,
"declarationMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
-19
View File
@@ -1,19 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'off',
},
};
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>阿里云 VOD 媒体库管理器</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-33
View File
@@ -1,33 +0,0 @@
{
"name": "@vod-manager/web",
"version": "0.1.0",
"description": "Web frontend for Aliyun VOD Media Library Manager",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx"
},
"dependencies": {
"antd": "^5.17.4",
"axios": "^1.7.2",
"dayjs": "^1.11.11",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"zustand": "^4.5.2"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.11.0",
"@typescript-eslint/parser": "^7.11.0",
"@vitejs/plugin-react": "^4.3.0",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"typescript": "^5.4.5",
"vite": "^5.2.12"
}
}
-37
View File
@@ -1,37 +0,0 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './store/auth';
import LoginPage from './pages/Login';
import Layout from './components/Layout';
import VideosPage from './pages/Videos';
import AccountsPage from './pages/Accounts';
import LogsPage from './pages/Logs';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((state) => state.token);
return token ? <>{children}</> : <Navigate to="/login" replace />;
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/videos" replace />} />
<Route path="videos" element={<VideosPage />} />
<Route path="accounts" element={<AccountsPage />} />
<Route path="logs" element={<LogsPage />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;
-44
View File
@@ -1,44 +0,0 @@
import { apiClient, ApiResponse } from './client';
export interface Account {
id: string;
name: string;
accessKeyId: string;
region: string;
endpoint?: string;
isActive: number;
createdAt: string;
updatedAt: string;
}
export interface AccountInput {
name: string;
accessKeyId: string;
accessKeySecret: string;
region: string;
endpoint?: string;
}
export async function listAccounts(): Promise<Account[]> {
const res = await apiClient.get<ApiResponse<Account[]>>('/accounts');
return res.data.data;
}
export async function createAccount(params: AccountInput): Promise<Account> {
const res = await apiClient.post<ApiResponse<Account>>('/accounts', params);
return res.data.data;
}
export async function updateAccount(id: string, params: Partial<AccountInput>): Promise<Account> {
const res = await apiClient.put<ApiResponse<Account>>(`/accounts/${id}`, params);
return res.data.data;
}
export async function deleteAccount(id: string): Promise<void> {
await apiClient.delete(`/accounts/${id}`);
}
export async function switchAccount(id: string): Promise<Account> {
const res = await apiClient.post<ApiResponse<Account>>(`/accounts/${id}/switch`);
return res.data.data;
}
-22
View File
@@ -1,22 +0,0 @@
import { apiClient, ApiResponse } from './client';
import { User } from '../store/auth';
export interface LoginParams {
username: string;
password: string;
}
export interface LoginResult {
token: string;
user: User;
}
export async function login(params: LoginParams): Promise<LoginResult> {
const res = await apiClient.post<ApiResponse<LoginResult>>('/auth/login', params);
return res.data.data;
}
export async function getMe(): Promise<User> {
const res = await apiClient.get<ApiResponse<User>>('/auth/me');
return res.data.data;
}
-6
View File
@@ -1,6 +0,0 @@
import { apiClient, ApiResponse } from './client';
export async function listCategories(): Promise<Record<string, unknown>[]> {
const res = await apiClient.get<ApiResponse<Record<string, unknown>[]>>('/categories');
return res.data.data;
}
-34
View File
@@ -1,34 +0,0 @@
import axios from 'axios';
import { useAuthStore } from '../store/auth';
export const apiClient = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
apiClient.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
useAuthStore.getState().clearAuth();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export interface ApiResponse<T> {
code: number;
message?: string;
data: T;
}
-28
View File
@@ -1,28 +0,0 @@
import { apiClient, ApiResponse } from './client';
export interface OperationLog {
id: string;
userId: string | null;
username: string | null;
action: string;
targetType: string;
targetIds: string;
details: string;
createdAt: string;
}
export interface ListLogsResult {
total: number;
pageNo: number;
pageSize: number;
logs: OperationLog[];
}
export async function listLogs(params: {
pageNo?: number;
pageSize?: number;
action?: string;
}): Promise<ListLogsResult> {
const res = await apiClient.get<ApiResponse<ListLogsResult>>('/logs', { params });
return res.data.data;
}
-92
View File
@@ -1,92 +0,0 @@
import { apiClient, ApiResponse } from './client';
export interface VideoItem {
videoId: string;
title: string;
description?: string;
duration?: number;
size?: number;
coverURL?: string;
status?: string;
cateId?: number;
cateName?: string;
tags?: string;
creationTime?: string;
modificationTime?: string;
}
export interface PlayInfo {
playURL: string;
definition: string;
duration: string;
size: number;
format: string;
}
export interface SearchParams {
keyword?: string;
cateId?: number;
pageNo?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: 'Asc' | 'Desc';
}
export interface SearchResult {
total: number;
pageNo: number;
pageSize: number;
videos: VideoItem[];
}
export async function searchVideos(params: SearchParams): Promise<SearchResult> {
const res = await apiClient.get<ApiResponse<SearchResult>>('/videos', { params });
return res.data.data;
}
export async function getVideo(videoId: string): Promise<VideoItem> {
const res = await apiClient.get<ApiResponse<VideoItem>>(`/videos/${videoId}`);
return res.data.data;
}
export async function getPlayInfo(videoId: string, definition?: string): Promise<PlayInfo[]> {
const res = await apiClient.post<ApiResponse<PlayInfo[]>>(`/videos/${videoId}/play-info`, {
definition,
});
return res.data.data;
}
export async function batchDelete(videoIds: string[]): Promise<{ deleted: number }> {
const res = await apiClient.post<ApiResponse<{ deleted: number }>>('/videos/batch-delete', {
videoIds,
});
return res.data.data;
}
export interface DownloadResult {
videoId: string;
title?: string;
urls: { definition: string; url: string }[];
}
export async function batchDownload(
videoIds: string[],
definition?: string
): Promise<DownloadResult[]> {
const res = await apiClient.post<ApiResponse<DownloadResult[]>>('/videos/batch-download', {
videoIds,
definition,
});
return res.data.data;
}
export async function batchUpdate(
videoIds: string[],
updates: { title?: string; description?: string; tags?: string; cateId?: number }
): Promise<{ updated: number }> {
const res = await apiClient.post<ApiResponse<{ updated: number }>>('/videos/batch-update', {
videoIds,
...updates,
});
return res.data.data;
}
-103
View File
@@ -1,103 +0,0 @@
import { Layout as AntLayout, Menu, Dropdown, Space, Button, theme } from 'antd';
import {
VideoCameraOutlined,
SettingOutlined,
FileTextOutlined,
UserOutlined,
LogoutOutlined,
} from '@ant-design/icons';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/auth';
const { Header, Sider, Content, Footer } = AntLayout;
export default function Layout() {
const location = useLocation();
const navigate = useNavigate();
const { user, clearAuth } = useAuthStore();
const {
token: { colorBgContainer, borderRadiusLG },
} = theme.useToken();
const menuItems = [
{ key: '/videos', icon: <VideoCameraOutlined />, label: '视频库' },
{ key: '/accounts', icon: <SettingOutlined />, label: '账号配置' },
{ key: '/logs', icon: <FileTextOutlined />, label: '操作日志' },
];
const handleMenuClick = ({ key }: { key: string }) => {
navigate(key);
};
const handleLogout = () => {
clearAuth();
navigate('/login');
};
const userMenuItems = [
{ key: 'user', label: user?.username, disabled: true },
{ key: 'role', label: `角色: ${user?.role === 'admin' ? '管理员' : '只读'}`, disabled: true },
{ type: 'divider' as const },
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: handleLogout },
];
return (
<AntLayout style={{ minHeight: '100vh' }}>
<Sider trigger={null} collapsible theme="light">
<div
style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 'bold',
fontSize: 16,
}}
>
VOD管理器
</div>
<Menu
mode="inline"
selectedKeys={[location.pathname]}
items={menuItems}
onClick={handleMenuClick}
/>
</Sider>
<AntLayout>
<Header style={{ padding: '0 24px', background: colorBgContainer }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', height: '100%' }}>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text">
<Space>
<UserOutlined />
{user?.username}
</Space>
</Button>
</Dropdown>
</div>
</Header>
<Content
style={{
margin: 24,
padding: 24,
background: colorBgContainer,
borderRadius: borderRadiusLG,
minHeight: 280,
}}
>
<Outlet />
</Content>
<Footer
style={{
textAlign: 'center',
background: colorBgContainer,
color: '#888',
fontSize: 13,
}}
>
© {new Date().getFullYear()} meshel.cn · VOD管理器 · MIT
</Footer>
</AntLayout>
</AntLayout>
);
}
-10
View File
@@ -1,10 +0,0 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif;
background-color: #f0f2f5;
}
#root {
min-height: 100vh;
}
-14
View File
@@ -1,14 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
</React.StrictMode>
);
-205
View File
@@ -1,205 +0,0 @@
import { useEffect, useState } from 'react';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
message,
Tag,
Popconfirm,
} from 'antd';
import { PlusOutlined, EditOutlined } from '@ant-design/icons';
import {
listAccounts,
createAccount,
updateAccount,
deleteAccount,
switchAccount,
Account,
AccountInput,
} from '../api/accounts';
export default function AccountsPage() {
const [accounts, setAccounts] = useState<Account[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
const [form] = Form.useForm();
const fetchAccounts = async () => {
setLoading(true);
try {
const data = await listAccounts();
setAccounts(data);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAccounts();
}, []);
const openCreateModal = () => {
setEditingAccount(null);
form.resetFields();
setModalOpen(true);
};
const openEditModal = (record: Account) => {
setEditingAccount(record);
form.setFieldsValue({
name: record.name,
accessKeyId: record.accessKeyId,
region: record.region,
endpoint: record.endpoint,
});
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditingAccount(null);
form.resetFields();
};
const handleSubmit = async (values: AccountInput) => {
try {
if (editingAccount) {
const payload: Partial<AccountInput> = { ...values };
if (!payload.accessKeySecret) {
delete payload.accessKeySecret;
}
await updateAccount(editingAccount.id, payload);
message.success('账号更新成功');
} else {
await createAccount(values);
message.success('账号添加成功');
}
closeModal();
fetchAccounts();
} catch (error) {
message.error(editingAccount ? '更新失败' : '添加失败');
}
};
const handleDelete = async (id: string) => {
try {
await deleteAccount(id);
message.success('已删除');
fetchAccounts();
} catch (error) {
message.error('删除失败');
}
};
const handleSwitch = async (id: string) => {
try {
await switchAccount(id);
message.success('已切换当前账号');
fetchAccounts();
} catch (error) {
message.error('切换失败');
}
};
const columns = [
{ title: '名称', dataIndex: 'name' },
{ title: 'AccessKey ID', dataIndex: 'accessKeyId', ellipsis: true },
{ title: 'Region', dataIndex: 'region' },
{
title: '状态',
dataIndex: 'isActive',
render: (isActive: number) =>
isActive ? <Tag color="green">使</Tag> : <Tag></Tag>,
},
{
title: '操作',
key: 'action',
render: (_: unknown, record: Account) => (
<Space>
{!record.isActive && (
<Button type="link" onClick={() => handleSwitch(record.id)}>
</Button>
)}
<Button type="link" icon={<EditOutlined />} onClick={() => openEditModal(record)}>
</Button>
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
const modalTitle = editingAccount ? '编辑阿里云 VOD 账号' : '添加阿里云 VOD 账号';
return (
<div>
<div style={{ marginBottom: 16 }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
</Button>
</div>
<Table rowKey="id" columns={columns} dataSource={accounts} loading={loading} />
<Modal
title={modalTitle}
open={modalOpen}
onCancel={closeModal}
footer={null}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item
label="名称"
name="name"
rules={[{ required: true, message: '请输入名称' }]}
>
<Input placeholder="用于区分不同账号" />
</Form.Item>
<Form.Item
label="AccessKey ID"
name="accessKeyId"
rules={[{ required: true, message: '请输入 AccessKey ID' }]}
>
<Input />
</Form.Item>
<Form.Item
label={editingAccount ? 'AccessKey Secret(留空则不修改)' : 'AccessKey Secret'}
name="accessKeySecret"
rules={[
{
required: !editingAccount,
message: '请输入 AccessKey Secret',
},
]}
>
<Input.Password />
</Form.Item>
<Form.Item
label="Region"
name="region"
rules={[{ required: true, message: '请输入 Region' }]}
>
<Input placeholder="如 cn-shanghai" />
</Form.Item>
<Form.Item label="Endpoint(可选)" name="endpoint">
<Input placeholder="默认 vod.{region}.aliyuncs.com" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form.Item>
</Form>
</Modal>
</div>
);
}
-111
View File
@@ -1,111 +0,0 @@
import { useState } from 'react';
import { Form, Input, Button, Card, message, Typography, Space } from 'antd';
import { VideoCameraOutlined, LockOutlined, UserOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/auth';
import { login } from '../api/auth';
const { Title, Text } = Typography;
export default function LoginPage() {
const navigate = useNavigate();
const setAuth = useAuthStore((state) => state.setAuth);
const [loading, setLoading] = useState(false);
const handleSubmit = async (values: { username: string; password: string }) => {
setLoading(true);
try {
const result = await login(values);
setAuth(result.token, result.user);
message.success('登录成功');
navigate('/videos');
} catch (error) {
message.error('登录失败,请检查用户名和密码');
} finally {
setLoading(false);
}
};
return (
<div
style={{
height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #1677ff 0%, #0958d9 100%)',
}}
>
<Card
style={{
width: 420,
borderRadius: 16,
boxShadow: '0 20px 50px rgba(0, 0, 0, 0.15)',
overflow: 'hidden',
}}
bodyStyle={{ padding: '40px 32px' }}
>
<Space direction="vertical" align="center" style={{ width: '100%', marginBottom: 32 }}>
<div
style={{
width: 64,
height: 64,
borderRadius: '50%',
background: 'linear-gradient(135deg, #1677ff 0%, #0958d9 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<VideoCameraOutlined style={{ fontSize: 32, color: '#fff' }} />
</div>
<Title level={3} style={{ margin: 0 }}>VOD管理器</Title>
<Text type="secondary"></Text>
</Space>
<Form name="login" onFinish={handleSubmit} autoComplete="off" layout="vertical">
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined />}
placeholder="用户名"
size="large"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码"
size="large"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 12 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
size="large"
block
style={{ borderRadius: 8 }}
>
</Button>
</Form.Item>
<div style={{ textAlign: 'center' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
admin / admin
</Text>
</div>
</Form>
</Card>
</div>
);
}
-87
View File
@@ -1,87 +0,0 @@
import { useEffect, useState } from 'react';
import { Table, Tag } from 'antd';
import dayjs from 'dayjs';
import { listLogs, OperationLog } from '../api/logs';
const actionMap: Record<string, string> = {
BATCH_DELETE: '批量删除',
BATCH_DOWNLOAD: '批量下载',
BATCH_UPDATE: '批量更新',
};
const actionColor: Record<string, string> = {
BATCH_DELETE: 'red',
BATCH_DOWNLOAD: 'blue',
BATCH_UPDATE: 'green',
};
export default function LogsPage() {
const [logs, setLogs] = useState<OperationLog[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [pageNo, setPageNo] = useState(1);
const [pageSize, setPageSize] = useState(10);
const fetchLogs = async (page: number, size: number) => {
setLoading(true);
try {
const result = await listLogs({ pageNo: page, pageSize: size });
setLogs(result.logs);
setTotal(result.total);
setPageNo(result.pageNo);
setPageSize(result.pageSize);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchLogs(1, 20);
}, []);
const columns = [
{
title: '时间',
dataIndex: 'createdAt',
width: 180,
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss'),
},
{ title: '用户', dataIndex: 'username', width: 120 },
{
title: '操作',
dataIndex: 'action',
width: 120,
render: (action: string) => <Tag color={actionColor[action] || 'default'}>{actionMap[action] || action}</Tag>,
},
{ title: '目标类型', dataIndex: 'targetType', width: 100 },
{
title: '目标 ID',
dataIndex: 'targetIds',
ellipsis: true,
render: (ids: string | null) => ids || '-',
},
{
title: '详情',
dataIndex: 'details',
ellipsis: true,
render: (details: string | null) => (details ? JSON.stringify(JSON.parse(details)) : '-'),
},
];
return (
<Table
rowKey="id"
columns={columns}
dataSource={logs}
loading={loading}
pagination={{
current: pageNo,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (page, size) => fetchLogs(page, size || 20),
}}
/>
);
}
-458
View File
@@ -1,458 +0,0 @@
import { useEffect, useState } from 'react';
import {
Table,
Input,
Button,
Space,
Form,
Select,
message,
Modal,
Drawer,
Tag,
Image,
Popconfirm,
Typography,
Tabs,
} from 'antd';
import { DownloadOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { searchVideos, batchDelete, batchDownload, VideoItem, DownloadResult } from '../api/videos';
import { listCategories } from '../api/categories';
import { useAuthStore } from '../store/auth';
const { Text } = Typography;
const formatDuration = (seconds?: number) => {
if (!seconds) return '-';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
};
const formatSize = (bytes?: number) => {
if (!bytes) return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let size = bytes;
while (size >= 1024 && i < units.length - 1) {
size /= 1024;
i++;
}
return `${size.toFixed(2)} ${units[i]}`;
};
const getExtFromUrl = (url: string): string => {
try {
const pathname = new URL(url).pathname;
const ext = pathname.split('.').pop();
return ext && ext.length <= 5 ? ext : 'mp4';
} catch {
return 'mp4';
}
};
export default function VideosPage() {
const user = useAuthStore((state) => state.user);
const [videos, setVideos] = useState<VideoItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedRows, setSelectedRows] = useState<VideoItem[]>([]);
const [total, setTotal] = useState(0);
const [pageNo, setPageNo] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [categories, setCategories] = useState<Record<string, unknown>[]>([]);
const [detail, setDetail] = useState<VideoItem | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [form] = Form.useForm();
const fetchVideos = async (params?: Record<string, unknown>) => {
setLoading(true);
try {
const values = form.getFieldsValue();
const query: Record<string, unknown> = {
pageNo,
pageSize,
...params,
};
if (values.keyword) query.keyword = values.keyword;
if (values.cateId) query.cateId = values.cateId;
const result = await searchVideos(query);
setVideos(result.videos);
setTotal(result.total);
setPageNo(result.pageNo);
setPageSize(result.pageSize);
} finally {
setLoading(false);
}
};
const fetchCategories = async () => {
try {
const data = await listCategories();
setCategories(data);
} catch {
// ignore
}
};
useEffect(() => {
fetchVideos();
fetchCategories();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSearch = () => {
setPageNo(1);
fetchVideos({ pageNo: 1 });
};
const handleReset = () => {
form.resetFields();
setPageNo(1);
fetchVideos({ pageNo: 1 });
};
const handleBatchDelete = async () => {
const ids = selectedRows.map((v) => v.videoId);
try {
await batchDelete(ids);
message.success(`已删除 ${ids.length} 个视频`);
setSelectedRows([]);
fetchVideos();
} catch (error) {
message.error('删除失败');
}
};
const [downloadResults, setDownloadResults] = useState<DownloadResult[]>([]);
const [downloadModalOpen, setDownloadModalOpen] = useState(false);
const allDownloadUrls = downloadResults.flatMap((item) =>
item.urls.map((u) => u.url)
);
const wgetCommand = allDownloadUrls.map((url) => `wget "${url}"`).join('\n');
const aria2Command = allDownloadUrls.map((url) => `aria2c "${url}"`).join('\n');
const copyAllUrls = () => {
navigator.clipboard.writeText(allDownloadUrls.join('\n')).then(() => {
message.success('已复制全部链接');
});
};
const startBrowserDownloads = () => {
let count = 0;
downloadResults.forEach((item) => {
item.urls.forEach((urlInfo) => {
const link = document.createElement('a');
link.href = urlInfo.url;
link.target = '_blank';
link.rel = 'noreferrer';
link.download = `${item.title || item.videoId}_${urlInfo.definition}.${getExtFromUrl(urlInfo.url)}`;
document.body.appendChild(link);
setTimeout(() => {
link.click();
document.body.removeChild(link);
}, count * 300);
count++;
});
});
message.success(`已尝试触发 ${count} 个下载任务`);
};
const handleBatchDownload = async () => {
const ids = selectedRows.map((v) => v.videoId);
try {
const results = await batchDownload(ids);
const valid = results.filter((r) => r.urls.length > 0);
setDownloadResults(valid);
setDownloadModalOpen(true);
setSelectedRows([]);
} catch (error) {
message.error('获取下载地址失败');
}
};
const handleRowClick = (record: VideoItem) => {
setDetail(record);
setDrawerOpen(true);
};
const columns = [
{
title: '封面',
dataIndex: 'coverURL',
width: 80,
render: (url?: string) =>
url ? <Image src={url} width={60} height={40} style={{ objectFit: 'cover' }} /> : '-',
},
{ title: '视频 ID', dataIndex: 'videoId', width: 180, ellipsis: true },
{ title: '标题', dataIndex: 'title', ellipsis: true },
{
title: '分类',
dataIndex: 'cateName',
width: 120,
render: (name?: string) => name || '-',
},
{
title: '时长',
dataIndex: 'duration',
width: 100,
render: (duration?: number) => formatDuration(duration),
},
{
title: '大小',
dataIndex: 'size',
width: 120,
render: (size?: number) => formatSize(size),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (status?: string) => <Tag>{status || '-'}</Tag>,
},
{
title: '创建时间',
dataIndex: 'creationTime',
width: 180,
render: (time?: string) => (time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-'),
},
{
title: '操作',
key: 'action',
width: 100,
render: (_: unknown, record: VideoItem) => (
<Button icon={<EyeOutlined />} type="link" onClick={() => handleRowClick(record)}>
</Button>
),
},
];
return (
<div>
<Form form={form} layout="inline" style={{ marginBottom: 16 }}>
<Form.Item name="keyword">
<Input placeholder="搜索标题 / 视频 ID" allowClear />
</Form.Item>
<Form.Item name="cateId">
<Select placeholder="选择分类" allowClear style={{ width: 160 }}>
{categories.map((cat: Record<string, unknown>) => (
<Select.Option key={String(cat.CateId)} value={cat.CateId as number}>
{String(cat.CateName)}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item>
<Button type="primary" onClick={handleSearch}>
</Button>
</Form.Item>
<Form.Item>
<Button onClick={handleReset}></Button>
</Form.Item>
</Form>
<Space style={{ marginBottom: 16 }}>
<Button
type="primary"
icon={<DownloadOutlined />}
disabled={selectedRows.length === 0}
onClick={handleBatchDownload}
>
({selectedRows.length})
</Button>
{user?.role === 'admin' && (
<Popconfirm
title="确认删除"
description={`确定要删除选中的 ${selectedRows.length} 个视频吗?删除后不可恢复。`}
onConfirm={handleBatchDelete}
disabled={selectedRows.length === 0}
>
<Button
danger
icon={<DeleteOutlined />}
disabled={selectedRows.length === 0}
>
({selectedRows.length})
</Button>
</Popconfirm>
)}
</Space>
<Table
rowKey="videoId"
columns={columns}
dataSource={videos}
loading={loading}
pagination={{
current: pageNo,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (page, size) => {
setPageNo(page);
setPageSize(size || 20);
fetchVideos({ pageNo: page, pageSize: size });
},
}}
rowSelection={{
preserveSelectedRowKeys: true,
selectedRowKeys: selectedRows.map((v) => v.videoId),
onChange: (_keys, rows) => setSelectedRows(rows),
}}
/>
<Modal
title="批量下载"
open={downloadModalOpen}
width={820}
onCancel={() => setDownloadModalOpen(false)}
footer={[
<Button key="close" onClick={() => setDownloadModalOpen(false)}>
</Button>,
]}
>
<Tabs
items={[
{
key: 'browser',
label: '浏览器下载',
children: (
<Space direction="vertical" style={{ width: '100%' }}>
<Button type="primary" onClick={startBrowserDownloads}>
</Button>
<Text type="secondary">
300ms使
</Text>
<div style={{ maxHeight: 320, overflow: 'auto' }}>
{downloadResults.map((item) => (
<div key={item.videoId} style={{ marginBottom: 12 }}>
<Text strong>{item.title || item.videoId}</Text>
<ul style={{ margin: 4, paddingLeft: 20 }}>
{item.urls.map((url, idx) => (
<li key={idx}>
<Text copyable>{url.url}</Text>
<Tag style={{ marginLeft: 8 }}>{url.definition}</Tag>
</li>
))}
</ul>
</div>
))}
</div>
</Space>
),
},
{
key: 'tools',
label: '下载工具',
children: (
<Space direction="vertical" style={{ width: '100%' }}>
<Button onClick={copyAllUrls}></Button>
<div style={{ background: '#f6f8fa', padding: 16, borderRadius: 8 }}>
<Text strong></Text>
<ul>
<li>
<Text>
使 <Text code>aria2</Text> {' '}
<Text code>wget</Text>
</Text>
</li>
<li>
<Text>aria2 线</Text>
</li>
</ul>
<Text strong>aria2 </Text>
<pre
style={{
background: '#1e1e1e',
color: '#d4d4d4',
padding: 12,
borderRadius: 4,
overflow: 'auto',
fontSize: 12,
}}
>
{aria2Command || '# 暂无下载链接'}
</pre>
<Text strong>wget </Text>
<pre
style={{
background: '#1e1e1e',
color: '#d4d4d4',
padding: 12,
borderRadius: 4,
overflow: 'auto',
fontSize: 12,
}}
>
{wgetCommand || '# 暂无下载链接'}
</pre>
</div>
</Space>
),
},
]}
/>
</Modal>
<Drawer
title="视频详情"
width={560}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
{detail && (
<Space direction="vertical" style={{ width: '100%' }}>
{detail.coverURL && <Image src={detail.coverURL} style={{ width: '100%' }} />}
<p>
<Text strong> ID</Text>
<Text copyable>{detail.videoId}</Text>
</p>
<p>
<Text strong></Text>
{detail.title}
</p>
<p>
<Text strong></Text>
{detail.description || '-'}
</p>
<p>
<Text strong></Text>
{detail.cateName || '-'}
</p>
<p>
<Text strong></Text>
{detail.tags || '-'}
</p>
<p>
<Text strong></Text>
{formatDuration(detail.duration)}
</p>
<p>
<Text strong></Text>
{formatSize(detail.size)}
</p>
<p>
<Text strong></Text>
<Tag>{detail.status || '-'}</Tag>
</p>
<p>
<Text strong></Text>
{detail.creationTime
? dayjs(detail.creationTime).format('YYYY-MM-DD HH:mm:ss')
: '-'}
</p>
</Space>
)}
</Drawer>
</div>
);
}
-27
View File
@@ -1,27 +0,0 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface User {
id: string;
username: string;
role: 'admin' | 'readonly';
}
interface AuthState {
token: string | null;
user: User | null;
setAuth: (token: string, user: User) => void;
clearAuth: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
setAuth: (token, user) => set({ token, user }),
clearAuth: () => set({ token: null, user: null }),
}),
{ name: 'vod-auth' }
)
);
-21
View File
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
-10
View File
@@ -1,10 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
-18
View File
@@ -1,18 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
},
});