6 Commits

Author SHA1 Message Date
nianzhibai e1b8f0eae7 Fix drive form dirty state and media fallbacks 2026-06-05 14:42:12 +00:00
nianzhibai 2d907da07d Redesign admin drive/video management UI
- 新建网盘弹窗:改为品牌色卡片选择器,二步式流程,选中后展示已选品牌栏
- 网盘详情页:简化页头(类型芯片 + 状态),生成状态改为三列布局,本地存储改为横向指标
- 视频管理页:标题列加缩略图,标签列合并至标题内联,来源列修复折行,操作按钮统一为纯图标

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 14:09:43 +00:00
nianzhibai 78cfb0a9e5 Fix admin modal focus reset 2026-06-05 12:57:06 +00:00
nianzhibai fa7823ef3e Fix admin loading spinner and empty drive copy 2026-06-05 12:50:21 +00:00
nianzhibai 5b0afcfc6c Fix deploy script update exit status 2026-06-05 12:35:14 +00:00
nianzhibai 76ae3cea7d fix admin video batch delete and spider91 form 2026-06-04 23:18:53 +08:00
17 changed files with 787 additions and 222 deletions
+31
View File
@@ -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"))
+57
View File
@@ -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")
-1
View File
@@ -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":
+29
View File
@@ -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")
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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} />
+66 -51
View File
@@ -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>
);
}
+38 -33
View File
@@ -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>
)}
+16 -10
View File
@@ -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>
);
+13 -1
View File
@@ -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
View File
@@ -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;
+47 -5
View File
@@ -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/);
+17
View File
@@ -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\]\);/);
});
+11
View File
@@ -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");
+9
View File
@@ -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*\)/
);
});