mirror of
https://github.com/nianzhibai/91.git
synced 2026-06-15 16:55:42 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1b8f0eae7 | |||
| 2d907da07d | |||
| 78cfb0a9e5 | |||
| fa7823ef3e | |||
| 5b0afcfc6c | |||
| 76ae3cea7d |
@@ -189,6 +189,27 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
items = appendUniqueVideos(items, fallback, homePageSize)
|
||||
}
|
||||
if len(items) < homePageSize && len(excludeIDs) > 0 {
|
||||
// The browser keeps a recent-video exclude list so normal refreshes do not
|
||||
// repeat too quickly. On small libraries that list can cover every visible
|
||||
// video; when that happens, start a new random round instead of returning
|
||||
// an empty home section.
|
||||
roundExclude := videoIDs(items)
|
||||
fallback, err := s.Catalog.RandomVideosWithReadyThumbnailsExcluding(r.Context(), roundExclude, homePageSize-len(items))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
items = appendUniqueVideos(items, fallback, homePageSize)
|
||||
}
|
||||
if len(items) < homePageSize && len(excludeIDs) > 0 {
|
||||
fallback, err := s.Catalog.RandomVideosExcluding(r.Context(), videoIDs(items), homePageSize-len(items))
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
items = appendUniqueVideos(items, fallback, homePageSize)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
writeJSON(w, http.StatusOK, mapVideos(items))
|
||||
}
|
||||
@@ -248,6 +269,16 @@ func appendUniqueVideos(dst []*catalog.Video, candidates []*catalog.Video, limit
|
||||
return dst
|
||||
}
|
||||
|
||||
func videoIDs(items []*catalog.Video) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item != nil && item.ID != "" {
|
||||
out = append(out, item.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
page, _ := strconv.Atoi(q.Get("page"))
|
||||
|
||||
@@ -241,6 +241,63 @@ func TestHandleHomeExcludesRecentlyShownVideos(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHomeStartsNewRoundWhenRecentExcludesAllVisibleVideos(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open catalog: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := cat.Close(); err != nil {
|
||||
t.Fatalf("close catalog: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
now := time.Now()
|
||||
excludes := make([]string, 0, homePageSize+2)
|
||||
for i := 0; i < homePageSize+2; i++ {
|
||||
id := "ready-video-" + strconv.Itoa(i)
|
||||
excludes = append(excludes, "exclude="+id)
|
||||
if err := cat.UpsertVideo(ctx, &catalog.Video{
|
||||
ID: id,
|
||||
DriveID: "drive",
|
||||
FileID: id,
|
||||
Title: id,
|
||||
ThumbnailURL: "https://thumb.example/" + id + ".jpg",
|
||||
PublishedAt: now.Add(time.Duration(i) * time.Minute),
|
||||
CreatedAt: now.Add(time.Duration(i) * time.Minute),
|
||||
UpdatedAt: now.Add(time.Duration(i) * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed ready video %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/home?"+strings.Join(excludes, "&"), nil)
|
||||
(&Server{Catalog: cat}).handleHome(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got []VideoDTO
|
||||
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(got) != homePageSize {
|
||||
t.Fatalf("home items = %d, want %d; body=%s", len(got), homePageSize, rr.Body.String())
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range got {
|
||||
if seen[item.ID] {
|
||||
t.Fatalf("home returned duplicate video %q; items=%#v", item.ID, got)
|
||||
}
|
||||
seen[item.ID] = true
|
||||
if !strings.HasPrefix(item.ID, "ready-video-") {
|
||||
t.Fatalf("home returned unexpected video %q; items=%#v", item.ID, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleListLatestPrefersReadyThumbnails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
|
||||
|
||||
@@ -1535,7 +1535,6 @@ func driveErrorShouldCooldown(d drives.Drive, err error) bool {
|
||||
strings.Contains(text, "too many requests") ||
|
||||
strings.Contains(text, "rate limit") ||
|
||||
strings.Contains(text, "blocked") ||
|
||||
strings.Contains(text, "moov atom not found") ||
|
||||
strings.Contains(text, "partial file") ||
|
||||
strings.Contains(text, "service unavailable")
|
||||
case "p123":
|
||||
|
||||
@@ -592,6 +592,35 @@ func TestThumbWorkerRequeuesP115TransientErrorBeforeRetryLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestThumbWorkerPikPakMoovAtomErrorFailsWithoutCooldown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, video := seedPreviewTestVideo(t, "thumb-pikpak-missing-moov")
|
||||
|
||||
mediaErr := errors.New("ffprobe: exit status 1, stderr: moov atom not found Invalid data found when processing input")
|
||||
gen := &fakeThumbGenerator{
|
||||
probeErr: mediaErr,
|
||||
generateErr: mediaErr,
|
||||
}
|
||||
drv := &previewFakeDrive{kind: "pikpak"}
|
||||
worker := NewThumbWorker(gen, cat, drv)
|
||||
|
||||
worker.process(ctx, video)
|
||||
|
||||
failed, err := cat.ListVideosByThumbnailStatus(ctx, video.DriveID, "failed", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("list failed thumbnails: %v", err)
|
||||
}
|
||||
if len(failed) != 1 || failed[0].ID != video.ID {
|
||||
t.Fatalf("failed thumbnails = %#v, want only %s", failed, video.ID)
|
||||
}
|
||||
if !worker.Status().CooldownUntil.IsZero() {
|
||||
t.Fatalf("cooldown until = %s, want no cooldown for invalid PikPak MP4", worker.Status().CooldownUntil)
|
||||
}
|
||||
if gen.generateCalls != 1 {
|
||||
t.Fatalf("generate calls = %d, want 1", gen.generateCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewWorkerP115TransientErrorKeepsVideoPending(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cat, video := seedPreviewTestVideo(t, "preview-p115-transient")
|
||||
|
||||
@@ -334,8 +334,8 @@ EOF
|
||||
}
|
||||
|
||||
open_firewall_port() {
|
||||
[[ "$CONFIGURE_UFW" == "1" ]] || return
|
||||
command -v ufw >/dev/null 2>&1 || return
|
||||
[[ "$CONFIGURE_UFW" == "1" ]] || return 0
|
||||
command -v ufw >/dev/null 2>&1 || return 0
|
||||
if ufw status 2>/dev/null | grep -qi "Status: active"; then
|
||||
log "UFW is active; allowing ${FRONTEND_PORT}/tcp"
|
||||
ufw allow "${FRONTEND_PORT}/tcp"
|
||||
@@ -378,7 +378,9 @@ install_or_update() {
|
||||
open_firewall_port
|
||||
restart_services
|
||||
show_status
|
||||
[[ "$mode" == "install" ]] && show_summary
|
||||
if [[ "$mode" == "install" ]]; then
|
||||
show_summary
|
||||
fi
|
||||
}
|
||||
|
||||
uninstall_services() {
|
||||
|
||||
+37
-33
@@ -70,7 +70,9 @@ export function DrivesPage() {
|
||||
const nightlyBusy = scanningAll || nightlyStatus.running || nightlyStatus.queued;
|
||||
const nameMissing = form.name.trim().length === 0;
|
||||
const nameError = nameTouched && nameMissing ? "请填写网盘名称" : "";
|
||||
const formDirty = !sameForm(form, initialForm);
|
||||
const formDirty = form.id
|
||||
? !sameForm(form, initialForm)
|
||||
: hasCreateFormChanges(form, initialForm);
|
||||
|
||||
const uploadTargets = useMemo(
|
||||
() => list.filter((d) => d.kind === "pikpak" || d.kind === "p115" || d.kind === "p123" || d.kind === "onedrive"),
|
||||
@@ -207,6 +209,13 @@ export function DrivesPage() {
|
||||
setNameTouched(false);
|
||||
}
|
||||
|
||||
function handleCreateFormChange(nextForm: FormState) {
|
||||
setForm(nextForm);
|
||||
if (!nextForm.id && !hasCreateFormChanges(nextForm, initialForm)) {
|
||||
setInitialForm(nextForm);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const name = form.name.trim();
|
||||
if (!name || !form.kind) {
|
||||
@@ -459,9 +468,10 @@ export function DrivesPage() {
|
||||
</button>
|
||||
<div className="admin-drive-detail__title-wrap">
|
||||
<h1 className="admin-drive-detail__title">{d.name || d.id}</h1>
|
||||
<span className="admin-mono-cell" style={{ fontSize: "14px", color: "var(--text-faint)" }}>
|
||||
({d.id})
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-drive-detail__header-right">
|
||||
<span className="admin-drive-detail__kind-chip">{kindLabel[d.kind] ?? d.kind}</span>
|
||||
<StatusTag kind={d.kind} status={d.status} error={d.lastError} hasCred={d.hasCredential} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -471,16 +481,11 @@ export function DrivesPage() {
|
||||
<header className="admin-detail-card__title">
|
||||
<div className="admin-detail-card__title-left">
|
||||
<HardDrive size={16} />
|
||||
<span>基本信息与状态</span>
|
||||
<span>基本信息</span>
|
||||
</div>
|
||||
<StatusTag kind={d.kind} status={d.status} error={d.lastError} hasCred={d.hasCredential} />
|
||||
</header>
|
||||
|
||||
<div className="admin-detail-grid">
|
||||
<div className="admin-detail-row">
|
||||
<span className="admin-detail-label">网盘类型</span>
|
||||
<span className="admin-detail-value">{kindLabel[d.kind] ?? d.kind}</span>
|
||||
</div>
|
||||
<div className="admin-detail-row">
|
||||
<span className="admin-detail-label">网盘 ID</span>
|
||||
<span className="admin-detail-value admin-mono-cell">{d.id}</span>
|
||||
@@ -499,15 +504,10 @@ export function DrivesPage() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{d.lastError && (
|
||||
<div className="admin-detail-row" style={{ alignItems: "start" }}>
|
||||
<span className="admin-detail-label">最后一次错误</span>
|
||||
<span className="admin-detail-value" style={{ color: "var(--danger)" }}>
|
||||
{d.lastError}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{d.lastError && (
|
||||
<div className="admin-detail-error">{d.lastError}</div>
|
||||
)}
|
||||
|
||||
<div className="admin-detail-actions">
|
||||
<div className="admin-task-controls" aria-label="当前网盘任务控制">
|
||||
@@ -584,21 +584,18 @@ export function DrivesPage() {
|
||||
<span>本地存储占用</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="admin-detail-grid">
|
||||
<div className="admin-detail-row">
|
||||
<span className="admin-detail-label">封面占用</span>
|
||||
<span className="admin-detail-value">{formatBytes(driveStorage?.thumbnailBytes ?? 0)}</span>
|
||||
<div className="admin-local-storage-metrics">
|
||||
<div className="admin-local-storage-metric">
|
||||
<span>封面</span>
|
||||
<strong>{formatBytes(driveStorage?.thumbnailBytes ?? 0)}</strong>
|
||||
</div>
|
||||
<div className="admin-detail-row">
|
||||
<span className="admin-detail-label">预览视频占用</span>
|
||||
<span className="admin-detail-value">{formatBytes(driveStorage?.teaserBytes ?? 0)}</span>
|
||||
<div className="admin-local-storage-metric">
|
||||
<span>预览视频</span>
|
||||
<strong>{formatBytes(driveStorage?.teaserBytes ?? 0)}</strong>
|
||||
</div>
|
||||
<div className="admin-detail-row">
|
||||
<span className="admin-detail-label">本地总占用</span>
|
||||
<span className="admin-detail-value" style={{ fontWeight: "bold" }}>
|
||||
{formatBytes(driveStorage?.totalBytes ?? 0)}
|
||||
</span>
|
||||
<div className="admin-local-storage-metric">
|
||||
<span>合计</span>
|
||||
<strong>{formatBytes(driveStorage?.totalBytes ?? 0)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -708,7 +705,7 @@ export function DrivesPage() {
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="admin-card admin-empty">
|
||||
还没有配置任何网盘。点击右上角「新建」,选择夸克 / 115 / PikPak / 沃盘 / OneDrive / 本地存储,填入凭证或路径即可。
|
||||
当前还没有配置任何网盘
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-drives-grid">
|
||||
@@ -765,7 +762,7 @@ export function DrivesPage() {
|
||||
>
|
||||
<DriveForm
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
onChange={handleCreateFormChange}
|
||||
isEdit={!!list.find((x) => x.id === form.id)}
|
||||
uploadTargets={uploadTargets}
|
||||
nameError={nameError}
|
||||
@@ -816,3 +813,10 @@ function sameRecord(a: Record<string, string>, b: Record<string, string>): boole
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasCreateFormChanges(form: FormState, initial: FormState): boolean {
|
||||
if (form.name.trim() !== "") return true;
|
||||
if (form.rootId.trim() !== "") return true;
|
||||
if (form.spider91UploadDriveId !== initial.spider91UploadDriveId) return true;
|
||||
return Object.values(form.creds).some((value) => value.trim() !== "");
|
||||
}
|
||||
|
||||
+9
-3
@@ -12,8 +12,13 @@ type Props = {
|
||||
|
||||
export function Modal({ open, title, onClose, children, footer, className = "" }: Props) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
const titleId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const previousFocus =
|
||||
@@ -25,7 +30,7 @@ export function Modal({ open, title, onClose, children, footer, className = "" }
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,7 +56,7 @@ export function Modal({ open, title, onClose, children, footer, className = "" }
|
||||
}
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
const focusTimer = window.setTimeout(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog || !isTopDialog(dialog)) return;
|
||||
const first = getFocusableElements(dialog)[0];
|
||||
@@ -60,12 +65,13 @@ export function Modal({ open, title, onClose, children, footer, className = "" }
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.clearTimeout(focusTimer);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
if (previousFocus?.isConnected) {
|
||||
previousFocus.focus();
|
||||
}
|
||||
};
|
||||
}, [open, onClose]);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
|
||||
+34
-25
@@ -152,15 +152,16 @@ export function VideosPage() {
|
||||
if (ids.length === 0) return;
|
||||
setBatchDeleting(true);
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
ids.map((id) => api.deleteVideo(id))
|
||||
);
|
||||
let success = 0;
|
||||
let deletedSources = 0;
|
||||
for (const r of results) {
|
||||
if (r.status !== "fulfilled") continue;
|
||||
success++;
|
||||
if (r.value.deletedSource) deletedSources++;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const result = await api.deleteVideo(id);
|
||||
success++;
|
||||
if (result.deletedSource) deletedSources++;
|
||||
} catch {
|
||||
// Keep deleting the rest of the selected videos; report aggregate failure below.
|
||||
}
|
||||
}
|
||||
const failed = ids.length - success;
|
||||
if (failed === 0) {
|
||||
@@ -301,7 +302,6 @@ export function VideosPage() {
|
||||
</th>
|
||||
<th>标题</th>
|
||||
<th>作者</th>
|
||||
<th>标签</th>
|
||||
<th>时长</th>
|
||||
<th>预览视频</th>
|
||||
<th>来源</th>
|
||||
@@ -322,24 +322,33 @@ export function VideosPage() {
|
||||
</button>
|
||||
</td>
|
||||
<td data-label="标题">
|
||||
<div className="admin-video-title">{v.title}</div>
|
||||
{fileMeta(v) && (
|
||||
<div className="admin-video-filemeta">
|
||||
{fileMeta(v)}
|
||||
<div className="admin-video-title-cell">
|
||||
<div className="admin-video-thumb-wrap" aria-hidden="true">
|
||||
{v.thumbnailUrl ? (
|
||||
<img className="admin-video-thumb" src={v.thumbnailUrl} alt="" />
|
||||
) : (
|
||||
<div className="admin-video-thumb-placeholder">
|
||||
<Image size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-video-title-body">
|
||||
<div className="admin-video-title">{v.title}</div>
|
||||
{fileMeta(v) && (
|
||||
<div className="admin-video-filemeta">{fileMeta(v)}</div>
|
||||
)}
|
||||
{(v.tags ?? []).length > 0 && (
|
||||
<div className="admin-pills admin-video-title-tags">
|
||||
{(v.tags ?? []).map((t) => (
|
||||
<span key={t} className="admin-pill">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<VideoFileMetaPills video={v} />
|
||||
</div>
|
||||
)}
|
||||
<VideoFileMetaPills video={v} />
|
||||
</td>
|
||||
<td data-label="作者">{v.author || <span className="admin-text-faint">—</span>}</td>
|
||||
<td data-label="标签">
|
||||
<div className="admin-pills">
|
||||
{(v.tags ?? []).map((t) => (
|
||||
<span key={t} className="admin-pill">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="作者">{v.author || <span className="admin-text-faint">—</span>}</td>
|
||||
<td data-label="时长">{formatDur(v.durationSeconds)}</td>
|
||||
<td data-label="预览视频">
|
||||
<PreviewStatus s={v.previewStatus} />
|
||||
@@ -348,8 +357,8 @@ export function VideosPage() {
|
||||
{driveNameMap.get(v.driveId) ?? v.driveId}
|
||||
</td>
|
||||
<td className="is-actions" data-label="操作">
|
||||
<button type="button" className="admin-btn" onClick={() => setEditing(v)}>
|
||||
<Edit size={13} /> 编辑
|
||||
<button type="button" className="admin-btn" onClick={() => setEditing(v)} title="编辑视频">
|
||||
<Edit size={13} />
|
||||
</button>{" "}
|
||||
<button type="button" className="admin-btn" onClick={() => handleRegen(v)} title="重生预览视频">
|
||||
<RefreshCw size={13} />
|
||||
|
||||
@@ -198,61 +198,34 @@ export function DriveGenerationPanel({
|
||||
style={{ padding: "4px 10px", fontSize: "11px" }}
|
||||
>
|
||||
{d.teaserEnabled ? <Power size={11} /> : <PowerOff size={11} />}
|
||||
<span>{d.teaserEnabled ? "预览视频生成:开" : "预览视频生成:关"}</span>
|
||||
<span>{d.teaserEnabled ? "预览视频:开" : "预览视频:关"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="admin-detail-grid">
|
||||
<div className="admin-detail-row">
|
||||
<span className="admin-detail-label">封面生成状态</span>
|
||||
<div className="admin-detail-value">
|
||||
<GenerationStatusLine label="封面" status={d.thumbnailGenerationStatus} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-detail-row">
|
||||
<span className="admin-detail-label">封面数量</span>
|
||||
<div className="admin-detail-value">
|
||||
<GenerationCounts
|
||||
ready={d.thumbnailReadyCount}
|
||||
pending={d.thumbnailPendingCount}
|
||||
failed={d.thumbnailFailedCount}
|
||||
durationPending={d.thumbnailDurationPendingCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-detail-row">
|
||||
<span className="admin-detail-label">预览视频生成状态</span>
|
||||
<div className="admin-detail-value">
|
||||
<GenerationStatusLine label="预览" status={d.previewGenerationStatus} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-detail-row">
|
||||
<span className="admin-detail-label">预览视频数量</span>
|
||||
<div className="admin-detail-value">
|
||||
<GenerationCounts
|
||||
ready={d.teaserReadyCount}
|
||||
pending={d.teaserPendingCount}
|
||||
failed={d.teaserFailedCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-detail-row">
|
||||
<span className="admin-detail-label">视频指纹生成状态</span>
|
||||
<div className="admin-detail-value">
|
||||
<GenerationStatusLine label="指纹" status={d.fingerprintGenerationStatus} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-detail-row">
|
||||
<span className="admin-detail-label">视频指纹数量</span>
|
||||
<div className="admin-detail-value">
|
||||
<GenerationCounts
|
||||
ready={d.fingerprintReadyCount}
|
||||
pending={d.fingerprintPendingCount}
|
||||
failed={d.fingerprintFailedCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-gen-columns">
|
||||
<DriveGenCol
|
||||
label="封面"
|
||||
status={d.thumbnailGenerationStatus}
|
||||
ready={d.thumbnailReadyCount}
|
||||
pending={d.thumbnailPendingCount}
|
||||
failed={d.thumbnailFailedCount}
|
||||
extra={d.thumbnailDurationPendingCount}
|
||||
/>
|
||||
<DriveGenCol
|
||||
label="预览视频"
|
||||
status={d.previewGenerationStatus}
|
||||
ready={d.teaserReadyCount}
|
||||
pending={d.teaserPendingCount}
|
||||
failed={d.teaserFailedCount}
|
||||
/>
|
||||
<DriveGenCol
|
||||
label="视频指纹"
|
||||
status={d.fingerprintGenerationStatus}
|
||||
ready={d.fingerprintReadyCount}
|
||||
pending={d.fingerprintPendingCount}
|
||||
failed={d.fingerprintFailedCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-detail-actions">
|
||||
@@ -284,3 +257,45 @@ export function DriveGenerationPanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DriveGenCol({
|
||||
label,
|
||||
status,
|
||||
ready,
|
||||
pending,
|
||||
failed,
|
||||
extra,
|
||||
}: {
|
||||
label: string;
|
||||
status?: api.DriveGenerationStatus;
|
||||
ready?: number;
|
||||
pending?: number;
|
||||
failed?: number;
|
||||
extra?: number;
|
||||
}) {
|
||||
const state = status?.state || "idle";
|
||||
const detail = generationDetail(status);
|
||||
const title = generationTitle(status, detail);
|
||||
return (
|
||||
<div className="admin-gen-col">
|
||||
<div className="admin-gen-col__head">
|
||||
<span className="admin-gen-col__label">{label}</span>
|
||||
<span
|
||||
className={`admin-status admin-generation-state is-${generationStateClass(state)}`}
|
||||
title={title || undefined}
|
||||
>
|
||||
{generationStateLabel(state)}
|
||||
</span>
|
||||
</div>
|
||||
{detail && <div className="admin-gen-col__detail">{detail}</div>}
|
||||
<div className="admin-gen-col__counts">
|
||||
<div className="admin-gen-col__count"><span>就绪</span><strong>{ready ?? 0}</strong></div>
|
||||
<div className="admin-gen-col__count"><span>待生成</span><strong>{pending ?? 0}</strong></div>
|
||||
<div className="admin-gen-col__count"><span>失败</span><strong>{failed ?? 0}</strong></div>
|
||||
{(extra ?? 0) > 0 && (
|
||||
<div className="admin-gen-col__count"><span>待补时长</span><strong>{extra}</strong></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,18 +16,19 @@ type DriveOption = {
|
||||
kind: Kind;
|
||||
label: string;
|
||||
abbr: string;
|
||||
desc: string;
|
||||
};
|
||||
|
||||
const DRIVE_OPTIONS: DriveOption[] = [
|
||||
{ kind: "p115", label: "115 网盘", abbr: "115" },
|
||||
{ kind: "p123", label: "123 云盘", abbr: "123" },
|
||||
{ kind: "pikpak", label: "PikPak", abbr: "Pk" },
|
||||
{ kind: "onedrive", label: "OneDrive", abbr: "OD" },
|
||||
{ kind: "googledrive", label: "Google Drive", abbr: "GD" },
|
||||
{ kind: "localstorage", label: "本地存储", abbr: "Lo" },
|
||||
{ kind: "spider91", label: "91 爬虫", abbr: "91" },
|
||||
{ kind: "quark", label: "夸克网盘", abbr: "Qk" },
|
||||
{ kind: "wopan", label: "联通沃盘", abbr: "Wo" },
|
||||
{ kind: "p115", label: "115 网盘", abbr: "115", desc: "302直链,不占带宽" },
|
||||
{ kind: "p123", label: "123 云盘", abbr: "123", desc: "扫码登录,302直链" },
|
||||
{ kind: "pikpak", label: "PikPak", abbr: "Pk", desc: "302直链,稳定快速" },
|
||||
{ kind: "onedrive", label: "OneDrive", abbr: "OD", desc: "302直链,微软网盘" },
|
||||
{ kind: "googledrive", label: "Google Drive", abbr: "GD", desc: "服务器中转模式" },
|
||||
{ kind: "localstorage", label: "本地存储", abbr: "Lo", desc: "本机文件目录" },
|
||||
{ kind: "spider91", label: "91 爬虫", abbr: "91", desc: "自动抓取热门视频" },
|
||||
{ kind: "quark", label: "夸克网盘", abbr: "Qk", desc: "302直链" },
|
||||
{ kind: "wopan", label: "联通沃盘", abbr: "Wo", desc: "302直链" },
|
||||
];
|
||||
|
||||
export function DriveForm({
|
||||
@@ -87,37 +88,41 @@ export function DriveForm({
|
||||
|
||||
if (step === "type" && !isEdit) {
|
||||
return (
|
||||
<div className="admin-drive-type-grid">
|
||||
{DRIVE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.kind}
|
||||
type="button"
|
||||
className="admin-drive-type-card"
|
||||
onClick={() => selectType(opt.kind)}
|
||||
>
|
||||
<span className="admin-drive-type-card__icon">
|
||||
{opt.abbr}
|
||||
</span>
|
||||
<span className="admin-drive-type-card__label">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="admin-drive-type-picker">
|
||||
<div className="admin-drive-type-grid">
|
||||
{DRIVE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.kind}
|
||||
type="button"
|
||||
className="admin-drive-type-card"
|
||||
data-kind={opt.kind}
|
||||
onClick={() => selectType(opt.kind)}
|
||||
>
|
||||
<span className="admin-drive-type-card__icon" data-kind={opt.kind}>
|
||||
{opt.abbr}
|
||||
</span>
|
||||
<span className="admin-drive-type-card__label">{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-form">
|
||||
{!isEdit && (
|
||||
<div className="admin-drive-step-header">
|
||||
<button type="button" className="admin-drive-step-back" onClick={goBack}>
|
||||
<ArrowLeft size={14} /> 重选类型
|
||||
{!isEdit && selectedOption && (
|
||||
<div className="admin-drive-selected-bar" data-kind={form.kind}>
|
||||
<span className="admin-drive-selected-bar__icon" data-kind={form.kind}>
|
||||
{selectedOption.abbr}
|
||||
</span>
|
||||
<div className="admin-drive-selected-bar__text">
|
||||
<span className="admin-drive-selected-bar__name">{selectedOption.label}</span>
|
||||
<span className="admin-drive-selected-bar__desc">{selectedOption.desc}</span>
|
||||
</div>
|
||||
<button type="button" className="admin-drive-selected-bar__back" onClick={goBack}>
|
||||
<ArrowLeft size={12} /> 重选类型
|
||||
</button>
|
||||
{selectedOption && (
|
||||
<span className="admin-drive-step-badge">
|
||||
<span className="admin-drive-step-badge__abbr">{selectedOption.abbr}</span>
|
||||
<span className="admin-drive-step-badge__label">{selectedOption.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useId } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { kindLabel } from "./constants";
|
||||
import * as api from "../api";
|
||||
|
||||
@@ -16,16 +17,21 @@ export function Spider91UploadTargetField({
|
||||
return (
|
||||
<div className="admin-form__row">
|
||||
<label htmlFor={targetId}>视频上传目标</label>
|
||||
<select id={targetId} value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">本地保存,不上传</option>
|
||||
{uploadTargets.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{kindLabel[d.kind] ?? d.kind} · {d.name || d.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="admin-form__help">
|
||||
选择本地保存时,爬取视频只保存在服务器本地;选择 115 网盘、123 云盘、PikPak 或 OneDrive 后,较早的视频会上传到该云盘根目录下的 91 Spider 文件夹。该设置全局生效。
|
||||
<div className="admin-form-select-wrap">
|
||||
<select
|
||||
id={targetId}
|
||||
className="admin-form-select"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
>
|
||||
<option value="">本地保存,不上传</option>
|
||||
{uploadTargets.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{kindLabel[d.kind] ?? d.kind} · {d.name || d.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={15} className="admin-form-select__icon" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
export type Kind = "quark" | "p115" | "p123" | "pikpak" | "wopan" | "onedrive" | "googledrive" | "localstorage" | "spider91";
|
||||
|
||||
export const kindAbbr: Record<string, string> = {
|
||||
quark: "Qk",
|
||||
p115: "115",
|
||||
p123: "123",
|
||||
pikpak: "Pk",
|
||||
wopan: "Wo",
|
||||
onedrive: "OD",
|
||||
googledrive: "GD",
|
||||
localstorage: "Lo",
|
||||
spider91: "91",
|
||||
};
|
||||
|
||||
export const kindLabel: Record<string, string> = {
|
||||
quark: "夸克网盘",
|
||||
p115: "115 网盘",
|
||||
@@ -264,7 +276,7 @@ export function credentialFields(kind: Kind): Array<{
|
||||
key: "proxy",
|
||||
label: "代理地址(可选)",
|
||||
placeholder: "http://127.0.0.1:7890",
|
||||
help: "仅用于 91Spider 的列表/详情请求和视频、封面下载;留空则使用服务器环境变量 HTTP_PROXY / HTTPS_PROXY 或直连。支持 http://、https://、socks5:// 或 socks5h://。",
|
||||
help: "支持 http://、https://、socks5://、socks5h://代理",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+368
-57
@@ -147,6 +147,9 @@
|
||||
|
||||
.admin-sidebar__check-update:disabled svg {
|
||||
animation: admin-update-spin 0.9s linear infinite;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes admin-update-spin {
|
||||
@@ -404,6 +407,39 @@
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.admin-form-select-wrap {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-form__row .admin-form-select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding-right: 36px;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-form__row .admin-form-select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-form-select__icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 12px;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-faint);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.admin-form-select:focus + .admin-form-select__icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-form__row--inline {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
@@ -1082,6 +1118,16 @@
|
||||
|
||||
.admin-spin {
|
||||
animation: admin-update-spin 0.9s linear infinite;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.admin-sidebar__check-update:disabled svg,
|
||||
.admin-spin {
|
||||
animation-duration: 0.9s !important;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-table-checkbox-btn {
|
||||
@@ -1604,6 +1650,8 @@
|
||||
.admin-table.is-selectable:not(.admin-drives-table) tr,
|
||||
.admin-table.is-selectable:not(.admin-drives-table) td,
|
||||
.admin-table.is-selectable:not(.admin-drives-table) td::before,
|
||||
.admin-table.is-selectable:not(.admin-drives-table) .admin-video-title-cell,
|
||||
.admin-table.is-selectable:not(.admin-drives-table) .admin-video-title-body,
|
||||
.admin-table.is-selectable:not(.admin-drives-table) .admin-video-title,
|
||||
.admin-table.is-selectable:not(.admin-drives-table) .admin-video-filemeta,
|
||||
.admin-table.is-selectable:not(.admin-drives-table) .admin-pills,
|
||||
@@ -1667,7 +1715,9 @@
|
||||
font-family: ui-monospace, SFMono-Regular, "JetBrains Mono", monospace;
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.admin-video-title {
|
||||
@@ -1686,6 +1736,48 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-video-title-cell {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-video-thumb-wrap {
|
||||
flex: 0 0 68px;
|
||||
width: 68px;
|
||||
height: 42px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.admin-video-thumb {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.admin-video-thumb-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.admin-video-title-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-video-title-tags {
|
||||
margin-top: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-videos-table:not(.admin-drives-table) tbody {
|
||||
gap: 10px;
|
||||
@@ -1810,6 +1902,17 @@
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) .admin-video-title-cell {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) .admin-video-thumb-wrap {
|
||||
flex: 0 0 54px;
|
||||
width: 54px;
|
||||
height: 34px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) .admin-video-title {
|
||||
color: var(--admin-video-card-main);
|
||||
font-size: 14px;
|
||||
@@ -1828,6 +1931,10 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) .admin-video-title-tags {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) .admin-video-filemeta-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1848,10 +1955,6 @@
|
||||
color: var(--admin-video-card-category-text);
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="标签"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="作者"],
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="来源"],
|
||||
.admin-videos-table:not(.admin-drives-table) td[data-label="时长"],
|
||||
@@ -2195,6 +2298,17 @@
|
||||
/* =========================================================
|
||||
* Drive Type Picker (新建网盘 - 类型选择卡片)
|
||||
* ========================================================= */
|
||||
.admin-drive-type-picker {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-drive-type-picker__hint {
|
||||
margin: 0;
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-drive-type-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
@@ -2205,24 +2319,22 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: var(--space-4) var(--space-3);
|
||||
gap: 6px;
|
||||
padding: var(--space-4) var(--space-3) var(--space-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-default);
|
||||
background: var(--bg-elevated);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: border-color var(--transition-fast),
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
background var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-drive-type-card:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-elevated);
|
||||
box-shadow: 0 4px 16px var(--accent-glow);
|
||||
background: var(--bg-surface);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -2235,23 +2347,53 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 悬停时用各自品牌色描边 + 光晕 */
|
||||
.admin-drive-type-card[data-kind="p115"]:hover { border-color: var(--drive-p115); box-shadow: 0 4px 18px rgba(245,107,118,.22); }
|
||||
.admin-drive-type-card[data-kind="p123"]:hover { border-color: var(--drive-p123); box-shadow: 0 4px 18px rgba(34,184,200,.2); }
|
||||
.admin-drive-type-card[data-kind="pikpak"]:hover { border-color: var(--drive-pikpak); box-shadow: 0 4px 18px rgba(138,109,255,.22); }
|
||||
.admin-drive-type-card[data-kind="onedrive"]:hover { border-color: var(--drive-onedrive); box-shadow: 0 4px 18px rgba(76,171,234,.2); }
|
||||
.admin-drive-type-card[data-kind="googledrive"]:hover { border-color: #4285f4; box-shadow: 0 4px 18px rgba(66,133,244,.2); }
|
||||
.admin-drive-type-card[data-kind="localstorage"]:hover { border-color: var(--drive-localstorage); box-shadow: 0 4px 18px rgba(53,184,143,.2); }
|
||||
.admin-drive-type-card[data-kind="spider91"]:hover { border-color: var(--accent); box-shadow: 0 4px 18px var(--accent-glow); }
|
||||
.admin-drive-type-card[data-kind="quark"]:hover { border-color: var(--drive-quark); box-shadow: 0 4px 18px rgba(91,141,239,.2); }
|
||||
.admin-drive-type-card[data-kind="wopan"]:hover { border-color: var(--drive-wopan); box-shadow: 0 4px 18px rgba(255,138,60,.2); }
|
||||
|
||||
.admin-drive-type-card__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: -0.02em;
|
||||
/* 默认色,未匹配 data-kind 时的兜底 */
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* 各网盘品牌色图标 */
|
||||
.admin-drive-type-card__icon[data-kind="p115"] { background: rgba(245,107,118,.14); color: var(--drive-p115); }
|
||||
.admin-drive-type-card__icon[data-kind="p123"] { background: rgba(34,184,200,.14); color: var(--drive-p123); }
|
||||
.admin-drive-type-card__icon[data-kind="pikpak"] { background: rgba(138,109,255,.14); color: var(--drive-pikpak); }
|
||||
.admin-drive-type-card__icon[data-kind="onedrive"] { background: rgba(76,171,234,.14); color: var(--drive-onedrive); }
|
||||
.admin-drive-type-card__icon[data-kind="googledrive"] { background: rgba(66,133,244,.14); color: #4285f4; }
|
||||
.admin-drive-type-card__icon[data-kind="localstorage"]{ background: rgba(53,184,143,.14); color: var(--drive-localstorage); }
|
||||
.admin-drive-type-card__icon[data-kind="spider91"] { background: var(--accent-soft); color: var(--accent); }
|
||||
.admin-drive-type-card__icon[data-kind="quark"] { background: rgba(91,141,239,.14); color: var(--drive-quark); }
|
||||
.admin-drive-type-card__icon[data-kind="wopan"] { background: rgba(255,138,60,.14); color: var(--drive-wopan); }
|
||||
|
||||
.admin-drive-type-card__label {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-strong);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.admin-drive-type-card__desc {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-faint);
|
||||
line-height: var(--line-tight);
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
@@ -2260,62 +2402,86 @@
|
||||
}
|
||||
}
|
||||
|
||||
.admin-drive-step-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-drive-step-back:hover {
|
||||
color: var(--text-strong);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
:root[data-theme="pink"] .admin-drive-step-back:hover {
|
||||
background: rgba(120, 50, 80, 0.06);
|
||||
}
|
||||
|
||||
.admin-drive-step-header {
|
||||
/* =========================================================
|
||||
* Drive Selected Bar (选完类型后在表单顶部显示的条)
|
||||
* ========================================================= */
|
||||
.admin-drive-selected-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 12px var(--space-4);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-drive-step-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid var(--border-accent);
|
||||
}
|
||||
|
||||
.admin-drive-step-badge__abbr {
|
||||
.admin-drive-selected-bar__icon {
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: -0.02em;
|
||||
/* 兜底 */
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.admin-drive-step-badge__label {
|
||||
font-size: var(--font-sm);
|
||||
.admin-drive-selected-bar__icon[data-kind="p115"] { background: rgba(245,107,118,.14); color: var(--drive-p115); }
|
||||
.admin-drive-selected-bar__icon[data-kind="p123"] { background: rgba(34,184,200,.14); color: var(--drive-p123); }
|
||||
.admin-drive-selected-bar__icon[data-kind="pikpak"] { background: rgba(138,109,255,.14); color: var(--drive-pikpak); }
|
||||
.admin-drive-selected-bar__icon[data-kind="onedrive"] { background: rgba(76,171,234,.14); color: var(--drive-onedrive); }
|
||||
.admin-drive-selected-bar__icon[data-kind="googledrive"] { background: rgba(66,133,244,.14); color: #4285f4; }
|
||||
.admin-drive-selected-bar__icon[data-kind="localstorage"]{ background: rgba(53,184,143,.14); color: var(--drive-localstorage); }
|
||||
.admin-drive-selected-bar__icon[data-kind="spider91"] { background: var(--accent-soft); color: var(--accent); }
|
||||
.admin-drive-selected-bar__icon[data-kind="quark"] { background: rgba(91,141,239,.14); color: var(--drive-quark); }
|
||||
.admin-drive-selected-bar__icon[data-kind="wopan"] { background: rgba(255,138,60,.14); color: var(--drive-wopan); }
|
||||
|
||||
.admin-drive-selected-bar__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-drive-selected-bar__name {
|
||||
font-size: var(--font-md);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.admin-drive-selected-bar__desc {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.admin-drive-selected-bar__back {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px var(--space-3);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-drive-selected-bar__back:hover {
|
||||
color: var(--text-strong);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.admin-form__section {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
@@ -2867,6 +3033,151 @@
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
* Drive Detail Header — right-side chips
|
||||
* ========================================================= */
|
||||
.admin-drive-detail__id {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-faint);
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.admin-drive-detail__header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-drive-detail__kind-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-default);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-medium);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
* Drive Detail — Error Banner & Local Storage Metrics
|
||||
* ========================================================= */
|
||||
.admin-detail-error {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
background: var(--danger-soft);
|
||||
border: 1px solid rgba(241, 85, 108, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--danger);
|
||||
line-height: var(--line-relaxed);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.admin-local-storage-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.admin-local-storage-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin-local-storage-metric span {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-faint);
|
||||
font-weight: var(--weight-medium);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.admin-local-storage-metric strong {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: var(--weight-bold);
|
||||
color: var(--text-strong);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
* Drive Generation — 3-Column Layout
|
||||
* ========================================================= */
|
||||
.admin-gen-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-gen-col {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.admin-gen-col__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-gen-col__label {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.admin-gen-col__detail {
|
||||
font-size: var(--font-xs);
|
||||
color: var(--text-muted);
|
||||
line-height: var(--line-tight);
|
||||
}
|
||||
|
||||
.admin-gen-col__counts {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.admin-gen-col__count {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.admin-gen-col__count span {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.admin-gen-col__count strong {
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--weight-semibold);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-gen-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.admin-local-storage-metrics {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Detail Layout --- */
|
||||
.admin-drive-detail-layout {
|
||||
display: grid;
|
||||
|
||||
@@ -10,10 +10,18 @@ const driveComponentsSource = readFileSync(
|
||||
new URL("../src/admin/drive/DriveComponents.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const spider91UploadTargetSource = readFileSync(
|
||||
new URL("../src/admin/drive/Spider91UploadTargetField.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const driveFormSource = readFileSync(
|
||||
new URL("../src/admin/drive/DriveForm.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const adminCss = readFileSync(
|
||||
new URL("../src/styles/admin.css", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const apiSource = readFileSync(
|
||||
new URL("../src/admin/api.ts", import.meta.url),
|
||||
"utf8"
|
||||
@@ -23,10 +31,7 @@ const constantsSource = readFileSync(
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const combinedSource = drivesPageSource + "\n" + driveFormSource + "\n" + constantsSource + "\n" + readFileSync(
|
||||
new URL("../src/admin/drive/Spider91UploadTargetField.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
const combinedSource = drivesPageSource + "\n" + driveFormSource + "\n" + constantsSource + "\n" + spider91UploadTargetSource;
|
||||
|
||||
function driveTypeOptions() {
|
||||
const match = /const DRIVE_OPTIONS:\s*DriveOption\[]\s*=\s*\[([\s\S]*?)\];/.exec(
|
||||
@@ -49,7 +54,7 @@ function assertDriveTypeOption(value: string, label: string) {
|
||||
test("spider91 drive form does not expose advanced crawler credentials", () => {
|
||||
assert.match(combinedSource, /key: "proxy"/);
|
||||
assert.match(combinedSource, /label: "代理地址(可选)"/);
|
||||
assert.match(combinedSource, /支持 http:\/\/、https:\/\/、socks5:\/\/ 或 socks5h:\/\//);
|
||||
assert.match(combinedSource, /支持 http:\/\/、https:\/\/、socks5:\/\/、socks5h:\/\/代理/);
|
||||
assert.doesNotMatch(combinedSource, /target_new/);
|
||||
assert.doesNotMatch(combinedSource, /crawl_hour/);
|
||||
assert.doesNotMatch(combinedSource, /python_path/);
|
||||
@@ -64,6 +69,18 @@ test("spider91 upload target uses explicit local-save option instead of auto tar
|
||||
);
|
||||
assert.doesNotMatch(combinedSource, /自动:唯一/);
|
||||
assert.doesNotMatch(combinedSource, /自动模式/);
|
||||
assert.doesNotMatch(combinedSource, /较早的视频会上传到该云盘根目录下的 91 Spider 文件夹/);
|
||||
});
|
||||
|
||||
test("spider91 upload target select uses an aligned custom arrow", () => {
|
||||
assert.match(spider91UploadTargetSource, /className="admin-form-select-wrap"/);
|
||||
assert.match(spider91UploadTargetSource, /className="admin-form-select"/);
|
||||
assert.match(spider91UploadTargetSource, /className="admin-form-select__icon"/);
|
||||
assert.match(adminCss, /\.admin-form__row \.admin-form-select\s*\{[^}]*appearance\s*:\s*none/s);
|
||||
assert.match(
|
||||
adminCss,
|
||||
/\.admin-form-select__icon\s*\{[^}]*top\s*:\s*50%[^}]*right\s*:\s*12px[^}]*transform\s*:\s*translateY\(-50%\)/s
|
||||
);
|
||||
});
|
||||
|
||||
test("drive form hides root directory id for localstorage and spider91", () => {
|
||||
@@ -193,6 +210,31 @@ test("drive discard confirmation matches delete confirmation modal styling", ()
|
||||
}
|
||||
});
|
||||
|
||||
test("new drive type selection alone is not treated as unsaved config", () => {
|
||||
assert.match(
|
||||
drivesPageSource,
|
||||
/const formDirty = form\.id\s*\?\s*!sameForm\(form, initialForm\)\s*:\s*hasCreateFormChanges\(form, initialForm\);/
|
||||
);
|
||||
assert.match(drivesPageSource, /function handleCreateFormChange\(nextForm: FormState\)/);
|
||||
assert.match(
|
||||
drivesPageSource,
|
||||
/if \(!nextForm\.id && !hasCreateFormChanges\(nextForm, initialForm\)\) \{\s*setInitialForm\(nextForm\);/
|
||||
);
|
||||
assert.match(drivesPageSource, /onChange=\{handleCreateFormChange\}/);
|
||||
|
||||
const match = /function hasCreateFormChanges\(form: FormState, initial: FormState\): boolean \{([\s\S]*?)\n\}/.exec(
|
||||
drivesPageSource
|
||||
);
|
||||
assert.ok(match, "create form dirty helper should be present");
|
||||
const helper = match[1];
|
||||
|
||||
assert.match(helper, /form\.name\.trim\(\) !== ""/);
|
||||
assert.match(helper, /form\.rootId\.trim\(\) !== ""/);
|
||||
assert.match(helper, /form\.spider91UploadDriveId !== initial\.spider91UploadDriveId/);
|
||||
assert.match(helper, /Object\.values\(form\.creds\)\.some/);
|
||||
assert.doesNotMatch(helper, /form\.kind/);
|
||||
});
|
||||
|
||||
test("drive generation actions can resume pending work after stop", () => {
|
||||
assert.match(driveComponentsSource, /thumbnailPendingCount/);
|
||||
assert.match(driveComponentsSource, /teaserPendingCount/);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const modalSource = readFileSync(
|
||||
new URL("../src/admin/Modal.tsx", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
test("admin modal does not reset focus when close handler identity changes", () => {
|
||||
assert.match(modalSource, /const onCloseRef = useRef\(onClose\);/);
|
||||
assert.match(modalSource, /onCloseRef\.current = onClose;/);
|
||||
assert.match(modalSource, /onCloseRef\.current\(\);/);
|
||||
assert.match(modalSource, /window\.clearTimeout\(focusTimer\);/);
|
||||
assert.match(modalSource, /\}, \[open\]\);/);
|
||||
assert.doesNotMatch(modalSource, /\}, \[open, onClose\]\);/);
|
||||
});
|
||||
@@ -100,6 +100,17 @@ test("admin video bulk actions use semantic theme colors", () => {
|
||||
assert.doesNotMatch(bulkBodies, /#ff5b8a|#fff6f9|rgba\(255,\s*91,\s*138/);
|
||||
});
|
||||
|
||||
test("admin loading spinner rotates around icon center", () => {
|
||||
const spinner = ruleBody(adminCss, ".admin-spin");
|
||||
const reducedMotion = ruleBodyByContains(adminCss, ".admin-sidebar__check-update:disabled svg");
|
||||
|
||||
assert.match(spinner, /animation\s*:\s*admin-update-spin\s+0\.9s\s+linear\s+infinite/);
|
||||
assert.match(spinner, /transform-box\s*:\s*fill-box/);
|
||||
assert.match(spinner, /transform-origin\s*:\s*center/);
|
||||
assert.match(spinner, /will-change\s*:\s*transform/);
|
||||
assert.match(reducedMotion, /animation-duration\s*:\s*0\.9s\s*!important/);
|
||||
});
|
||||
|
||||
test("mobile video management uses compact theme-aware video cards", () => {
|
||||
const css = mobileCss();
|
||||
const card = ruleBody(css, ".admin-videos-table:not(.admin-drives-table) tr");
|
||||
|
||||
@@ -11,3 +11,12 @@ test("admin videos page uses responsive page size", () => {
|
||||
assert.match(videosPageSource, /window\.matchMedia\(VIDEOS_MOBILE_QUERY\)/);
|
||||
assert.match(videosPageSource, /api\.listVideos\(\{ driveId, page, size: pageSize, keyword: searchKeyword \}\)/);
|
||||
});
|
||||
|
||||
test("admin videos batch delete runs deletions sequentially", () => {
|
||||
assert.match(videosPageSource, /for \(const id of ids\) \{/);
|
||||
assert.match(videosPageSource, /const result = await api\.deleteVideo\(id\);/);
|
||||
assert.doesNotMatch(
|
||||
videosPageSource,
|
||||
/Promise\.allSettled\(\s*ids\.map\(\(id\) => api\.deleteVideo\(id\)\)\s*\)/
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user