迁移: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:
@@ -1,13 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
.env
|
||||
.env.*
|
||||
.git/
|
||||
.github/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.md
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage/
|
||||
@@ -1,31 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
lint-and-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Test server
|
||||
run: npm run test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -1,7 +0,0 @@
|
||||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
/*.xcodeproj
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/
|
||||
@@ -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"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# 火炬 VOD 管理器 - macOS 原生版
|
||||
|
||||
基于 SwiftUI + Swift Package Manager 的阿里云 VOD 桌面管理客户端。
|
||||
|
||||
## 特性
|
||||
|
||||
- 原生 macOS SwiftUI 界面
|
||||
- 直接调用阿里云 VOD OpenAPI,无需 Node 后端
|
||||
- 多账号管理、切换
|
||||
- 视频列表、搜索、详情
|
||||
- 批量下载到用户指定目录
|
||||
- 自定义下载文件名(自动清理非法字符)
|
||||
- 下载任务历史
|
||||
- 操作日志
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Swift 6 + SwiftUI
|
||||
- GRDB(SQLite)
|
||||
- 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 */;
|
||||
}
|
||||
+7
@@ -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
+3
-3
@@ -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
@@ -1,3 +1,4 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
+25
-13
@@ -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>
|
||||
+1
-1
@@ -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) { }
|
||||
}
|
||||
+19
-19
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
-13
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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 {
|
||||
+46
-40
@@ -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 {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
TextField("搜索本地缓存标题", text: $keyword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(minWidth: 200)
|
||||
.onChange(of: keyword) { _, _ in
|
||||
applyFilter()
|
||||
if accountStore.activeAccount == nil {
|
||||
ContentUnavailableView(
|
||||
"error.no_active_account",
|
||||
systemImage: "person.slash",
|
||||
description: Text("account.add")
|
||||
)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
Button("搜索") {
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 12) {
|
||||
TextField("video.search_placeholder", text: $keyword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(minWidth: 200)
|
||||
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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": "^_" }]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
`);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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, "\\'");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
-24
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' }
|
||||
)
|
||||
);
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
JWT_SECRET=your-random-secret-key-min-32-chars
|
||||
APP_ENCRYPTION_KEY=your-32-char-encryption-key
|
||||
DEFAULT_ADMIN_USERNAME=admin
|
||||
DEFAULT_ADMIN_PASSWORD=admin
|
||||
@@ -1,33 +0,0 @@
|
||||
# Stage 1: Build web
|
||||
FROM node:20-alpine AS web-builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
COPY apps/web/package*.json ./apps/web/
|
||||
COPY apps/server/package*.json ./apps/server/
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build -w apps/web
|
||||
|
||||
# Stage 2: Build server
|
||||
FROM node:20-alpine AS server-builder
|
||||
WORKDIR /app
|
||||
COPY --from=web-builder /app/node_modules ./node_modules
|
||||
COPY --from=web-builder /app/package*.json ./
|
||||
COPY --from=web-builder /app/apps/web ./apps/web
|
||||
COPY --from=web-builder /app/apps/server ./apps/server
|
||||
RUN npm run build -w apps/server
|
||||
|
||||
# Stage 3: Production
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=server-builder /app/node_modules ./node_modules
|
||||
COPY --from=server-builder /app/package*.json ./
|
||||
COPY --from=server-builder /app/apps/server/package*.json ./apps/server/
|
||||
COPY --from=server-builder /app/apps/server/dist ./apps/server/dist
|
||||
COPY --from=server-builder /app/apps/web/dist ./apps/web/dist
|
||||
|
||||
EXPOSE 3001
|
||||
VOLUME ["/app/apps/server/data"]
|
||||
|
||||
CMD ["node", "apps/server/dist/index.js"]
|
||||
@@ -1,22 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
vod-manager:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: aliyun-vod-manager
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
- PORT=3001
|
||||
- NODE_ENV=production
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- JWT_EXPIRES_IN=7d
|
||||
- APP_ENCRYPTION_KEY=${APP_ENCRYPTION_KEY:-}
|
||||
- DEFAULT_ADMIN_USERNAME=${DEFAULT_ADMIN_USERNAME:-admin}
|
||||
- DEFAULT_ADMIN_PASSWORD=${DEFAULT_ADMIN_PASSWORD:-admin}
|
||||
- DB_PATH=/app/apps/server/data/vod-manager.db
|
||||
volumes:
|
||||
- ./data:/app/apps/server/data
|
||||
restart: unless-stopped
|
||||
@@ -1,89 +0,0 @@
|
||||
# 部署文档
|
||||
|
||||
## Docker Compose 部署
|
||||
|
||||
### 1. 准备环境
|
||||
|
||||
确保已安装 Docker 与 Docker Compose。
|
||||
|
||||
### 2. 下载并配置
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-org/Aliyun-VOD-Media-Library-Manager.git
|
||||
cd Aliyun-VOD-Media-Library-Manager/docker
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
编辑 `.env`:
|
||||
|
||||
```env
|
||||
JWT_SECRET=your-random-secret-key-min-32-chars
|
||||
APP_ENCRYPTION_KEY=your-32-char-encryption-key
|
||||
DEFAULT_ADMIN_USERNAME=admin
|
||||
DEFAULT_ADMIN_PASSWORD=your-strong-password
|
||||
```
|
||||
|
||||
### 3. 启动
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 4. 访问
|
||||
|
||||
打开浏览器访问 `http://<服务器IP>:3001`。
|
||||
|
||||
### 5. 更新
|
||||
|
||||
```bash
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 源码部署
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js >= 20
|
||||
- npm >= 10
|
||||
|
||||
### 步骤
|
||||
|
||||
```bash
|
||||
npm install
|
||||
|
||||
# 后端
|
||||
npm run dev -w apps/server
|
||||
|
||||
# 前端(新终端)
|
||||
npm run dev -w apps/web
|
||||
```
|
||||
|
||||
生产构建:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run start -w apps/server
|
||||
```
|
||||
|
||||
## Nginx 反向代理(可选)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name vod.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
建议配合 Let's Encrypt 开启 HTTPS。
|
||||
|
||||
## 数据备份
|
||||
|
||||
SQLite 数据文件默认位于 `apps/server/data/vod-manager.db`,Docker 部署时通过 volume 映射到 `./data`。建议定期备份该文件。
|
||||
Generated
-9244
File diff suppressed because it is too large
Load Diff
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"name": "aliyun-vod-media-library-manager",
|
||||
"version": "0.1.0",
|
||||
"description": "A lightweight, self-hosted management console for Alibaba Cloud VOD",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev -w apps/server\" \"npm run dev -w apps/web\"",
|
||||
"build": "npm run build -w apps/web && npm run build -w apps/server",
|
||||
"lint": "npm run lint -w apps/server && npm run lint -w apps/web",
|
||||
"test": "npm run test -w apps/server"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
Reference in New Issue
Block a user