feat: align motrix-style download UI/actions and stabilize aria2 ops

This commit is contained in:
tongki078
2026-02-24 12:00:30 +09:00
parent 845d5ca65c
commit 552f27c002
29 changed files with 2164 additions and 226 deletions

View File

@@ -2,14 +2,22 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { getCurrentWindow } from '@tauri-apps/api/window'
import type { UnlistenFn } from '@tauri-apps/api/event'
import { open as openDialog } from '@tauri-apps/plugin-dialog'
import {
addAria2Torrent,
addAria2Uri,
detectAria2Binary,
getEngineStatus,
listAria2Tasks,
loadTorrentFile,
pauseAllAria2,
pauseAria2Task,
openPathInFileManager,
removeAria2Task,
removeAria2TaskRecord,
resumeAllAria2,
resumeAria2Task,
startEngine,
stopEngine,
type Aria2Task,
type Aria2TaskSnapshot,
type EngineStatus,
@@ -19,6 +27,17 @@ type TaskFilter = 'all' | 'active' | 'waiting' | 'stopped'
type AddTab = 'url' | 'torrent'
type AppPage = 'downloads' | 'settings'
type SettingsTab = 'basic' | 'advanced' | 'lab'
type PersistedSettings = {
binaryPath?: string
rpcPort?: number
rpcSecret?: string
downloadDir?: string
split?: number
maxConcurrentDownloads?: number
autoRefresh?: boolean
}
const SETTINGS_STORAGE_KEY = 'gdown.settings.v1'
const binaryPath = ref('aria2c')
const rpcPort = ref(6800)
@@ -30,10 +49,11 @@ const maxConcurrentDownloads = ref(5)
const loadingEngine = ref(false)
const loadingTasks = ref(false)
const loadingAddTask = ref(false)
const loadingTaskAction = ref(false)
const errorMessage = ref('')
const successMessage = ref('')
const autoRefresh = ref(true)
const filter = ref<TaskFilter>('all')
const filter = ref<TaskFilter>('active')
const page = ref<AppPage>('downloads')
const settingsTab = ref<SettingsTab>('basic')
@@ -83,16 +103,45 @@ const tasks = ref<Aria2TaskSnapshot>({
let refreshTimer: number | null = null
let unlistenDragDrop: UnlistenFn | null = null
const totalCount = computed(() => tasks.value.active.length + tasks.value.waiting.length + tasks.value.stopped.length)
const filteredTasks = computed(() => {
if (filter.value === 'active') return tasks.value.active
if (filter.value === 'waiting') return tasks.value.waiting
if (filter.value === 'stopped') return tasks.value.stopped
return [...tasks.value.active, ...tasks.value.waiting, ...tasks.value.stopped]
})
const filteredTaskCount = computed(() => filteredTasks.value.length)
const runtimeLabel = computed(() => (status.value.running ? 'Running' : 'Stopped'))
function loadSettingsFromStorage() {
try {
const raw = localStorage.getItem(SETTINGS_STORAGE_KEY)
if (!raw) return
const parsed = JSON.parse(raw) as PersistedSettings
if (typeof parsed.binaryPath === 'string') binaryPath.value = parsed.binaryPath
if (typeof parsed.rpcPort === 'number' && Number.isFinite(parsed.rpcPort)) rpcPort.value = parsed.rpcPort
if (typeof parsed.rpcSecret === 'string') rpcSecret.value = parsed.rpcSecret
if (typeof parsed.downloadDir === 'string') downloadDir.value = parsed.downloadDir
if (typeof parsed.split === 'number' && Number.isFinite(parsed.split)) split.value = parsed.split
if (typeof parsed.maxConcurrentDownloads === 'number' && Number.isFinite(parsed.maxConcurrentDownloads)) {
maxConcurrentDownloads.value = parsed.maxConcurrentDownloads
}
if (typeof parsed.autoRefresh === 'boolean') autoRefresh.value = parsed.autoRefresh
} catch {
// ignore malformed settings
}
}
function saveSettingsToStorage() {
const payload: PersistedSettings = {
binaryPath: binaryPath.value,
rpcPort: rpcPort.value,
rpcSecret: rpcSecret.value,
downloadDir: downloadDir.value,
split: split.value,
maxConcurrentDownloads: maxConcurrentDownloads.value,
autoRefresh: autoRefresh.value,
}
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(payload))
}
function pushError(message: string) {
successMessage.value = ''
@@ -111,11 +160,6 @@ function rpcConfig() {
}
}
function formatDateTime(epochSec: number | null): string {
if (!epochSec) return '-'
return new Date(epochSec * 1000).toLocaleString()
}
function formatBytes(value: string): string {
const n = Number(value)
if (!Number.isFinite(n)) return '-'
@@ -187,39 +231,164 @@ function updateRefreshTimer() {
}, 3000)
}
async function onStartEngine() {
async function autoStartEngine(silent = true) {
loadingEngine.value = true
try {
let resolvedBinaryPath = binaryPath.value.trim() || undefined
const probe = await detectAria2Binary(resolvedBinaryPath).catch(() => null)
if (probe?.found && probe.binaryPath) {
binaryPath.value = probe.binaryPath
resolvedBinaryPath = probe.binaryPath
}
status.value = await startEngine({
binaryPath: binaryPath.value.trim() || undefined,
binaryPath: resolvedBinaryPath,
rpcListenPort: rpcPort.value,
rpcSecret: rpcSecret.value.trim() || undefined,
downloadDir: downloadDir.value.trim() || undefined,
maxConcurrentDownloads: maxConcurrentDownloads.value,
split: split.value,
})
pushSuccess('aria2 engine started.')
saveSettingsToStorage()
if (!silent) pushSuccess('aria2 engine started.')
await refreshTasks()
return true
} catch (error) {
pushError(String(error))
return false
} finally {
loadingEngine.value = false
}
}
async function onStopEngine() {
loadingEngine.value = true
async function ensureEngineRunning() {
if (status.value.running) return true
await refreshEngineStatus()
if (status.value.running) return true
return autoStartEngine(false)
}
async function onPauseAllTasks() {
const ready = await ensureEngineRunning()
if (!ready) return
loadingTaskAction.value = true
try {
status.value = await stopEngine()
tasks.value = { active: [], waiting: [], stopped: [] }
pushSuccess('aria2 engine stopped.')
await pauseAllAria2(rpcConfig())
pushSuccess('모든 작업을 일시정지했습니다.')
await refreshTasks()
} catch (error) {
pushError(String(error))
} finally {
loadingEngine.value = false
loadingTaskAction.value = false
}
}
async function onResumeAllTasks() {
const ready = await ensureEngineRunning()
if (!ready) return
loadingTaskAction.value = true
try {
await resumeAllAria2(rpcConfig())
pushSuccess('모든 작업을 재개했습니다.')
await refreshTasks()
} catch (error) {
pushError(String(error))
} finally {
loadingTaskAction.value = false
}
}
async function onTaskAction(task: Aria2Task, action: 'pause' | 'resume' | 'remove' | 'purge') {
const ready = await ensureEngineRunning()
if (!ready) return
loadingTaskAction.value = true
try {
const payload = { rpc: rpcConfig(), gid: task.gid }
if (action === 'pause') {
await pauseAria2Task(payload)
} else if (action === 'resume') {
await resumeAria2Task(payload)
} else if (action === 'remove') {
await removeAria2Task(payload)
} else {
await removeAria2TaskRecord(payload)
}
await refreshTasks()
} catch (error) {
pushError(String(error))
} finally {
loadingTaskAction.value = false
}
}
async function onRemoveFilteredTasks() {
const ready = await ensureEngineRunning()
if (!ready) return
const targetTasks = [...filteredTasks.value]
if (targetTasks.length === 0) return
loadingTaskAction.value = true
try {
for (const task of targetTasks) {
const payload = { rpc: rpcConfig(), gid: task.gid }
if (task.status === 'stopped') {
await removeAria2TaskRecord(payload)
} else {
await removeAria2Task(payload)
}
}
if (filter.value === 'stopped') {
pushSuccess(`${targetTasks.length}개 기록을 정리했습니다.`)
} else {
pushSuccess(`${targetTasks.length}개 작업을 삭제했습니다.`)
}
await refreshTasks()
} catch (error) {
pushError(String(error))
} finally {
loadingTaskAction.value = false
}
}
function taskPrimaryAction(task: Aria2Task): 'pause' | 'resume' {
return task.status === 'active' ? 'pause' : 'resume'
}
function taskPrimaryIcon(task: Aria2Task): string {
return task.status === 'active' ? '□' : '▶'
}
function taskPrimaryTitle(task: Aria2Task): string {
return task.status === 'active' ? '정지' : '재개'
}
async function onOpenTaskFolder(task: Aria2Task) {
const dir = task.dir?.trim()
if (!dir) {
pushError('작업 폴더 경로가 없습니다.')
return
}
try {
await openPathInFileManager(dir)
} catch (error) {
pushError(String(error))
}
}
async function onCopyTaskLink(task: Aria2Task) {
const text = task.uri?.trim() || task.gid
try {
await navigator.clipboard.writeText(text)
pushSuccess('작업 링크를 클립보드에 복사했습니다.')
} catch (error) {
pushError(`클립보드 복사 실패: ${error}`)
}
}
function onShowTaskInfo(task: Aria2Task) {
pushSuccess(`gid=${task.gid} / status=${task.status} / dir=${task.dir || '-'}`)
}
function openAddModal() {
showAddModal.value = true
addTab.value = 'url'
@@ -234,9 +403,28 @@ function openSettingsPage() {
}
function saveSettings() {
saveSettingsToStorage()
pushSuccess('설정이 저장되었습니다.')
}
async function pickDownloadFolder() {
try {
const selected = await openDialog({
directory: true,
multiple: false,
defaultPath: downloadDir.value || undefined,
title: '기본 다운로드 폴더 선택',
})
if (typeof selected === 'string' && selected.trim()) {
downloadDir.value = selected
saveSettingsToStorage()
pushSuccess('기본 폴더를 변경했습니다.')
}
} catch (error) {
pushError(String(error))
}
}
function closeAddModal() {
showAddModal.value = false
modalDropActive.value = false
@@ -388,26 +576,32 @@ async function onWindowDrop(event: DragEvent) {
}
async function onSubmitAddTask() {
if (!status.value.running) {
pushError('먼저 엔진을 시작하세요.')
return
}
const ready = await ensureEngineRunning()
if (!ready) return
loadingAddTask.value = true
try {
if (addTab.value === 'url') {
if (!addUrl.value.trim()) {
const uris = addUrl.value
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
if (uris.length === 0) {
pushError('URL을 입력하세요.')
return
}
const gid = await addAria2Uri({
rpc: rpcConfig(),
uri: addUrl.value.trim(),
out: addOut.value.trim() || undefined,
dir: downloadDir.value.trim() || undefined,
split: addSplit.value,
})
pushSuccess(`작업이 추가되었습니다. gid=${gid}`)
const gids: string[] = []
for (const uri of uris) {
const gid = await addAria2Uri({
rpc: rpcConfig(),
uri,
out: uris.length === 1 ? (addOut.value.trim() || undefined) : undefined,
dir: downloadDir.value.trim() || undefined,
split: addSplit.value,
})
gids.push(gid)
}
pushSuccess(`${gids.length}개 작업이 추가되었습니다.`)
} else {
if (!torrentBase64.value.trim()) {
pushError('.torrent 파일을 선택하세요.')
@@ -439,7 +633,11 @@ async function onSubmitAddTask() {
}
onMounted(async () => {
loadSettingsFromStorage()
await refreshEngineStatus()
if (!status.value.running) {
await autoStartEngine(true)
}
await refreshTasks()
updateRefreshTimer()
@@ -510,116 +708,85 @@ onUnmounted(() => {
</div>
</aside>
<section v-if="page === 'downloads'" class="content">
<header class="toolbar">
<div class="toolbar-left">
<h1>다운로드 </h1>
<span class="count">{{ totalCount }} tasks</span>
</div>
<div class="toolbar-right">
<label class="switcher">
<input v-model="autoRefresh" type="checkbox" @change="updateRefreshTimer" />
<span>Auto refresh</span>
</label>
<button class="ghost" :disabled="loadingTasks" @click="refreshTasks()">새로고침</button>
</div>
</header>
<section v-if="page === 'downloads'" class="content downloads-view">
<aside class="downloads-filter card">
<h2>작업</h2>
<button :class="{ active: filter === 'active' }" @click="filter = 'active'"> 다운로드 </button>
<button :class="{ active: filter === 'waiting' }" @click="filter = 'waiting'"> 대기 </button>
<button :class="{ active: filter === 'stopped' }" @click="filter = 'stopped'"> 중단됨</button>
</aside>
<section v-if="errorMessage" class="notice error">{{ errorMessage }}</section>
<section v-if="successMessage" class="notice success">{{ successMessage }}</section>
<section class="downloads-main">
<header class="toolbar">
<div class="toolbar-left">
<h1>{{ filter === 'active' ? '다운로드 중' : filter === 'waiting' ? '대기 중' : '중단됨' }}</h1>
</div>
<div class="toolbar-right">
<button
class="icon-tool ghost"
:disabled="loadingTaskAction || filteredTaskCount === 0"
title="목록 삭제"
@click="onRemoveFilteredTasks"
>
</button>
<button class="icon-tool ghost" :disabled="loadingTasks" title="새로고침" @click="refreshTasks()"></button>
<button class="icon-tool ghost" :disabled="loadingTaskAction || !status.running" title="전체 재개" @click="onResumeAllTasks"></button>
<button class="icon-tool ghost" :disabled="loadingTaskAction || !status.running" title="전체 일시정지" @click="onPauseAllTasks"></button>
</div>
</header>
<section v-if="errorMessage" class="notice error">{{ errorMessage }}</section>
<section v-if="successMessage" class="notice success">{{ successMessage }}</section>
<section class="main-grid">
<article class="task-pane card">
<div class="tabs">
<button :class="{ active: filter === 'all' }" @click="filter = 'all'">전체</button>
<button :class="{ active: filter === 'active' }" @click="filter = 'active'">
다운로드 ({{ tasks.active.length }})
</button>
<button :class="{ active: filter === 'waiting' }" @click="filter = 'waiting'">
대기 ({{ tasks.waiting.length }})
</button>
<button :class="{ active: filter === 'stopped' }" @click="filter = 'stopped'">
중단됨 ({{ tasks.stopped.length }})
</button>
<div class="task-card-list">
<article v-for="task in filteredTasks" :key="task.gid" class="task-card">
<div class="task-card-head">
<div class="file-cell">
<strong>{{ task.fileName || '-' }}</strong>
</div>
<div class="task-actions-pill">
<button
class="icon-pill ghost"
:disabled="loadingTaskAction || task.status === 'stopped'"
:title="taskPrimaryTitle(task)"
@click="onTaskAction(task, taskPrimaryAction(task))"
>
{{ taskPrimaryIcon(task) }}
</button>
<button
class="icon-pill ghost"
:disabled="loadingTaskAction"
title="삭제"
@click="onTaskAction(task, task.status === 'stopped' ? 'purge' : 'remove')"
>
</button>
<button class="icon-pill ghost" title="폴더 열기" @click="onOpenTaskFolder(task)">📁</button>
<button class="icon-pill ghost" :title="task.uri ? '링크 복사' : 'GID 복사'" @click="onCopyTaskLink(task)">
🔗
</button>
<button class="icon-pill ghost" title="정보" @click="onShowTaskInfo(task)"></button>
</div>
</div>
<div class="progress-row">
<div class="bar">
<span :style="{ width: `${progress(task).toFixed(1)}%` }" />
</div>
</div>
<div class="task-meta-row">
<span>{{ formatBytes(task.completedLength) }} / {{ formatBytes(task.totalLength) }}</span>
<span class="task-meta-right">
<span>{{ formatSpeed(task.downloadSpeed) }}</span>
</span>
</div>
</article>
<div v-if="filteredTasks.length === 0" class="empty">현재 다운로드 없음</div>
</div>
<div class="task-table-wrap">
<table class="task-table">
<thead>
<tr>
<th>작업</th>
<th>상태</th>
<th>진행률</th>
<th>속도</th>
<th>전체 크기</th>
</tr>
</thead>
<tbody>
<tr v-for="task in filteredTasks" :key="task.gid">
<td>
<div class="file-cell">
<strong>{{ task.fileName || '-' }}</strong>
<small>{{ task.gid }}</small>
</div>
</td>
<td>
<span class="status-tag" :class="task.status">{{ task.status }}</span>
</td>
<td>
<div class="progress-row">
<div class="bar">
<span :style="{ width: `${progress(task).toFixed(1)}%` }" />
</div>
<small>{{ progress(task).toFixed(1) }}%</small>
</div>
</td>
<td>{{ formatSpeed(task.downloadSpeed) }}</td>
<td>{{ formatBytes(task.totalLength) }}</td>
</tr>
<tr v-if="filteredTasks.length === 0">
<td colspan="5" class="empty">현재 다운로드 없음</td>
</tr>
</tbody>
</table>
</div>
</article>
<article class="right-pane card">
<h2>Engine</h2>
<p class="runtime" :class="status.running ? 'ok' : 'off'">{{ runtimeLabel }}</p>
<p><strong>PID:</strong> {{ status.pid ?? '-' }}</p>
<p><strong>Started:</strong> {{ formatDateTime(status.startedAt) }}</p>
<div class="engine-actions">
<button :disabled="loadingEngine || status.running" @click="onStartEngine">Start</button>
<button class="ghost" :disabled="loadingEngine || !status.running" @click="onStopEngine">Stop</button>
</div>
<h3>연결 설정</h3>
<label>
<span>Binary Path</span>
<input v-model="binaryPath" type="text" placeholder="aria2c" />
</label>
<label>
<span>RPC Port</span>
<input v-model.number="rpcPort" type="number" min="1" max="65535" />
</label>
<label>
<span>RPC Secret</span>
<input v-model="rpcSecret" type="text" placeholder="optional" />
</label>
<label>
<span>폴더</span>
<input v-model="downloadDir" type="text" placeholder="/path/to/downloads" />
</label>
<label>
<span>Split</span>
<input v-model.number="split" type="number" min="1" max="64" />
</label>
<label>
<span>Max Concurrent</span>
<input v-model.number="maxConcurrentDownloads" type="number" min="1" max="20" />
</label>
</article>
</section>
</section>
@@ -627,18 +794,32 @@ onUnmounted(() => {
<section v-else class="content settings-content">
<section class="settings-layout">
<aside class="settings-nav card">
<h2>설정</h2>
<button :class="{ active: settingsTab === 'basic' }" @click="settingsTab = 'basic'">기본</button>
<button :class="{ active: settingsTab === 'advanced' }" @click="settingsTab = 'advanced'">고급</button>
<button :class="{ active: settingsTab === 'lab' }" @click="settingsTab = 'lab'">실험실</button>
<h2>Settings</h2>
<p class="settings-nav-sub">다운로드 엔진과 동작을 조정합니다.</p>
<button :class="{ active: settingsTab === 'basic' }" @click="settingsTab = 'basic'">
<span>기본</span>
<small>일반 사용 옵션</small>
</button>
<button :class="{ active: settingsTab === 'advanced' }" @click="settingsTab = 'advanced'">
<span>고급</span>
<small>네트워크/RPC</small>
</button>
<button :class="{ active: settingsTab === 'lab' }" @click="settingsTab = 'lab'">
<span>실험실</span>
<small>미리보기 기능</small>
</button>
</aside>
<article class="settings-panel card">
<h1>기본</h1>
<header class="settings-header">
<h1>환경 설정</h1>
<p>Motrix 스타일에 맞춘 엔진/다운로드 환경 구성</p>
</header>
<div v-if="settingsTab === 'basic'" class="settings-form">
<section class="settings-group">
<label>테마</label>
<section class="settings-group settings-card-section">
<div class="group-title">UI & 표시</div>
<label class="field-label">테마</label>
<div class="theme-row">
<button class="theme-tile" :class="{ active: settingTheme === 'auto' }" @click="settingTheme = 'auto'">자동</button>
<button class="theme-tile" :class="{ active: settingTheme === 'light' }" @click="settingTheme = 'light'">밝게</button>
@@ -646,13 +827,15 @@ onUnmounted(() => {
</div>
</section>
<section class="settings-group">
<section class="settings-group settings-card-section">
<div class="group-title">시작/표시 동작</div>
<label class="check-row"><input v-model="settingHideWindowOnStartup" type="checkbox" /> 자동으로 숨기기</label>
<label class="check-row"><input v-model="settingTraySpeed" type="checkbox" /> 메뉴 막대 트레이에 실시간 속도 표시</label>
<label class="check-row"><input v-model="settingShowDockProgress" type="checkbox" /> 다운로드 진행률 막대 보기</label>
</section>
<section class="settings-group two-col">
<section class="settings-group settings-card-section two-col">
<div class="group-title full-row">기본 파라미터</div>
<label>언어
<select v-model="settingLanguage">
<option>한국어</option>
@@ -664,19 +847,26 @@ onUnmounted(() => {
</label>
</section>
<section class="settings-group">
<section class="settings-group settings-card-section">
<div class="group-title">세션</div>
<label class="check-row"><input v-model="settingRunOnLogin" type="checkbox" /> 로그인 실행</label>
<label class="check-row"><input v-model="settingRememberWindow" type="checkbox" /> 크기 위치 기억</label>
<label class="check-row"><input v-model="settingAutoResume" type="checkbox" /> 완료되지 않은 작업 자동 재개</label>
</section>
<section class="settings-group">
<label>기본 폴더
<input v-model="downloadDir" type="text" />
</label>
<section class="settings-group settings-card-section">
<div class="group-title">다운로드 디렉터리</div>
<label class="field-label">기본 폴더</label>
<div class="folder-picker-row">
<input v-model="downloadDir" type="text" placeholder="/Users/.../Downloads" />
<button type="button" class="ghost folder-browse-btn" @click.stop.prevent="pickDownloadFolder">
폴더 선택
</button>
</div>
</section>
<section class="settings-group two-col">
<section class="settings-group settings-card-section two-col">
<div class="group-title full-row">대역폭 제한</div>
<label>업로드 제한 (KB/s)
<input v-model.number="settingUploadLimit" type="number" min="0" />
</label>
@@ -685,8 +875,8 @@ onUnmounted(() => {
</label>
</section>
<section class="settings-group">
<h3>BitTorrent</h3>
<section class="settings-group settings-card-section">
<div class="group-title">BitTorrent</div>
<label class="check-row"><input v-model="settingMagnetAsTorrent" type="checkbox" /> 마그넷 링크를 토렌트 파일로 저장</label>
<label class="check-row"><input v-model="settingAutoDownloadTorrentMeta" type="checkbox" /> 마그넷 토렌트 내용 자동 다운로드</label>
<label class="check-row"><input v-model="settingBtForceEncryption" type="checkbox" /> BT 강제 암호화</label>
@@ -702,8 +892,9 @@ onUnmounted(() => {
</div>
</div>
<div v-else class="settings-placeholder">
<p>{{ settingsTab === 'advanced' ? '고급 설정 화면(포팅 진행 중)' : '실험실 설정 화면(포팅 진행 중)' }}</p>
<div v-else class="settings-placeholder card">
<h3>{{ settingsTab === 'advanced' ? '고급 설정' : '실험실 설정' }}</h3>
<p>{{ settingsTab === 'advanced' ? 'RPC, 프록시, 트래커 등 고급 항목을 Motrix 구조에 맞춰 포팅 중입니다.' : '실험 기능과 실험적 네트워크 옵션을 단계적으로 이식합니다.' }}</p>
</div>
</article>
</section>

View File

@@ -8,6 +8,13 @@ export interface EngineStatus {
startedAt: number | null
}
export interface BinaryProbeResult {
found: boolean
binaryPath: string | null
source: string | null
candidates: string[]
}
export interface EngineStartPayload {
binaryPath?: string
rpcListenPort?: number
@@ -30,6 +37,7 @@ export interface Aria2Task {
downloadSpeed: string
dir: string
fileName: string
uri: string
}
export interface Aria2TaskSnapshot {
@@ -54,6 +62,11 @@ export interface AddTorrentPayload {
split?: number
}
export interface TaskCommandPayload {
rpc: Aria2RpcConfig
gid: string
}
export interface TorrentFilePayload {
name: string
base64: string
@@ -72,6 +85,10 @@ export async function stopEngine(): Promise<EngineStatus> {
return invoke<EngineStatus>('engine_stop')
}
export async function detectAria2Binary(binaryPath?: string): Promise<BinaryProbeResult> {
return invoke<BinaryProbeResult>('detect_aria2_binary', { binaryPath })
}
export async function listAria2Tasks(config: Aria2RpcConfig): Promise<Aria2TaskSnapshot> {
return invoke<Aria2TaskSnapshot>('aria2_list_tasks', { config })
}
@@ -87,3 +104,31 @@ export async function addAria2Torrent(payload: AddTorrentPayload): Promise<strin
export async function loadTorrentFile(path: string): Promise<TorrentFilePayload> {
return invoke<TorrentFilePayload>('load_torrent_file', { path })
}
export async function pauseAria2Task(payload: TaskCommandPayload): Promise<string> {
return invoke<string>('aria2_pause_task', { request: payload })
}
export async function resumeAria2Task(payload: TaskCommandPayload): Promise<string> {
return invoke<string>('aria2_resume_task', { request: payload })
}
export async function removeAria2Task(payload: TaskCommandPayload): Promise<string> {
return invoke<string>('aria2_remove_task', { request: payload })
}
export async function removeAria2TaskRecord(payload: TaskCommandPayload): Promise<string> {
return invoke<string>('aria2_remove_task_record', { request: payload })
}
export async function pauseAllAria2(config: Aria2RpcConfig): Promise<string> {
return invoke<string>('aria2_pause_all', { config })
}
export async function resumeAllAria2(config: Aria2RpcConfig): Promise<string> {
return invoke<string>('aria2_resume_all', { config })
}
export async function openPathInFileManager(path: string): Promise<void> {
return invoke<void>('open_path_in_file_manager', { path })
}

View File

@@ -120,6 +120,121 @@ body {
overflow: auto;
}
.downloads-view {
background: #f3f3f5;
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 22px;
padding: 18px 20px;
}
.downloads-view .card {
background: #f3f3f5;
border: 1px solid #dfdfe4;
border-radius: 10px;
}
.downloads-filter {
padding: 16px;
align-self: start;
min-height: calc(100vh - 40px);
}
.downloads-filter h2 {
margin: 2px 0 14px;
color: #2a2a2e;
font-size: 1.05rem;
}
.downloads-filter button {
width: 100%;
text-align: left;
border: 1px solid transparent;
border-radius: 7px;
background: transparent;
color: #3f424a;
font-size: 0.98rem;
padding: 10px 12px;
margin-bottom: 8px;
}
.downloads-filter button.active {
background: #e9e9ee;
color: #5b5fe9;
}
.downloads-main .toolbar {
border-bottom: 1px solid #d8d8de;
padding-bottom: 12px;
margin-bottom: 14px;
}
.downloads-main h1 {
color: #2f3136;
font-size: 2.1rem;
font-weight: 600;
}
.downloads-main .icon-tool.ghost {
background: transparent;
color: #5f626b;
border-color: transparent;
}
.downloads-main .icon-tool.ghost:hover {
background: #ebecf1;
color: #383b42;
}
.downloads-main .task-pane {
background: transparent;
border: none;
padding: 0;
}
.downloads-main .task-card {
border: 1px solid #ceced6;
border-radius: 10px;
background: #f9f9fb;
padding: 14px 16px;
}
.downloads-main .file-cell strong {
color: #37393f;
font-size: 1.08rem;
font-weight: 500;
}
.downloads-main .task-actions-pill {
background: #f5f5f8;
border: 1px solid #dfdfe6;
}
.downloads-main .icon-pill {
color: #727782;
border-color: transparent;
background: transparent;
}
.downloads-main .icon-pill:hover {
background: #e7e8ee;
border-color: transparent;
}
.downloads-main .bar {
height: 8px;
background: #e0e1ea;
}
.downloads-main .bar span {
background: #6268ee;
}
.downloads-main .task-meta-row {
color: #757b87;
font-size: 1rem;
}
.toolbar {
display: flex;
align-items: center;
@@ -135,6 +250,17 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
.toolbar-right { display: flex; align-items: center; gap: 8px; }
.icon-tool {
width: 34px;
height: 34px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
font-size: 0.96rem;
}
.switcher {
display: inline-flex;
align-items: center;
@@ -170,7 +296,7 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
.main-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
@@ -193,27 +319,24 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
color: #e0e3ff;
}
.task-table-wrap { border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.task-table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
.task-card-list {
display: grid;
gap: 10px;
}
.task-table th,
.task-table td {
text-align: left;
padding: 9px;
border-bottom: 1px solid #2b2f37;
.task-card {
border: 1px solid #363b45;
border-radius: 10px;
background: #20242c;
padding: 12px 14px;
}
.task-table th {
font-size: 0.72rem;
color: #8f9ab0;
background: #1a1c22;
text-transform: uppercase;
letter-spacing: 0.04em;
.task-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.file-cell { display: flex; flex-direction: column; gap: 2px; }
@@ -240,8 +363,8 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
.progress-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
grid-template-columns: 1fr;
gap: 6px;
align-items: center;
}
@@ -259,35 +382,45 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
background: linear-gradient(90deg, #5b86ff, #5860f0);
}
.progress-row small { color: #a8b2c4; font-size: 0.72rem; }
.empty { text-align: center; color: var(--text-sub); }
.right-pane {
padding: 10px;
.task-actions-pill {
display: flex;
flex-direction: column;
gap: 7px;
gap: 6px;
justify-content: flex-start;
background: #262b33;
border: 1px solid #3a404c;
border-radius: 999px;
padding: 4px;
}
.right-pane h2 { margin: 0; font-size: 0.98rem; }
.right-pane h3 { margin: 6px 0 2px; font-size: 0.86rem; }
.runtime { margin: 0; font-weight: 700; }
.runtime.ok { color: var(--success); }
.runtime.off { color: #949db0; }
.right-pane p { margin: 0; color: #9aa3b8; font-size: 0.8rem; }
.right-pane label {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 0.75rem;
color: #a3acbe;
.icon-pill {
width: 28px;
height: 28px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
font-size: 0.82rem;
}
.task-meta-row {
margin-top: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #a5afc1;
font-size: 0.78rem;
}
.task-meta-right {
display: inline-flex;
align-items: center;
gap: 8px;
}
.right-pane input,
.add-modal input,
.add-modal textarea {
height: 33px;
@@ -305,7 +438,6 @@ h1 { margin: 0; font-size: 1.16rem; font-weight: 600; }
min-height: 92px;
}
.right-pane input:focus,
.add-modal input:focus,
.add-modal textarea:focus {
outline: none;
@@ -338,11 +470,19 @@ button.ghost:hover {
}
button.small { padding: 4px 8px; font-size: 0.74rem; }
button.tiny { padding: 3px 7px; font-size: 0.72rem; }
button.ghost.danger {
color: #ffbec8;
border-color: #66414a;
background: #4b2e35;
}
button.ghost.danger:hover {
background: #5a3640;
border-color: #7a4a55;
}
button:disabled { opacity: 0.55; cursor: not-allowed; }
.engine-actions { display: flex; gap: 7px; margin: 4px 0 6px; }
.modal-backdrop {
position: fixed;
inset: 0;
@@ -487,11 +627,278 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
gap: 8px;
}
.settings-content {
padding: 16px 18px;
}
.settings-layout {
display: grid;
grid-template-columns: 208px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.settings-nav {
padding: 12px;
position: sticky;
top: 8px;
}
.settings-nav h2 {
margin: 0;
font-size: 1.05rem;
letter-spacing: 0.01em;
}
.settings-nav-sub {
margin: 8px 0 14px;
color: #97a2b9;
font-size: 0.75rem;
line-height: 1.45;
}
.settings-nav button {
width: 100%;
text-align: left;
border: 1px solid transparent;
border-radius: 9px;
margin-bottom: 8px;
padding: 9px 10px;
background: #23262d;
color: #c9d1e3;
}
.settings-nav button span {
display: block;
font-size: 0.82rem;
font-weight: 700;
}
.settings-nav button small {
display: block;
margin-top: 1px;
color: #919db4;
font-size: 0.68rem;
font-weight: 500;
}
.settings-nav button.active {
border-color: #575ced;
background: #2f345e;
color: #eef1ff;
}
.settings-nav button.active small {
color: #c9d2ff;
}
.settings-panel {
padding: 18px 20px;
min-height: 640px;
}
.settings-header {
margin-bottom: 16px;
}
.settings-header h1 {
margin: 0;
font-size: 1.2rem;
}
.settings-header p {
margin: 6px 0 0;
color: #98a2b8;
font-size: 0.81rem;
}
.settings-form {
display: grid;
gap: 14px;
max-width: 760px;
margin: 0 auto;
}
.settings-group {
display: grid;
gap: 8px;
}
.settings-card-section {
border: 1px solid #3a404c;
border-radius: 10px;
padding: 14px;
background: #252a33;
}
.group-title {
color: #e5e9f7;
font-size: 0.84rem;
font-weight: 700;
letter-spacing: 0.01em;
margin-bottom: 6px;
}
.group-title.full-row {
grid-column: 1 / -1;
}
.field-label {
color: #9aa6bf;
font-size: 0.76rem;
}
.theme-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.theme-tile {
background: #2d313a;
border: 1px solid #464d5b;
color: #cdd4e6;
min-height: 36px;
}
.theme-tile.active {
background: #3a3f73;
border-color: #646dff;
color: #f0f3ff;
}
.settings-group label {
display: flex;
flex-direction: column;
gap: 3px;
color: #b1b9cb;
font-size: 0.79rem;
}
.settings-group input,
.settings-group select {
height: 33px;
border: 1px solid #434955;
border-radius: 4px;
padding: 7px 10px;
font-size: 0.81rem;
color: var(--text-main);
background: #1b1f27;
}
.settings-group input:focus,
.settings-group select:focus {
outline: none;
border-color: #656cf5;
box-shadow: none;
}
.settings-group select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, #8e97aa 50%),
linear-gradient(135deg, #8e97aa 50%, transparent 50%);
background-position:
calc(100% - 14px) calc(50% - 2px),
calc(100% - 9px) calc(50% - 2px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
padding-right: 28px;
}
.settings-group.two-col {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.check-row {
display: flex !important;
flex-direction: row !important;
align-items: center;
gap: 6px !important;
color: #c1c8d9 !important;
}
.check-row input {
width: 15px;
height: 15px;
margin: 0;
}
.settings-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
border-top: 1px solid #373d49;
}
.folder-picker-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
}
.folder-browse-btn {
white-space: nowrap;
padding: 7px 12px;
min-height: 33px;
}
.settings-placeholder {
padding: 20px;
background: #242831;
border: 1px solid #3a404c;
border-radius: 12px;
}
.settings-placeholder h3 {
margin: 0 0 8px;
font-size: 0.96rem;
}
.settings-placeholder p {
margin: 0;
color: #9aa6bf;
font-size: 0.82rem;
line-height: 1.6;
}
@media (max-width: 1180px) {
.main-grid { grid-template-columns: 1fr; }
.settings-layout { grid-template-columns: 1fr; }
.settings-nav {
position: static;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.settings-nav h2,
.settings-nav-sub {
grid-column: 1 / -1;
}
.settings-nav button {
margin-bottom: 0;
}
}
@media (max-width: 900px) {
.downloads-view {
grid-template-columns: 1fr;
min-height: auto;
}
.downloads-filter {
min-height: auto;
}
.app-shell { grid-template-columns: 1fr; }
.sidebar { display: none; }
.settings-group.two-col {
grid-template-columns: 1fr;
}
.theme-row {
grid-template-columns: 1fr;
}
}