Add media folder opener to client and server

This commit is contained in:
wolfmanster 2026-08-19 17:32:06 +08:00
parent bc65ef8630
commit 66d07e9e23
4 changed files with 56 additions and 2 deletions

View file

@ -7,6 +7,7 @@ import {
Clock3,
Download,
FileText,
FolderOpen,
History,
Image as ImageIcon,
LoaderCircle,
@ -19,7 +20,7 @@ import {
X,
Zap,
} from 'lucide-react';
import { cancelJob, clearHistory, createJobs, listJobs, retryJob } from './api';
import { cancelJob, clearHistory, createJobs, listJobs, openMediaFolder, retryJob } from './api';
import type { DownloadJob, JobStatus, MediaItem } from './types';
const STATUS: Record<JobStatus, { label: string; className: string }> = {
@ -146,6 +147,7 @@ export default function App() {
const [submitting, setSubmitting] = useState(false);
const [dragging, setDragging] = useState(false);
const [notice, setNotice] = useState('');
const [openingFolder, setOpeningFolder] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const urls = useMemo(() => extractUrls(input), [input]);
@ -206,6 +208,17 @@ export default function App() {
setNotice(`已清除 ${removed} 条历史记录`);
}
async function showMediaFolder() {
setOpeningFolder(true);
try {
await openMediaFolder();
} catch (error) {
setNotice(error instanceof Error ? error.message : '无法打开媒体文件夹');
} finally {
setOpeningFolder(false);
}
}
const activeJobs = jobs.filter((job) => ['queued', 'resolving', 'downloading'].includes(job.status));
const historyJobs = jobs.filter((job) => ['completed', 'failed', 'canceled'].includes(job.status));
const visibleJobs = tab === 'queue' ? activeJobs : historyJobs;
@ -215,7 +228,19 @@ export default function App() {
<div className="app-shell">
<header className="topbar">
<a className="brand" href="#top"><span className="brand-mark">𝕏</span><span>X 媒体存档<small>PUBLIC MEDIA ARCHIVE</small></span></a>
<div className="service-state"><span /> Cobalt 本地解析</div>
<div className="topbar-actions">
<button
className="folder-button"
type="button"
title="打开 C:\\Users\\70918\\Desktop\\veo downloader\\data\\media"
onClick={() => void showMediaFolder()}
disabled={openingFolder}
>
{openingFolder ? <LoaderCircle className="spin" size={15} /> : <FolderOpen size={15} />}
打开媒体文件夹
</button>
<div className="service-state"><span /> Cobalt 本地解析</div>
</div>
</header>
<main id="top">

View file

@ -33,3 +33,7 @@ export function retryJob(id: string) {
export function clearHistory() {
return request<{ removed: number }>('/api/history', { method: 'DELETE' });
}
export function openMediaFolder() {
return request<{ path: string }>('/api/open-media-folder', { method: 'POST' });
}

View file

@ -26,6 +26,10 @@ a { color: inherit; text-decoration: none; }
.brand-mark { width: 37px; height: 37px; border-radius: 9px; display: grid; place-items: center; background: var(--ink); color: white; font-size: 19px; }
.brand > span:last-child { display: grid; line-height: 1.05; }
.brand small { font: 500 8px/1.7 Consolas, monospace; letter-spacing: .14em; color: var(--muted); }
.topbar-actions { display: flex; align-items: center; gap: 20px; }
.folder-button { border: 1px solid var(--line); background: var(--card); min-height: 32px; padding: 0 11px; display: flex; align-items: center; gap: 7px; cursor: pointer; color: var(--ink); font-size: 11px; }
.folder-button:hover:not(:disabled) { border-color: var(--ink); color: var(--accent); }
.folder-button:disabled { opacity: .6; cursor: wait; }
.service-state { font: 500 11px Consolas, monospace; letter-spacing: .04em; color: var(--muted); display: flex; gap: 8px; align-items: center; }
.service-state span { width: 7px; height: 7px; background: #3c9c64; border-radius: 50%; box-shadow: 0 0 0 4px rgba(60,156,100,.12); }
main { width: min(1120px, calc(100% - 56px)); margin: 0 auto; }
@ -120,6 +124,7 @@ main { width: min(1120px, calc(100% - 56px)); margin: 0 auto; }
@media (max-width: 760px) {
.topbar { padding: 0 20px; }
.topbar-actions { gap: 0; }
.service-state { display: none; }
main { width: calc(100% - 28px); }
.hero { padding: 52px 0 36px; display: block; }

View file

@ -1,4 +1,5 @@
import 'dotenv/config';
import { spawn } from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
@ -107,6 +108,25 @@ app.post('/api/jobs/:id/retry', (req, res) => {
app.delete('/api/history', async (_req, res) => res.json({ removed: await store.clearCompleted() }));
app.post('/api/open-media-folder', async (_req, res) => {
const platform = process.platform;
const command = platform === 'win32' ? 'explorer.exe' : platform === 'darwin' ? 'open' : 'xdg-open';
try {
await new Promise<void>((resolve, reject) => {
const opener = spawn(command, [store.mediaDir], { detached: true, stdio: 'ignore' });
opener.once('error', reject);
opener.once('spawn', () => {
opener.unref();
resolve();
});
});
return res.json({ path: store.mediaDir });
} catch (error) {
return res.status(500).json({ error: error instanceof Error ? error.message : '无法打开媒体文件夹' });
}
});
app.get('/api/jobs/:jobId/media/:mediaId', async (req, res) => {
const job = store.get(req.params.jobId);
const media = job?.media.find((item) => item.id === req.params.mediaId);