refactor: polish admin mobile management UI

This commit is contained in:
nianzhibai
2026-06-03 19:28:00 +08:00
parent 9e1acd4e56
commit 397823bb8d
8 changed files with 335 additions and 114 deletions
+2
View File
@@ -23,8 +23,10 @@ tools/
# 编译产物
backend/server
backend/server.*
release/
tsconfig.tsbuildinfo
tmp/
# 91 爬虫脚本独立运行时的默认输出文件(backend 跑时会显式 --output 到 backend/data/spider91/,所以不会落在这里)
91porn_videos.json
+5 -1
View File
@@ -608,7 +608,10 @@ export function DrivesPage() {
{storage && <StorageSummary storage={storage} />}
{loading ? (
<div className="admin-empty">...</div>
<div className="admin-loading-state">
<RefreshCw size={20} className="admin-spin" />
<span>...</span>
</div>
) : loadError ? (
<div className="admin-error-state">
<strong></strong>
@@ -681,6 +684,7 @@ export function DrivesPage() {
uploadTargets={uploadTargets}
nameError={nameError}
onNameBlur={() => setNameTouched(true)}
onBack={() => setNameTouched(false)}
/>
</Modal>
<DeleteDriveModal
+1 -5
View File
@@ -8,11 +8,7 @@ export function RequireAuth({ children }: { children: ReactNode }) {
const location = useLocation();
if (status === "loading") {
return (
<div className="admin-loading-screen">
...
</div>
);
return null;
}
if (status === "guest") {
+4 -1
View File
@@ -360,7 +360,10 @@ export function TagsPage() {
)}
{loading ? (
<div className="admin-empty">...</div>
<div className="admin-loading-state">
<RefreshCw size={20} className="admin-spin" />
<span>...</span>
</div>
) : loadError ? (
<div className="admin-error-state">
<strong></strong>
+5 -38
View File
@@ -53,6 +53,7 @@ export function VideosPage() {
}, [driveId, page, searchKeyword]);
useEffect(() => {
if (keyword === searchKeyword) return;
const timer = window.setTimeout(() => {
setSearchKeyword(keyword);
setPage(1);
@@ -208,44 +209,10 @@ export function VideosPage() {
)}
{loading ? (
<table className="admin-table is-selectable">
<thead>
<tr>
<th className="is-checkbox" style={{ width: '40px' }}><Square size={16} color="var(--border-default)" /></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>Teaser</th>
<th></th>
<th className="is-actions"></th>
</tr>
</thead>
<tbody>
{[...Array(10)].map((_, i) => (
<tr key={i}>
<td className="is-checkbox"><Square size={16} color="var(--border-subtle)" /></td>
<td>
<div className="admin-skeleton-pulse" style={{ width: '60%', height: '14px', marginBottom: '6px', borderRadius: '4px' }}></div>
<div className="admin-skeleton-pulse" style={{ width: '40%', height: '12px', borderRadius: '4px' }}></div>
</td>
<td><div className="admin-skeleton-pulse" style={{ width: '80%', height: '14px', borderRadius: '4px' }}></div></td>
<td>
<div style={{ display: 'flex', gap: '4px' }}>
<div className="admin-skeleton-pulse" style={{ width: '40px', height: '22px', borderRadius: '12px' }}></div>
<div className="admin-skeleton-pulse" style={{ width: '30px', height: '22px', borderRadius: '12px' }}></div>
</div>
</td>
<td><div className="admin-skeleton-pulse" style={{ width: '40px', height: '14px', borderRadius: '4px' }}></div></td>
<td><div className="admin-skeleton-pulse" style={{ width: '50px', height: '22px', borderRadius: '4px' }}></div></td>
<td><div className="admin-skeleton-pulse" style={{ width: '60px', height: '14px', borderRadius: '4px' }}></div></td>
<td className="is-actions">
<div className="admin-skeleton-pulse" style={{ width: '60px', height: '28px', borderRadius: '4px', display: 'inline-block' }}></div>
</td>
</tr>
))}
</tbody>
</table>
<div className="admin-loading-state">
<RefreshCw size={20} className="admin-spin" />
<span>...</span>
</div>
) : loadError ? (
<div className="admin-error-state">
<strong></strong>
+122 -59
View File
@@ -1,9 +1,35 @@
import { useId, useMemo } from "react";
import { useId, useMemo, useState } from "react";
import { ArrowLeft } from "lucide-react";
import { P123QRCodeLogin } from "./P123QRCodeLogin";
import { Spider91UploadTargetField } from "./Spider91UploadTargetField";
import { FormState, Kind, credentialFields, credentialHelp, usesRootDirectoryID, rootIdPlaceholder } from "./constants";
import {
FormState,
Kind,
credentialFields,
credentialHelp,
usesRootDirectoryID,
rootIdPlaceholder,
} from "./constants";
import * as api from "../api";
type DriveOption = {
kind: Kind;
label: string;
abbr: 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" },
];
export function DriveForm({
form,
onChange,
@@ -11,6 +37,7 @@ export function DriveForm({
uploadTargets,
nameError,
onNameBlur,
onBack,
}: {
form: FormState;
onChange: (f: FormState) => void;
@@ -18,12 +45,13 @@ export function DriveForm({
uploadTargets: api.AdminDrive[];
nameError?: string;
onNameBlur?: () => void;
onBack?: () => void;
}) {
const idPrefix = useId();
const fields = useMemo(() => credentialFields(form.kind), [form.kind]);
const help = credentialHelp(form.kind, isEdit);
const [step, setStep] = useState<"type" | "form">(isEdit ? "form" : "type");
const nameId = `${idPrefix}-drive-name`;
const kindId = `${idPrefix}-drive-kind`;
const rootId = `${idPrefix}-drive-root`;
function set<K extends keyof FormState>(k: K, v: FormState[K]) {
@@ -40,64 +68,98 @@ export function DriveForm({
creds: {},
});
}
function selectType(kind: Kind) {
setKind(kind);
setStep("form");
}
function goBack() {
setStep("type");
onChange({
...form,
name: "",
rootId: "",
creds: {},
});
onBack?.();
}
const selectedOption = DRIVE_OPTIONS.find((o) => o.kind === form.kind);
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>
);
}
return (
<div className="admin-form">
<div className="admin-form__row">
<label htmlFor={nameId}> *</label>
<input
id={nameId}
value={form.name}
onChange={(e) => set("name", e.target.value)}
onBlur={onNameBlur}
placeholder="给这个盘起个名字"
className={nameError ? "is-invalid" : undefined}
aria-invalid={nameError ? "true" : undefined}
aria-describedby={nameError ? `${nameId}-error` : undefined}
/>
{nameError && (
<div className="admin-form__error" id={`${nameId}-error`}>
{nameError}
</div>
)}
</div>
<div className="admin-form__row">
<label htmlFor={kindId}></label>
<select
id={kindId}
value={form.kind}
onChange={(e) => setKind(e.target.value as Kind)}
disabled={isEdit}
>
<option value="p115">115 </option>
<option value="p123">123 </option>
<option value="pikpak">PikPak</option>
<option value="onedrive">OneDrive</option>
<option value="googledrive">Google Drive</option>
<option value="localstorage"></option>
<option value="spider91">91 Spider</option>
<option value="quark"></option>
<option value="wopan"></option>
</select>
</div>
{usesRootDirectoryID(form.kind) && (
<div className="admin-form__row">
<label htmlFor={rootId}> ID</label>
<input
id={rootId}
value={form.rootId}
onChange={(e) => set("rootId", e.target.value)}
placeholder={rootIdPlaceholder(form.kind)}
/>
<div className="admin-form__help">
使ID获取方式请参考OpenList文档
</div>
{!isEdit && (
<div className="admin-drive-step-header">
<button type="button" className="admin-drive-step-back" onClick={goBack}>
<ArrowLeft size={14} />
</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>
)}
<div className="admin-form__section">
<div className="admin-form__row">
<label htmlFor={nameId}> *</label>
<input
id={nameId}
value={form.name}
onChange={(e) => set("name", e.target.value)}
onBlur={onNameBlur}
placeholder="给这个盘起个名字"
className={nameError ? "is-invalid" : undefined}
aria-invalid={nameError ? "true" : undefined}
aria-describedby={nameError ? `${nameId}-error` : undefined}
/>
{nameError && (
<div className="admin-form__error" id={`${nameId}-error`}>
{nameError}
</div>
)}
</div>
{usesRootDirectoryID(form.kind) && (
<div className="admin-form__row">
<label htmlFor={rootId}> ID</label>
<input
id={rootId}
value={form.rootId}
onChange={(e) => set("rootId", e.target.value)}
placeholder={rootIdPlaceholder(form.kind)}
/>
<div className="admin-form__help">
使
</div>
</div>
)}
</div>
{(help || fields.length > 0) && (
<>
<hr className="admin-form__divider" />
<div className="admin-form__section">
<h3 className="admin-form__section-label"></h3>
{help && (
<div className="admin-form__help admin-form__help--lead">
@@ -114,7 +176,8 @@ export function DriveForm({
{fields.map((f) => (
<div key={f.key} className="admin-form__row">
<label htmlFor={`${idPrefix}-credential-${f.key}`}>
{f.label}{f.required && " *"}
{f.label}
{f.required && " *"}
</label>
{f.multiline ? (
<textarea
@@ -135,18 +198,18 @@ export function DriveForm({
{f.help && <div className="admin-form__help">{f.help}</div>}
</div>
))}
</>
</div>
)}
{form.kind === "spider91" && (
<>
<hr className="admin-form__divider" />
<div className="admin-form__section">
<h3 className="admin-form__section-label"></h3>
<Spider91UploadTargetField
value={form.spider91UploadDriveId}
onChange={(v) => set("spider91UploadDriveId", v)}
uploadTargets={uploadTargets}
/>
</>
</div>
)}
</div>
);
+174
View File
@@ -1143,6 +1143,9 @@
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
-webkit-tap-highlight-color: transparent;
-webkit-user-select: none;
user-select: none;
}
.admin-table.admin-drives-table tr:hover td {
@@ -1158,6 +1161,7 @@
border-bottom: 0;
white-space: normal;
overflow-wrap: anywhere;
-webkit-tap-highlight-color: transparent;
}
.admin-table.admin-drives-table td::before {
@@ -1398,6 +1402,14 @@
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
transition: border-color var(--transition-fast), transform var(--transition-fast);
-webkit-tap-highlight-color: transparent;
-webkit-user-select: none;
user-select: none;
}
.admin-table:not(.admin-drives-table) tr.is-selected {
background: var(--accent-soft);
border-color: var(--border-accent);
}
@media (hover: hover) and (pointer: fine) {
@@ -1419,6 +1431,7 @@
border-bottom: 0;
white-space: normal;
overflow-wrap: anywhere;
-webkit-tap-highlight-color: transparent;
}
.admin-table:not(.admin-drives-table) td::before {
@@ -1538,6 +1551,17 @@
background: var(--bg-page);
}
.admin-loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-8);
color: var(--text-muted);
font-size: var(--font-md);
}
.admin-videos-filter {
flex-wrap: wrap;
}
@@ -1649,6 +1673,150 @@
}
}
/* =========================================================
* Drive Type Picker (新建网盘 - 类型选择卡片)
* ========================================================= */
.admin-drive-type-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-3);
}
.admin-drive-type-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: var(--space-4) var(--space-3);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--bg-surface);
color: var(--text-default);
cursor: pointer;
text-align: center;
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);
transform: translateY(-2px);
}
.admin-drive-type-card:active {
transform: translateY(0);
}
.admin-drive-type-card:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.admin-drive-type-card__icon {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: var(--radius-md);
background: var(--accent-soft);
color: var(--accent);
font-size: 14px;
font-weight: var(--weight-bold);
letter-spacing: -0.02em;
}
.admin-drive-type-card__label {
font-size: var(--font-sm);
font-weight: var(--weight-semibold);
color: var(--text-strong);
}
@media (max-width: 520px) {
.admin-drive-type-grid {
grid-template-columns: repeat(2, 1fr);
}
}
.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 {
display: flex;
align-items: center;
gap: var(--space-3);
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 {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--text-on-accent);
font-size: 11px;
font-weight: var(--weight-bold);
}
.admin-drive-step-badge__label {
font-size: var(--font-sm);
font-weight: var(--weight-semibold);
color: var(--text-strong);
}
.admin-form__section {
display: grid;
gap: var(--space-4);
padding: var(--space-4);
background: var(--bg-elevated);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.admin-form__section + .admin-form__section {
margin-top: var(--space-2);
}
.admin-form__section-label {
font-size: var(--font-sm);
font-weight: var(--weight-semibold);
color: var(--text-strong);
margin: 0;
}
/* =========================================================
* Theme Page (外观)
*
@@ -2071,6 +2239,9 @@
cursor: pointer;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
position: relative;
-webkit-tap-highlight-color: transparent;
-webkit-user-select: none;
user-select: none;
}
.admin-drive-card:hover,
@@ -2497,6 +2668,9 @@
gap: var(--space-3);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
box-shadow: var(--shadow-sm);
-webkit-tap-highlight-color: transparent;
-webkit-user-select: none;
user-select: none;
}
.admin-tag-card:hover {
+22 -10
View File
@@ -20,6 +20,24 @@ const combinedSource = drivesPageSource + "\n" + driveFormSource + "\n" + consta
"utf8"
);
function driveTypeOptions() {
const match = /const DRIVE_OPTIONS:\s*DriveOption\[]\s*=\s*\[([\s\S]*?)\];/.exec(
driveFormSource
);
assert.ok(match, "drive option card list should be present");
return Array.from(
match[1].matchAll(/\{\s*kind:\s*"([^"]+)",\s*label:\s*"([^"]+)"/g),
(option) => ({ value: option[1], label: option[2] })
);
}
function assertDriveTypeOption(value: string, label: string) {
assert.ok(
driveTypeOptions().some((option) => option.value === value && option.label === label),
`${value} drive type option should be present`
);
}
test("spider91 drive form does not expose advanced crawler credentials", () => {
assert.match(combinedSource, /key: "proxy"/);
assert.match(combinedSource, /label: "代理地址(可选)"/);
@@ -70,7 +88,7 @@ test("onedrive drive form only exposes required default-app fields", () => {
});
test("googledrive drive form only exposes refresh token", () => {
assert.match(combinedSource, /<option value="googledrive">Google Drive<\/option>/);
assertDriveTypeOption("googledrive", "Google Drive");
const match =
/case "googledrive":\s*return \[([\s\S]*?)\];\s*case "localstorage":/.exec(
@@ -104,7 +122,7 @@ test("pikpak drive form only exposes account login fields", () => {
});
test("localstorage drive form asks for a server directory path", () => {
assert.match(combinedSource, /<option value="localstorage">本地存储<\/option>/);
assertDriveTypeOption("localstorage", "本地存储");
const match =
/case "localstorage":\s*return \[([\s\S]*?)\];\s*case "spider91":/.exec(
@@ -120,20 +138,14 @@ test("localstorage drive form asks for a server directory path", () => {
});
test("drive type selector keeps primary source order", () => {
const options = Array.from(
combinedSource.matchAll(/<option value="([^"]+)">([^<]+)<\/option>/g),
(match) => ({ value: match[1], label: match[2] })
);
const driveOptions = options.slice(0, 9);
assert.deepEqual(driveOptions, [
assert.deepEqual(driveTypeOptions(), [
{ value: "p115", label: "115 网盘" },
{ value: "p123", label: "123 云盘" },
{ value: "pikpak", label: "PikPak" },
{ value: "onedrive", label: "OneDrive" },
{ value: "googledrive", label: "Google Drive" },
{ value: "localstorage", label: "本地存储" },
{ value: "spider91", label: "91 Spider" },
{ value: "spider91", label: "91 爬虫" },
{ value: "quark", label: "夸克网盘" },
{ value: "wopan", label: "联通沃盘" },
]);