feat: improve admin storage and mobile UI

This commit is contained in:
nianzhibai
2026-05-12 19:57:48 +08:00
parent 31a2f99feb
commit fcc0ecd6ef
21 changed files with 774 additions and 22 deletions
+3 -2
View File
@@ -101,8 +101,9 @@ func main() {
}
adminServer := &api.AdminServer{
Catalog: cat,
Auth: authr,
Catalog: cat,
Auth: authr,
LocalPreviewDir: cfg.Storage.LocalPreviewDir,
OnDriveSaved: func(driveID string) error {
d, err := cat.GetDrive(ctx, driveID)
if err != nil {
+3
View File
@@ -15,6 +15,8 @@ import (
type AdminServer struct {
Catalog *catalog.Catalog
Auth *auth.Authenticator
// LocalPreviewDir is the local directory that stores generated teasers and thumbs.
LocalPreviewDir string
// Hooks:外层注入实际执行者
OnDriveSaved func(driveID string) error
OnDriveRemoved func(driveID string)
@@ -40,6 +42,7 @@ func (a *AdminServer) Register(r chi.Router) {
// 网盘
r.Get("/drives", a.handleListDrives)
r.Get("/drives/storage", a.handleDriveStorage)
r.Post("/drives", a.handleUpsertDrive)
r.Delete("/drives/{id}", a.handleDeleteDrive)
r.Post("/drives/{id}/rescan", a.handleRescan)
+113
View File
@@ -6,6 +6,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -192,6 +194,117 @@ func TestHandleListDrivesIncludesTeaserCounts(t *testing.T) {
}
}
func TestHandleDriveStorageReportsLocalMediaUsage(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
cat, err := catalog.Open(filepath.Join(root, "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)
}
})
localDir := filepath.Join(root, "previews")
thumbDir := filepath.Join(localDir, "thumbs")
if err := os.MkdirAll(thumbDir, 0o755); err != nil {
t.Fatalf("mkdir thumbs: %v", err)
}
if err := os.WriteFile(filepath.Join(localDir, "drive-one-video.mp4"), []byte("teaser-one"), 0o644); err != nil {
t.Fatalf("write teaser one: %v", err)
}
if err := os.WriteFile(filepath.Join(localDir, "drive-two-video.mp4"), []byte("teaser-two!!"), 0o644); err != nil {
t.Fatalf("write teaser two: %v", err)
}
if err := os.WriteFile(filepath.Join(thumbDir, "drive-one-video.jpg"), []byte("jpg-one"), 0o644); err != nil {
t.Fatalf("write thumb one: %v", err)
}
if err := os.WriteFile(filepath.Join(thumbDir, "drive-two-video.jpg"), []byte("jpg-two!!"), 0o644); err != nil {
t.Fatalf("write thumb two: %v", err)
}
for _, d := range []*catalog.Drive{
{ID: "drive-one", Kind: "onedrive", Name: "Drive One", RootID: "root", Status: "ok"},
{ID: "drive-two", Kind: "pikpak", Name: "Drive Two", RootID: "", Status: "ok"},
} {
if err := cat.UpsertDrive(ctx, d); err != nil {
t.Fatalf("seed drive %s: %v", d.ID, err)
}
}
now := time.Now()
for _, v := range []*catalog.Video{
{
ID: "drive-one-video",
DriveID: "drive-one",
FileID: "file-one",
Title: "Video One",
PreviewLocal: filepath.Join(localDir, "drive-one-video.mp4"),
PreviewStatus: "ready",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
},
{
ID: "drive-two-video",
DriveID: "drive-two",
FileID: "file-two",
Title: "Video Two",
PreviewLocal: filepath.Join(localDir, "drive-two-video.mp4"),
PreviewStatus: "ready",
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
},
} {
if err := cat.UpsertVideo(ctx, v); err != nil {
t.Fatalf("seed video %s: %v", v.ID, err)
}
}
req := httptest.NewRequest(http.MethodGet, "/admin/api/drives/storage", nil)
rr := httptest.NewRecorder()
(&AdminServer{Catalog: cat, LocalPreviewDir: localDir}).handleDriveStorage(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var got struct {
ThumbnailBytes int64 `json:"thumbnailBytes"`
TeaserBytes int64 `json:"teaserBytes"`
TotalBytes int64 `json:"totalBytes"`
AvailableBytes int64 `json:"availableBytes"`
Drives map[string]struct {
ThumbnailBytes int64 `json:"thumbnailBytes"`
TeaserBytes int64 `json:"teaserBytes"`
TotalBytes int64 `json:"totalBytes"`
} `json:"drives"`
}
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.ThumbnailBytes != int64(len("jpg-one")+len("jpg-two!!")) {
t.Fatalf("thumbnail bytes = %d, want %d", got.ThumbnailBytes, len("jpg-one")+len("jpg-two!!"))
}
if got.TeaserBytes != int64(len("teaser-one")+len("teaser-two!!")) {
t.Fatalf("teaser bytes = %d, want %d", got.TeaserBytes, len("teaser-one")+len("teaser-two!!"))
}
if got.TotalBytes != got.ThumbnailBytes+got.TeaserBytes {
t.Fatalf("total bytes = %d, want thumbnail + teaser", got.TotalBytes)
}
if got.AvailableBytes <= 0 {
t.Fatalf("available bytes = %d, want positive", got.AvailableBytes)
}
if got.Drives["drive-one"].ThumbnailBytes != int64(len("jpg-one")) ||
got.Drives["drive-one"].TeaserBytes != int64(len("teaser-one")) {
t.Fatalf("drive-one usage = %#v", got.Drives["drive-one"])
}
if got.Drives["drive-two"].TotalBytes != int64(len("jpg-two!!")+len("teaser-two!!")) {
t.Fatalf("drive-two total = %d, want %d", got.Drives["drive-two"].TotalBytes, len("jpg-two!!")+len("teaser-two!!"))
}
}
func TestHandleCreateTagClassifiesExistingVideos(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
+15 -1
View File
@@ -313,7 +313,7 @@ func (s *Server) handleUploadVideo(w http.ResponseWriter, r *http.Request) {
now := time.Now()
title := strings.TrimSpace(r.FormValue("title"))
if title == "" {
title = "upload-" + now.Format("20060102150405")
title = uploadTitleFromFileName(originalName)
}
uploadID, err := newUploadID(now)
@@ -747,6 +747,20 @@ func uploadTagValues(r *http.Request) []string {
return values
}
func uploadTitleFromFileName(fileName string) string {
name := strings.TrimSpace(filepath.Base(fileName))
ext := filepath.Ext(name)
if ext != "" {
if trimmed := strings.TrimSuffix(name, ext); strings.TrimSpace(trimmed) != "" {
return trimmed
}
}
if name != "" {
return name
}
return "upload-" + time.Now().Format("20060102150405")
}
func parseUploadTags(values []string) ([]string, error) {
seen := make(map[string]struct{})
out := make([]string, 0, len(values))
+4 -4
View File
@@ -130,7 +130,7 @@ func TestHandleUploadVideoSavesFileVideoTagsAndQueuesPreview(t *testing.T) {
}
}
func TestHandleUploadVideoDefaultsBlankTitleToTimestamp(t *testing.T) {
func TestHandleUploadVideoDefaultsBlankTitleToOriginalFileName(t *testing.T) {
ctx := context.Background()
cat, err := catalog.Open(t.TempDir() + "/catalog.db")
if err != nil {
@@ -142,7 +142,7 @@ func TestHandleUploadVideoDefaultsBlankTitleToTimestamp(t *testing.T) {
}
})
server := &Server{Catalog: cat, LocalDir: t.TempDir()}
req := multipartUploadRequest(t, map[string]string{"title": " "}, "clip.mp4", "video-bytes")
req := multipartUploadRequest(t, map[string]string{"title": " "}, "holiday.clip.final.mp4", "video-bytes")
rr := httptest.NewRecorder()
server.handleUploadVideo(rr, req)
@@ -158,8 +158,8 @@ func TestHandleUploadVideoDefaultsBlankTitleToTimestamp(t *testing.T) {
if err != nil {
t.Fatalf("get uploaded video: %v", err)
}
if got.Title == "" || !strings.HasPrefix(got.Title, "upload-") {
t.Fatalf("title = %q, want upload timestamp fallback", got.Title)
if got.Title != "holiday.clip.final" {
t.Fatalf("title = %q, want original file name without extension", got.Title)
}
}
+69
View File
@@ -0,0 +1,69 @@
package api
import (
"context"
"errors"
"net/http"
"os"
"strings"
"golang.org/x/sys/unix"
"github.com/video-site/backend/internal/catalog"
"github.com/video-site/backend/internal/storageusage"
)
func (a *AdminServer) handleDriveStorage(w http.ResponseWriter, r *http.Request) {
usage, err := collectLocalMediaStorage(r.Context(), a.Catalog, a.LocalPreviewDir)
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, usage)
}
func collectLocalMediaStorage(ctx context.Context, cat *catalog.Catalog, localDir string) (storageusage.Usage, error) {
if cat == nil {
return storageusage.Usage{}, errors.New("catalog is not configured")
}
localDir = strings.TrimSpace(localDir)
if localDir == "" {
return storageusage.Usage{}, errors.New("local preview dir is not configured")
}
if err := os.MkdirAll(localDir, 0o755); err != nil {
return storageusage.Usage{}, err
}
drives, err := cat.ListDrives(ctx)
if err != nil {
return storageusage.Usage{}, err
}
refs, err := cat.ListLocalMediaRefs(ctx)
if err != nil {
return storageusage.Usage{}, err
}
driveIDs := make([]string, 0, len(drives))
for _, drive := range drives {
driveIDs = append(driveIDs, drive.ID)
}
assetRefs := make([]storageusage.VideoAssetRef, 0, len(refs))
for _, ref := range refs {
assetRefs = append(assetRefs, storageusage.VideoAssetRef{
ID: ref.VideoID,
DriveID: ref.DriveID,
PreviewLocal: ref.PreviewLocal,
})
}
return storageusage.Compute(localDir, assetRefs, driveIDs, localDiskStats)
}
func localDiskStats(path string) (storageusage.DiskStats, error) {
var stat unix.Statfs_t
if err := unix.Statfs(path, &stat); err != nil {
return storageusage.DiskStats{}, err
}
blockSize := uint64(stat.Bsize)
return storageusage.DiskStats{
AvailableBytes: int64(uint64(stat.Bavail) * blockSize),
CapacityBytes: int64(uint64(stat.Blocks) * blockSize),
}, nil
}
+29
View File
@@ -503,6 +503,35 @@ func (c *Catalog) CountTeasersByDrive(ctx context.Context) (map[string]DriveTeas
return out, nil
}
type LocalMediaRef struct {
DriveID string
VideoID string
PreviewLocal string
}
func (c *Catalog) ListLocalMediaRefs(ctx context.Context) ([]LocalMediaRef, error) {
rows, err := c.db.QueryContext(ctx,
`SELECT drive_id, id, COALESCE(preview_local, '')
FROM videos`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LocalMediaRef
for rows.Next() {
var ref LocalMediaRef
if err := rows.Scan(&ref.DriveID, &ref.VideoID, &ref.PreviewLocal); err != nil {
return nil, err
}
out = append(out, ref)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
// ---------- Drive ----------
type Drive struct {
@@ -0,0 +1,140 @@
package storageusage
import (
"errors"
"os"
"path/filepath"
"strings"
)
type VideoAssetRef struct {
ID string
DriveID string
PreviewLocal string
}
type DiskStats struct {
AvailableBytes int64 `json:"availableBytes"`
CapacityBytes int64 `json:"capacityBytes"`
}
type DriveUsage struct {
ThumbnailBytes int64 `json:"thumbnailBytes"`
TeaserBytes int64 `json:"teaserBytes"`
TotalBytes int64 `json:"totalBytes"`
}
type Usage struct {
ThumbnailBytes int64 `json:"thumbnailBytes"`
TeaserBytes int64 `json:"teaserBytes"`
TotalBytes int64 `json:"totalBytes"`
AvailableBytes int64 `json:"availableBytes"`
CapacityBytes int64 `json:"capacityBytes"`
Drives map[string]DriveUsage `json:"drives"`
}
func Compute(
localDir string,
refs []VideoAssetRef,
driveIDs []string,
diskStats func(string) (DiskStats, error),
) (Usage, error) {
localDir = strings.TrimSpace(localDir)
if localDir == "" {
return Usage{}, errors.New("local preview dir is not configured")
}
if diskStats == nil {
diskStats = func(string) (DiskStats, error) { return DiskStats{}, nil }
}
stats, err := diskStats(localDir)
if err != nil {
return Usage{}, err
}
out := Usage{
AvailableBytes: stats.AvailableBytes,
CapacityBytes: stats.CapacityBytes,
Drives: make(map[string]DriveUsage, len(driveIDs)),
}
allowed := make(map[string]bool, len(driveIDs))
for _, id := range driveIDs {
if id == "" {
continue
}
allowed[id] = true
out.Drives[id] = DriveUsage{}
}
seen := make(map[string]bool)
for _, ref := range refs {
if ref.ID == "" || ref.DriveID == "" || !allowed[ref.DriveID] {
continue
}
driveUsage := out.Drives[ref.DriveID]
thumbPath := filepath.Join(localDir, "thumbs", ref.ID+".jpg")
if size, exists, err := regularFileSize(thumbPath); err != nil {
return Usage{}, err
} else if exists {
key := ref.DriveID + "\x00thumb\x00" + thumbPath
if !seen[key] {
driveUsage.ThumbnailBytes += size
seen[key] = true
}
}
if previewPath, ok := pathWithin(localDir, ref.PreviewLocal); ok {
if size, exists, err := regularFileSize(previewPath); err != nil {
return Usage{}, err
} else if exists {
key := ref.DriveID + "\x00teaser\x00" + previewPath
if !seen[key] {
driveUsage.TeaserBytes += size
seen[key] = true
}
}
}
driveUsage.TotalBytes = driveUsage.ThumbnailBytes + driveUsage.TeaserBytes
out.Drives[ref.DriveID] = driveUsage
}
for _, driveUsage := range out.Drives {
out.ThumbnailBytes += driveUsage.ThumbnailBytes
out.TeaserBytes += driveUsage.TeaserBytes
}
out.TotalBytes = out.ThumbnailBytes + out.TeaserBytes
return out, nil
}
func regularFileSize(path string) (int64, bool, error) {
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return 0, false, nil
}
return 0, false, err
}
if !info.Mode().IsRegular() {
return 0, false, nil
}
return info.Size(), true, nil
}
func pathWithin(root, path string) (string, bool) {
if strings.TrimSpace(path) == "" {
return "", false
}
rootAbs, err := filepath.Abs(root)
if err != nil {
return "", false
}
pathAbs, err := filepath.Abs(path)
if err != nil {
return "", false
}
rel, err := filepath.Rel(rootAbs, pathAbs)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", false
}
return pathAbs, true
}
@@ -0,0 +1,57 @@
package storageusage
import (
"os"
"path/filepath"
"testing"
)
func TestComputeCountsLocalThumbnailsAndTeasersByDrive(t *testing.T) {
localDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(localDir, "thumbs"), 0o755); err != nil {
t.Fatalf("mkdir thumbs: %v", err)
}
writeSizedFile(t, filepath.Join(localDir, "thumbs", "video-a.jpg"), 3)
writeSizedFile(t, filepath.Join(localDir, "thumbs", "video-b.jpg"), 5)
teaserA := filepath.Join(localDir, "video-a.mp4")
teaserB := filepath.Join(localDir, "video-b.mp4")
writeSizedFile(t, teaserA, 7)
writeSizedFile(t, teaserB, 11)
outside := filepath.Join(t.TempDir(), "outside.mp4")
writeSizedFile(t, outside, 99)
got, err := Compute(localDir, []VideoAssetRef{
{ID: "video-a", DriveID: "drive-a", PreviewLocal: teaserA},
{ID: "video-a-copy", DriveID: "drive-a", PreviewLocal: teaserA},
{ID: "video-b", DriveID: "drive-b", PreviewLocal: teaserB},
{ID: "outside", DriveID: "drive-b", PreviewLocal: outside},
{ID: "unknown-drive-video", DriveID: "missing", PreviewLocal: teaserB},
}, []string{"drive-a", "drive-b"}, func(string) (DiskStats, error) {
return DiskStats{AvailableBytes: 123, CapacityBytes: 456}, nil
})
if err != nil {
t.Fatalf("compute: %v", err)
}
if got.AvailableBytes != 123 || got.CapacityBytes != 456 {
t.Fatalf("disk stats = available:%d capacity:%d, want 123/456", got.AvailableBytes, got.CapacityBytes)
}
driveA := got.Drives["drive-a"]
if driveA.ThumbnailBytes != 3 || driveA.TeaserBytes != 7 || driveA.TotalBytes != 10 {
t.Fatalf("drive-a usage = %#v, want thumbnails=3 teaser=7 total=10", driveA)
}
driveB := got.Drives["drive-b"]
if driveB.ThumbnailBytes != 5 || driveB.TeaserBytes != 11 || driveB.TotalBytes != 16 {
t.Fatalf("drive-b usage = %#v, want thumbnails=5 teaser=11 total=16", driveB)
}
if got.ThumbnailBytes != 8 || got.TeaserBytes != 18 || got.TotalBytes != 26 {
t.Fatalf("totals = %#v, want thumbnails=8 teaser=18 total=26", got)
}
}
func writeSizedFile(t *testing.T, path string, size int) {
t.Helper()
if err := os.WriteFile(path, make([]byte, size), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
+49 -1
View File
@@ -3,6 +3,7 @@ import { Plus, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import * as api from "./api";
import { useToast } from "./ToastContext";
import { Modal } from "./Modal";
import { formatBytes } from "./storageFormat";
const kindLabel: Record<string, string> = {
quark: "夸克网盘",
@@ -34,6 +35,7 @@ const emptyForm: FormState = {
export function DrivesPage() {
const [list, setList] = useState<api.AdminDrive[]>([]);
const [storage, setStorage] = useState<api.AdminDriveStorage | null>(null);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState<FormState>(emptyForm);
@@ -44,8 +46,12 @@ export function DrivesPage() {
async function refresh() {
setLoading(true);
try {
const data = await api.listDrives();
const [data, storageData] = await Promise.all([
api.listDrives(),
api.getDriveStorage(),
]);
setList(data ?? []);
setStorage(storageData);
} catch (e) {
show(e instanceof Error ? e.message : "加载失败", "error");
} finally {
@@ -146,6 +152,8 @@ export function DrivesPage() {
</button>
</header>
{storage && <StorageSummary storage={storage} />}
{loading ? (
<div className="admin-empty">...</div>
) : list.length === 0 ? (
@@ -161,6 +169,7 @@ export function DrivesPage() {
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th>Teaser</th>
<th className="is-actions"></th>
</tr>
@@ -177,6 +186,9 @@ export function DrivesPage() {
<td style={{ fontFamily: "ui-monospace", fontSize: 12 }}>
{d.scanRootId || d.rootId}
</td>
<td>
<StorageCell usage={storage?.drives[d.id]} />
</td>
<td>
<TeaserCounts drive={d} />
</td>
@@ -230,6 +242,42 @@ export function DrivesPage() {
);
}
function StorageSummary({ storage }: { storage: api.AdminDriveStorage }) {
return (
<section className="admin-card admin-storage-summary" aria-label="本地媒体存储">
<div className="admin-storage-summary__metric">
<span></span>
<strong>{formatBytes(storage.thumbnailBytes)}</strong>
</div>
<div className="admin-storage-summary__metric">
<span>Teaser </span>
<strong>{formatBytes(storage.teaserBytes)}</strong>
</div>
<div className="admin-storage-summary__metric">
<span></span>
<strong>{formatBytes(storage.totalBytes)}</strong>
</div>
<div className="admin-storage-summary__metric">
<span></span>
<strong>{formatBytes(storage.availableBytes)}</strong>
</div>
</section>
);
}
function StorageCell({ usage }: { usage?: api.DriveStorageUsage }) {
if (!usage || usage.totalBytes <= 0) {
return <span className="admin-storage-cell__empty">0 B</span>;
}
return (
<div className="admin-storage-cell">
<strong>{formatBytes(usage.totalBytes)}</strong>
<span> {formatBytes(usage.thumbnailBytes)}</span>
<span>Teaser {formatBytes(usage.teaserBytes)}</span>
</div>
);
}
function TeaserCounts({ drive }: { drive: api.AdminDrive }) {
return (
<div className="admin-teaser-counts">
+16
View File
@@ -70,6 +70,22 @@ export function listDrives() {
return request<AdminDrive[]>("/drives");
}
export type DriveStorageUsage = {
thumbnailBytes: number;
teaserBytes: number;
totalBytes: number;
};
export type AdminDriveStorage = DriveStorageUsage & {
availableBytes: number;
capacityBytes: number;
drives: Record<string, DriveStorageUsage>;
};
export function getDriveStorage() {
return request<AdminDriveStorage>("/drives/storage");
}
export type UpsertDriveInput = {
id: string;
kind: "quark" | "p115" | "pikpak" | "wopan" | "onedrive";
+16
View File
@@ -0,0 +1,16 @@
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
if (unitIndex === 0) return `${Math.round(value)} ${units[unitIndex]}`;
const rounded = Number(value.toFixed(1));
return `${rounded} ${units[unitIndex]}`;
}
+1 -11
View File
@@ -1,4 +1,4 @@
import { Globe, LogIn, UserPlus } from "lucide-react";
import { Globe } from "lucide-react";
export function TopBar() {
return (
@@ -10,16 +10,6 @@ export function TopBar() {
</a>
</div>
<div className="top-bar__side">
<a href="#register">
<UserPlus size={12} style={{ marginRight: 4, verticalAlign: -1 }} />
</a>
<a href="#login">
<LogIn size={12} style={{ marginRight: 4, verticalAlign: -1 }} />
</a>
</div>
</div>
</div>
);
+8
View File
@@ -0,0 +1,8 @@
export function defaultUploadTitleFromFileName(fileName: string): string {
const baseName = fileName.split(/[\\/]/).pop()?.trim() ?? "";
const lastDot = baseName.lastIndexOf(".");
if (lastDot > 0) {
return baseName.slice(0, lastDot);
}
return baseName;
}
+5 -2
View File
@@ -4,6 +4,7 @@ import { Check, Film, UploadCloud } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { SectionHeader } from "@/components/SectionHeader";
import { uploadVideo } from "@/data/videos";
import { defaultUploadTitleFromFileName } from "@/lib/uploadTitle";
import type { VideoItem } from "@/types";
const UPLOAD_TAGS = ["奶子", "臀", "口角", "女大", "人妻", "AV"];
@@ -27,7 +28,9 @@ export default function UploadPage() {
}, [file]);
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
setFile(event.target.files?.[0] ?? null);
const nextFile = event.target.files?.[0] ?? null;
setFile(nextFile);
setTitle(nextFile ? defaultUploadTitleFromFileName(nextFile.name) : "");
setUploaded(null);
setError("");
}
@@ -83,7 +86,7 @@ export default function UploadPage() {
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="不填则使用当前时间戳"
placeholder="选择文件后自动填入"
maxLength={120}
/>
</label>
+135 -1
View File
@@ -116,6 +116,28 @@
gap: var(--space-2);
}
.admin-storage-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 14px;
padding: var(--space-4);
}
.admin-storage-summary__metric {
display: grid;
gap: 3px;
}
.admin-storage-summary__metric span {
color: var(--color-muted);
font-size: 12px;
}
.admin-storage-summary__metric strong {
font-size: 18px;
font-weight: 650;
}
.admin-form {
display: grid;
gap: var(--space-3);
@@ -273,6 +295,23 @@
min-width: 150px;
}
.admin-storage-cell {
display: grid;
gap: 2px;
min-width: 118px;
}
.admin-storage-cell strong {
font-size: 13px;
}
.admin-storage-cell span,
.admin-storage-cell__empty {
color: var(--color-muted);
font-size: 12px;
white-space: nowrap;
}
.admin-table {
width: 100%;
border-collapse: collapse;
@@ -339,10 +378,12 @@
display: grid;
place-items: center;
background: #f5f5f4;
padding: var(--space-4);
}
.admin-login__card {
width: 360px;
width: min(360px, 100%);
box-sizing: border-box;
padding: var(--space-5);
background: #fff;
border: 1px solid var(--color-line);
@@ -530,6 +571,99 @@
}
.admin-main {
padding: var(--space-3);
overflow-x: hidden;
}
.admin-page__header {
align-items: stretch;
}
.admin-page__header .admin-btn {
justify-content: center;
}
.admin-card {
padding: var(--space-3);
border-radius: var(--radius-sm);
}
.admin-storage-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.admin-storage-summary__metric strong {
font-size: 16px;
}
.admin-form {
max-width: 100%;
}
.admin-form__row input,
.admin-form__row select,
.admin-form__row textarea {
min-width: 0;
width: 100%;
box-sizing: border-box;
}
.admin-form__row--inline {
flex-wrap: wrap;
}
.admin-btn {
padding: 7px 10px;
}
.admin-table {
display: block;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.admin-table th,
.admin-table td {
white-space: nowrap;
}
.admin-table td.is-actions {
min-width: 168px;
white-space: normal;
}
.admin-table td.is-actions .admin-btn {
margin-bottom: 6px;
}
.admin-modal-backdrop {
align-items: stretch;
padding: var(--space-2);
}
.admin-modal {
width: 100%;
max-height: calc(100vh - 16px);
}
.admin-modal__header,
.admin-modal__body,
.admin-modal__footer {
padding: var(--space-3);
}
.admin-modal__footer {
flex-wrap: wrap;
}
.admin-modal__footer .admin-btn {
flex: 1 1 auto;
justify-content: center;
}
.admin-empty {
padding: var(--space-4);
}
}
+2
View File
@@ -149,6 +149,8 @@
}
.main-nav.is-open .main-nav__link {
display: flex;
width: 100%;
height: auto;
padding: var(--space-3) var(--space-2);
border-bottom: 1px solid #1f1f1f;
+53
View File
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const adminCss = readFileSync(
new URL("../src/styles/admin.css", import.meta.url),
"utf8"
);
function ruleBody(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
assert.ok(match, `Expected CSS rule for ${selector}`);
return match[1];
}
function allRuleBodies(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return Array.from(css.matchAll(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`, "g")))
.map((match) => match[1])
.join("\n");
}
function mobileCss(): string {
const marker = "@media (max-width: 768px)";
const start = adminCss.indexOf(marker);
assert.notEqual(start, -1, "Expected mobile admin media query");
return adminCss.slice(start);
}
test("admin login card fits narrow phone screens", () => {
const body = ruleBody(adminCss, ".admin-login__card");
assert.match(body, /width\s*:\s*min\(360px,\s*100%\)/);
assert.match(body, /box-sizing\s*:\s*border-box/);
});
test("admin tables scroll inside the mobile viewport", () => {
const css = mobileCss();
const body = ruleBody(css, ".admin-table");
assert.match(body, /display\s*:\s*block/);
assert.match(body, /max-width\s*:\s*100%/);
assert.match(body, /overflow-x\s*:\s*auto/);
});
test("admin modals and action footers adapt on mobile", () => {
const css = mobileCss();
assert.match(ruleBody(css, ".admin-modal"), /width\s*:\s*100%/);
assert.match(allRuleBodies(css, ".admin-modal__footer"), /flex-wrap\s*:\s*wrap/);
assert.match(ruleBody(css, ".admin-form"), /max-width\s*:\s*100%/);
});
+32
View File
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const navigationCss = readFileSync(
new URL("../src/styles/navigation.css", import.meta.url),
"utf8"
);
const topBarSource = readFileSync(
new URL("../src/components/TopBar.tsx", import.meta.url),
"utf8"
);
function ruleBody(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
assert.ok(match, `Expected CSS rule for ${selector}`);
return match[1];
}
test("mobile menu links fill the full expanded menu row", () => {
const body = ruleBody(navigationCss, ".main-nav.is-open .main-nav__link");
assert.match(body, /display\s*:\s*flex\b/);
assert.match(body, /width\s*:\s*100%/);
});
test("top bar does not render inactive public auth links", () => {
assert.doesNotMatch(topBarSource, /href="#(?:register|login)"/);
assert.doesNotMatch(topBarSource, />\s*(?:注册|登录)\s*</);
});
+12
View File
@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import { formatBytes } from "../src/admin/storageFormat.ts";
test("formats byte counts for storage usage display", () => {
assert.equal(formatBytes(0), "0 B");
assert.equal(formatBytes(512), "512 B");
assert.equal(formatBytes(1536), "1.5 KB");
assert.equal(formatBytes(2 * 1024 * 1024), "2 MB");
assert.equal(formatBytes(3.25 * 1024 * 1024 * 1024), "3.3 GB");
});
+12
View File
@@ -0,0 +1,12 @@
import assert from "node:assert/strict";
import test from "node:test";
import { defaultUploadTitleFromFileName } from "../src/lib/uploadTitle.ts";
test("uses the selected file name without the extension as the default upload title", () => {
assert.equal(defaultUploadTitleFromFileName("holiday.clip.final.mp4"), "holiday.clip.final");
});
test("falls back to the full file name when there is no extension", () => {
assert.equal(defaultUploadTitleFromFileName("clip"), "clip");
});