feat: add ImageFind application and release pipelines
@@ -0,0 +1,25 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist/**", "node_modules/**", "tests/**/*.spec.ts"] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}", "*.config.ts"],
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
},
|
||||
plugins: { "react-hooks": reactHooks },
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
"react-hooks/purity": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
||||
"no-empty": ["error", { allowEmptyCatch: true }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f7f8fa" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<script>
|
||||
(() => {
|
||||
const saved = localStorage.getItem("imagefind:theme");
|
||||
const preference = saved === "light" || saved === "dark" ? saved : "system";
|
||||
const dark = preference === "dark" || (preference === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
document.documentElement.dataset.themePreference = preference;
|
||||
document.documentElement.dataset.theme = dark ? "dark" : "light";
|
||||
document.documentElement.style.colorScheme = dark ? "dark" : "light";
|
||||
document.querySelector('meta[name="theme-color"]').content = dark ? "#0f1115" : "#f7f8fa";
|
||||
})();
|
||||
</script>
|
||||
<title>ImageFind · 私有媒体空间</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "imagefind-web",
|
||||
"private": true,
|
||||
"version": "0.5.45",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -b && vite build",
|
||||
"test:ui": "playwright test",
|
||||
"test:ui:fnos": "playwright test --config playwright.fnos.config.ts",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"hls.js": "latest",
|
||||
"lucide-react": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"typescript": "latest",
|
||||
"vite": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.8.0",
|
||||
"typescript-eslint": "^8.65.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_PORT || 4173);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
testIgnore: "**/fnos.spec.ts",
|
||||
outputDir: "../.playwright-results",
|
||||
// Chromium can take longer to create a fresh page on the low-memory fnOS
|
||||
// release runner after visual-audit contexts have been reclaimed.
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 30_000, toHaveScreenshot: { animations: "disabled", maxDiffPixelRatio: 0.01 } },
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${port}`,
|
||||
locale: "zh-CN",
|
||||
timezoneId: "Asia/Shanghai",
|
||||
colorScheme: "light",
|
||||
},
|
||||
webServer: {
|
||||
command: `npm run dev -- --host 127.0.0.1 --port ${port}`,
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
// CI and release acceptance must own the server process so a stale Vite
|
||||
// instance from a previous batch cannot disappear midway through a run.
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const baseURL = process.env.IMAGEFIND_FNOS_BASE_URL;
|
||||
const storageState = process.env.IMAGEFIND_FNOS_STORAGE_STATE;
|
||||
|
||||
if (!baseURL || !storageState) {
|
||||
throw new Error("真机测试需要 IMAGEFIND_FNOS_BASE_URL 和 IMAGEFIND_FNOS_STORAGE_STATE");
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
testMatch: "**/fnos.spec.ts",
|
||||
outputDir: "../.playwright-results/fnos",
|
||||
timeout: 120_000,
|
||||
expect: { timeout: 30_000 },
|
||||
use: {
|
||||
baseURL,
|
||||
storageState,
|
||||
locale: "zh-CN",
|
||||
timezoneId: "Asia/Shanghai",
|
||||
colorScheme: "light",
|
||||
screenshot: "only-on-failure",
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
__IMAGEFIND_BASE__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
function normaliseBase(value: string | undefined) {
|
||||
const base = value?.trim() || "/";
|
||||
return `/${base.replace(/^\/+|\/+$/g, "")}${base === "/" ? "" : "/"}`;
|
||||
}
|
||||
|
||||
export const appBase = normaliseBase(window.__IMAGEFIND_BASE__);
|
||||
|
||||
export function appUrl(value: string | undefined): string {
|
||||
if (!value) return "";
|
||||
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(value)) return value;
|
||||
const target = `${appBase}${value.replace(/^\/+/, "")}`;
|
||||
const path = `/${value.replace(/^\/+/, "")}`;
|
||||
const mediaPath = /^\/api\/v1\/(?:frames\/[^/]+\/thumbnail|faces\/[^/]+\/thumbnail|videos\/[^/]+\/(?:stream|download)|previews\/)/.test(path);
|
||||
if (appBase === "/" || !gatewayMediaToken || !mediaPath) return target;
|
||||
return `${target}${target.includes("?") ? "&" : "?"}media_token=${encodeURIComponent(gatewayMediaToken)}`;
|
||||
}
|
||||
|
||||
let csrfToken = sessionStorage.getItem("imagefind:csrf") || "";
|
||||
let gatewaySessionToken = sessionStorage.getItem("imagefind:gateway-session") || "";
|
||||
let gatewayMediaToken = sessionStorage.getItem("imagefind:gateway-media-token") || "";
|
||||
let gatewayRenewal: Promise<void> | null = null;
|
||||
const inflightReads = new Map<string, Promise<unknown>>();
|
||||
|
||||
export function appMediaUrl(value: string | undefined): string {
|
||||
if (!value) return "";
|
||||
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(value)) return value;
|
||||
const target = appUrl(value);
|
||||
if (appBase === "/" || !gatewayMediaToken) return target;
|
||||
const hashAt = target.indexOf("#");
|
||||
const body = hashAt >= 0 ? target.slice(0, hashAt) : target;
|
||||
const hash = hashAt >= 0 ? target.slice(hashAt) : "";
|
||||
if (/(?:^|[?&])media_token=/.test(body)) return target;
|
||||
const separator = body.includes("?") ? "&" : "?";
|
||||
return `${body}${separator}media_token=${encodeURIComponent(gatewayMediaToken)}${hash}`;
|
||||
}
|
||||
|
||||
export function setCsrf(token: string) {
|
||||
csrfToken = token;
|
||||
if (token) sessionStorage.setItem("imagefind:csrf", token);
|
||||
else sessionStorage.removeItem("imagefind:csrf");
|
||||
}
|
||||
|
||||
export function setGatewaySession(token: string) {
|
||||
gatewaySessionToken = token;
|
||||
if (token) sessionStorage.setItem("imagefind:gateway-session", token);
|
||||
else sessionStorage.removeItem("imagefind:gateway-session");
|
||||
}
|
||||
|
||||
export function setGatewayMediaToken(token: string) {
|
||||
gatewayMediaToken = token;
|
||||
if (token) sessionStorage.setItem("imagefind:gateway-media-token", token);
|
||||
else sessionStorage.removeItem("imagefind:gateway-media-token");
|
||||
}
|
||||
|
||||
async function renewGatewaySession(): Promise<void> {
|
||||
if (appBase === "/") throw new Error("需要管理员登录");
|
||||
if (!gatewayRenewal) {
|
||||
gatewayRenewal = fetch(appUrl("/api/v1/auth/gateway"), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
}).then(async response => {
|
||||
if (!response.ok) throw new Error("飞牛管理员会话需要重新建立");
|
||||
const value = await response.json() as {
|
||||
csrf_token: string;
|
||||
gateway_session_token: string;
|
||||
gateway_media_token: string;
|
||||
};
|
||||
setGatewaySession(value.gateway_session_token);
|
||||
setGatewayMediaToken(value.gateway_media_token);
|
||||
setCsrf(value.csrf_token);
|
||||
window.dispatchEvent(new CustomEvent("imagefind-gateway-renewed"));
|
||||
}).finally(() => { gatewayRenewal = null; });
|
||||
}
|
||||
return gatewayRenewal;
|
||||
}
|
||||
|
||||
async function request(path: string, options: RequestInit = {}): Promise<Response> {
|
||||
const send = () => {
|
||||
const headers = new Headers(options.headers);
|
||||
if (typeof options.body === "string" && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
||||
if (options.method && !["GET", "HEAD"].includes(options.method.toUpperCase()) && csrfToken) {
|
||||
headers.set("X-CSRF-Token", csrfToken);
|
||||
}
|
||||
if (appBase !== "/" && gatewaySessionToken) headers.set("X-ImageFind-Gateway-Session", gatewaySessionToken);
|
||||
return fetch(appUrl(`/api/v1${path}`), { ...options, headers, credentials: "same-origin" });
|
||||
};
|
||||
let response = await send();
|
||||
if (response.status === 401 && appBase !== "/" && path !== "/auth/gateway") {
|
||||
await renewGatewaySession();
|
||||
response = await send();
|
||||
}
|
||||
if (!response.ok) {
|
||||
let message = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const payload = await response.json();
|
||||
message = typeof payload.detail === "string" ? payload.detail : payload.detail?.message || message;
|
||||
} catch { /* response is not JSON */ }
|
||||
throw new Error(message);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const method = String(options.method || "GET").toUpperCase();
|
||||
if (method === "GET" && options.body === undefined) {
|
||||
const existing = inflightReads.get(path);
|
||||
if (existing) return existing as Promise<T>;
|
||||
const pending = request(path, options).then(response =>
|
||||
response.status === 204 ? undefined : response.json()
|
||||
).finally(() => inflightReads.delete(path));
|
||||
inflightReads.set(path, pending);
|
||||
return pending as Promise<T>;
|
||||
}
|
||||
const response = await request(path, options);
|
||||
if (response.status === 204) return undefined as T;
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function downloadBackup(scope: "keys" | "full", password: string) {
|
||||
const response = await request("/backups/export", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ scope, password }),
|
||||
});
|
||||
const disposition = response.headers.get("Content-Disposition") || "";
|
||||
const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
||||
const plain = disposition.match(/filename="?([^";]+)"?/i)?.[1];
|
||||
const filename = encoded ? decodeURIComponent(encoded) : plain || `imagefind-backup-${scope}.ifbackup`;
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
return { filename, size_bytes: blob.size };
|
||||
}
|
||||
|
||||
export async function downloadGeneratedBackup(exportId: string) {
|
||||
const response = await request(`/backups/${encodeURIComponent(exportId)}/download`);
|
||||
const disposition = response.headers.get("Content-Disposition") || "";
|
||||
const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
||||
const plain = disposition.match(/filename="?([^";]+)"?/i)?.[1];
|
||||
const filename = encoded ? decodeURIComponent(encoded) : plain || `imagefind-backup-${exportId}.ifbackup`;
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
return { filename, size_bytes: blob.size };
|
||||
}
|
||||
|
||||
export async function restoreBackup<T>(file: File, password: string, confirmed: boolean): Promise<T> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("password", password);
|
||||
form.append("confirmed", String(confirmed));
|
||||
return api<T>("/backups/restore", { method: "POST", body: form });
|
||||
}
|
||||
|
||||
export async function uploadImage(file: File): Promise<{ id: string }> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return api("/query-images", { method: "POST", body: form });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { appBase } from "./api";
|
||||
import "./styles.css";
|
||||
|
||||
document.documentElement.dataset.imagefindAccess=appBase==="/"?"direct":"gateway";
|
||||
|
||||
function syncVisualViewport(){
|
||||
const height=Math.round(window.visualViewport?.height||window.innerHeight);
|
||||
document.documentElement.style.setProperty("--imagefind-visual-height",`${height}px`);
|
||||
document.documentElement.style.setProperty("--imagefind-sheet-height",`${Math.round(height*.82)}px`);
|
||||
}
|
||||
syncVisualViewport();
|
||||
window.addEventListener("resize",syncVisualViewport,{passive:true});
|
||||
window.visualViewport?.addEventListener("resize",syncVisualViewport,{passive:true});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode><App /></React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,120 @@
|
||||
import { expect, Page, test } from "@playwright/test";
|
||||
|
||||
const sourceName=process.env.IMAGEFIND_E2E_SOURCE_NAME;
|
||||
|
||||
async function appApi<T>(page:Page,path:string,options:RequestInit={}):Promise<T>{
|
||||
return page.evaluate(async({path,options})=>{
|
||||
const base=(window.__IMAGEFIND_BASE__||"/").replace(/\/$/,"");
|
||||
const headers=new Headers(options.headers);const csrf=sessionStorage.getItem("imagefind:csrf");
|
||||
if(csrf)headers.set("X-CSRF-Token",csrf);
|
||||
if(typeof options.body==="string")headers.set("Content-Type","application/json");
|
||||
const response=await fetch(`${base}/api/v1${path}`,{...options,headers,credentials:"same-origin"});
|
||||
if(!response.ok)throw new Error(`${response.status} ${await response.text()}`);
|
||||
return response.status===204?null:response.json();
|
||||
},{path,options}) as Promise<T>;
|
||||
}
|
||||
|
||||
test("fnOS deployment renders the app shell and system-history navigation",async({page})=>{
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await page.goto("");
|
||||
await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible();
|
||||
const homeLayout=await page.evaluate(()=>{const content=document.querySelector<HTMLElement>(".content")!;const shell=document.querySelector<HTMLElement>(".app-shell")!;return {access:document.documentElement.dataset.imagefindAccess,topbarTop:Math.round(document.querySelector<HTMLElement>(".topbar")!.getBoundingClientRect().top),contentOverflow:getComputedStyle(content).overflowY,shellOverflow:getComputedStyle(shell).overflow,documentHeight:document.documentElement.scrollHeight,viewportHeight:innerHeight}});
|
||||
expect(["direct","gateway"]).toContain(homeLayout.access);
|
||||
expect(homeLayout).toMatchObject({topbarTop:0,contentOverflow:"auto",shellOverflow:"hidden"});
|
||||
expect(homeLayout.documentHeight).toBeLessThanOrEqual(homeLayout.viewportHeight+1);
|
||||
await expect(page.locator(".sidebar nav > button:visible")).toHaveCount(5);
|
||||
await page.getByRole("button",{name:"更多"}).click();
|
||||
await expect(page.locator(".mobile-more-menu").getByText("退出登录",{exact:true})).toHaveCount(homeLayout.access==="gateway"?0:1);
|
||||
await expect(page.getByText("返回飞牛桌面",{exact:true})).toHaveCount(0);
|
||||
await page.locator(".mobile-more-grid>.mobile-more-item").filter({hasText:"设置"}).click();
|
||||
await expect(page.getByRole("heading",{name:"设置"})).toBeVisible();
|
||||
await expect(page.locator(".topbar")).toBeHidden();
|
||||
await expect(page.locator(".sidebar")).toBeHidden();
|
||||
await expect(page.locator(".content")).not.toHaveClass(/sheet-locked/);
|
||||
await expect.poll(()=>page.locator(".content").evaluate(element=>element.scrollHeight-element.clientHeight)).toBeGreaterThan(320);
|
||||
await page.locator(".content").evaluate(element=>element.scrollTo({top:320,behavior:"instant"}));
|
||||
await expect.poll(()=>page.locator(".content").evaluate(element=>element.scrollTop)).toBeGreaterThan(100);
|
||||
await expect.poll(()=>page.evaluate(()=>window.scrollY)).toBe(0);
|
||||
await page.goBack();
|
||||
await expect(page.getByRole("heading",{name:"最近新增"})).toBeVisible();
|
||||
});
|
||||
|
||||
test("live transfer, profile, resources and media surfaces remain usable",async({page})=>{
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await page.goto("");
|
||||
const status=await appApi<{configured:boolean;version:string;access_mode:string}>(page,"/status");
|
||||
expect(status).toMatchObject({configured:true,version:"0.5.45"});
|
||||
|
||||
await page.getByRole("button",{name:"更多"}).click();
|
||||
await page.locator(".mobile-more-grid>.mobile-more-item").filter({hasText:"上传中心"}).click();
|
||||
await expect(page.getByRole("heading",{name:"传输中心"})).toBeVisible();
|
||||
await expect(page.locator(".transfer-tabs>button")).toHaveCount(2);
|
||||
await page.locator(".transfer-tabs").getByRole("button",{name:/后台下载/}).click();
|
||||
const aria2=await appApi<{available:boolean;running:boolean}>(page,"/downloads/runtime");
|
||||
expect(typeof aria2.available).toBe("boolean");
|
||||
await expect(page.locator(".download-list")).toBeVisible();
|
||||
|
||||
await page.goBack();
|
||||
await page.locator(".mobile-profile-button").click();
|
||||
await expect(page.getByRole("button",{name:"个人资料"})).toBeVisible();
|
||||
await expect(page.locator(".profile-stats article")).toHaveCount(4);
|
||||
|
||||
await page.getByRole("button",{name:"更多"}).click();
|
||||
await page.locator(".mobile-more-grid>.mobile-more-item").filter({hasText:"设置"}).click();
|
||||
await page.getByRole("button",{name:"任务与偏好"}).click();
|
||||
await expect(page.locator(".resource-panel")).toContainText("前台访问优先");
|
||||
await expect(page.locator(".resource-lanes article")).toHaveCount(4);
|
||||
const resources=await appApi<{profile:string;policy:{cpu_pause_percent:number;cpu_resume_percent:number}}>(page,"/system/resources");
|
||||
expect(["conservative","balanced","turbo"]).toContain(resources.profile);
|
||||
expect(resources.policy.cpu_resume_percent).toBeLessThan(resources.policy.cpu_pause_percent);
|
||||
|
||||
await page.goBack();
|
||||
await page.locator(".nav-item-1").click();
|
||||
const cards=page.locator(".video-card");
|
||||
if(await cards.count()){
|
||||
await cards.first().click();
|
||||
await expect(page.locator(".player-stage video")).toBeVisible();
|
||||
await expect(page.getByRole("button",{name:"删除视频"})).toBeVisible();
|
||||
const source=await page.locator(".player-stage video").getAttribute("src");
|
||||
expect(source).toContain("/api/v1/videos/");
|
||||
}
|
||||
});
|
||||
|
||||
for(const width of [320,390])test(`live mobile layout has no horizontal overflow at ${width}px`,async({page})=>{
|
||||
await page.setViewportSize({width,height:780});
|
||||
await page.goto("");
|
||||
const geometry=await page.evaluate(()=>({viewport:innerWidth,document:document.documentElement.scrollWidth,content:document.querySelector<HTMLElement>(".content")!.scrollWidth}));
|
||||
expect(geometry.document).toBeLessThanOrEqual(geometry.viewport+1);
|
||||
expect(geometry.content).toBeLessThanOrEqual(geometry.viewport+1);
|
||||
await expect(page.locator(".sidebar nav > button:visible")).toHaveCount(5);
|
||||
});
|
||||
|
||||
test("fnOS isolated source supports upload and test-owned cleanup",async({page})=>{
|
||||
test.skip(!sourceName,"设置 IMAGEFIND_E2E_SOURCE_NAME 后才运行写入测试");
|
||||
await page.setViewportSize({width:390,height:844});
|
||||
await page.goto("");
|
||||
const filename=`E2E-${Date.now()}.mp4`;
|
||||
let sourceId="";
|
||||
try{
|
||||
await page.locator(".sidebar nav > .upload-status").click();
|
||||
const sourceSelect=page.getByLabel("目标媒体库");
|
||||
const option=sourceSelect.locator("option").filter({hasText:sourceName!});
|
||||
await expect(option).toHaveCount(1);
|
||||
sourceId=await option.getAttribute("value")||"";
|
||||
await sourceSelect.selectOption(sourceId);
|
||||
await page.locator('.drop-zone input[type="file"]').setInputFiles({name:filename,mimeType:"video/mp4",buffer:Buffer.from("imagefind isolated e2e fixture")});
|
||||
await page.getByRole("button",{name:"开始上传"}).click();
|
||||
await expect(page.getByText(/已进入后台传输队列|1 个文件已排队/)).toBeVisible();
|
||||
await page.waitForTimeout(1500);
|
||||
const uploads=await appApi<Array<{id:string;filename:string}>>(page,"/uploads?limit=100");
|
||||
expect(uploads.some(item=>item.filename===filename)).toBe(true);
|
||||
}finally{
|
||||
if(sourceId){
|
||||
const videos=await appApi<Array<{source_id:string;source_key:string;display_name:string}>>(page,"/videos?limit=500").catch(()=>[]);
|
||||
const owned=videos.filter(item=>item.source_id===sourceId&&(item.display_name===filename||item.source_key.endsWith(`/${filename}`)||item.source_key===filename));
|
||||
if(owned.length)await appApi(page,"/files/trash",{method:"POST",body:JSON.stringify({source_id:sourceId,keys:owned.map(item=>item.source_key)})}).catch(()=>null);
|
||||
const trash=await appApi<Array<{id:string;display_name:string}>>(page,`/trash?source_id=${encodeURIComponent(sourceId)}`).catch(()=>[]);
|
||||
for(const item of trash.filter(value=>value.display_name===filename))await appApi(page,`/trash/${item.id}`,{method:"DELETE"}).catch(()=>null);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
"document": {
|
||||
"width": 1440,
|
||||
"height": 1464
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1440,
|
||||
"height": 64
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 64,
|
||||
"width": 224,
|
||||
"height": 836
|
||||
},
|
||||
"content": {
|
||||
"x": 224,
|
||||
"y": 0,
|
||||
"width": 1216,
|
||||
"height": 1464
|
||||
},
|
||||
"page": {
|
||||
"x": 224,
|
||||
"y": 64,
|
||||
"width": 1216,
|
||||
"height": 1400
|
||||
},
|
||||
"videoGrid": {
|
||||
"x": 256,
|
||||
"y": 259,
|
||||
"width": 1152,
|
||||
"height": 220
|
||||
},
|
||||
"tagGroups": null,
|
||||
"mobileMore": null
|
||||
}
|
||||
|
After Width: | Height: | Size: 173 KiB |
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
"document": {
|
||||
"width": 1440,
|
||||
"height": 1094
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1440,
|
||||
"height": 64
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 64,
|
||||
"width": 224,
|
||||
"height": 836
|
||||
},
|
||||
"content": {
|
||||
"x": 224,
|
||||
"y": 0,
|
||||
"width": 1216,
|
||||
"height": 1094
|
||||
},
|
||||
"page": {
|
||||
"x": 224,
|
||||
"y": 64,
|
||||
"width": 1216,
|
||||
"height": 1030
|
||||
},
|
||||
"videoGrid": null,
|
||||
"tagGroups": {
|
||||
"x": 256,
|
||||
"y": 198,
|
||||
"width": 1152,
|
||||
"height": 236
|
||||
},
|
||||
"mobileMore": null
|
||||
}
|
||||
|
After Width: | Height: | Size: 126 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"document": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"content": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"page": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 394
|
||||
},
|
||||
"videoGrid": null,
|
||||
"tagGroups": null,
|
||||
"mobileMore": null
|
||||
}
|
||||
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"document": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 58
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 780,
|
||||
"width": 390,
|
||||
"height": 64
|
||||
},
|
||||
"content": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"page": {
|
||||
"x": 0,
|
||||
"y": 58,
|
||||
"width": 390,
|
||||
"height": 1210
|
||||
},
|
||||
"videoGrid": {
|
||||
"x": 12,
|
||||
"y": 268,
|
||||
"width": 378,
|
||||
"height": 175
|
||||
},
|
||||
"tagGroups": null,
|
||||
"mobileMore": null
|
||||
}
|
||||
|
After Width: | Height: | Size: 73 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"document": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"content": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"page": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 298
|
||||
},
|
||||
"videoGrid": null,
|
||||
"tagGroups": null,
|
||||
"mobileMore": null
|
||||
}
|
||||
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"document": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 58
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 780,
|
||||
"width": 390,
|
||||
"height": 64
|
||||
},
|
||||
"content": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"page": {
|
||||
"x": 0,
|
||||
"y": 58,
|
||||
"width": 390,
|
||||
"height": 1212
|
||||
},
|
||||
"videoGrid": {
|
||||
"x": 12,
|
||||
"y": 270,
|
||||
"width": 378,
|
||||
"height": 175
|
||||
},
|
||||
"tagGroups": null,
|
||||
"mobileMore": {
|
||||
"x": 0,
|
||||
"y": 508,
|
||||
"width": 390,
|
||||
"height": 336
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"document": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 58
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 780,
|
||||
"width": 390,
|
||||
"height": 64
|
||||
},
|
||||
"content": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"page": {
|
||||
"x": 0,
|
||||
"y": 58,
|
||||
"width": 390,
|
||||
"height": 1210
|
||||
},
|
||||
"videoGrid": {
|
||||
"x": 12,
|
||||
"y": 268,
|
||||
"width": 378,
|
||||
"height": 175
|
||||
},
|
||||
"tagGroups": null,
|
||||
"mobileMore": {
|
||||
"x": 0,
|
||||
"y": 508,
|
||||
"width": 390,
|
||||
"height": 336
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"document": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"content": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"page": null,
|
||||
"videoGrid": null,
|
||||
"tagGroups": null,
|
||||
"mobileMore": null
|
||||
}
|
||||
|
After Width: | Height: | Size: 59 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"document": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 780,
|
||||
"width": 390,
|
||||
"height": 64
|
||||
},
|
||||
"content": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"page": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 1203
|
||||
},
|
||||
"videoGrid": null,
|
||||
"tagGroups": null,
|
||||
"mobileMore": null
|
||||
}
|
||||
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"document": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 780,
|
||||
"width": 390,
|
||||
"height": 64
|
||||
},
|
||||
"content": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"page": {
|
||||
"x": 0,
|
||||
"y": 96,
|
||||
"width": 390,
|
||||
"height": 728
|
||||
},
|
||||
"videoGrid": null,
|
||||
"tagGroups": null,
|
||||
"mobileMore": null
|
||||
}
|
||||
|
After Width: | Height: | Size: 68 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"document": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"content": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"page": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 341
|
||||
},
|
||||
"videoGrid": null,
|
||||
"tagGroups": null,
|
||||
"mobileMore": null
|
||||
}
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"document": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"content": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"page": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 1297
|
||||
},
|
||||
"videoGrid": null,
|
||||
"tagGroups": {
|
||||
"x": 12,
|
||||
"y": 153,
|
||||
"width": 366,
|
||||
"height": 369
|
||||
},
|
||||
"mobileMore": null
|
||||
}
|
||||
|
After Width: | Height: | Size: 50 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"document": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"content": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"page": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 579
|
||||
},
|
||||
"videoGrid": null,
|
||||
"tagGroups": null,
|
||||
"mobileMore": null
|
||||
}
|
||||
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 1024,
|
||||
"height": 900
|
||||
},
|
||||
"document": {
|
||||
"width": 1024,
|
||||
"height": 2198
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1024,
|
||||
"height": 64
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 64,
|
||||
"width": 224,
|
||||
"height": 836
|
||||
},
|
||||
"content": {
|
||||
"x": 224,
|
||||
"y": 0,
|
||||
"width": 800,
|
||||
"height": 2198
|
||||
},
|
||||
"page": {
|
||||
"x": 224,
|
||||
"y": 64,
|
||||
"width": 800,
|
||||
"height": 2134
|
||||
},
|
||||
"videoGrid": null,
|
||||
"tagGroups": null,
|
||||
"mobileMore": null
|
||||
}
|
||||
|
After Width: | Height: | Size: 254 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
"document": {
|
||||
"width": 1440,
|
||||
"height": 2000
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1440,
|
||||
"height": 64
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 64,
|
||||
"width": 224,
|
||||
"height": 836
|
||||
},
|
||||
"content": {
|
||||
"x": 224,
|
||||
"y": 0,
|
||||
"width": 1216,
|
||||
"height": 2000
|
||||
},
|
||||
"page": {
|
||||
"x": 224,
|
||||
"y": 64,
|
||||
"width": 1216,
|
||||
"height": 1936
|
||||
},
|
||||
"videoGrid": null,
|
||||
"tagGroups": null,
|
||||
"mobileMore": null
|
||||
}
|
||||
|
After Width: | Height: | Size: 255 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 320,
|
||||
"height": 780
|
||||
},
|
||||
"document": {
|
||||
"width": 320,
|
||||
"height": 780
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"content": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 320,
|
||||
"height": 780
|
||||
},
|
||||
"page": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 320,
|
||||
"height": 2967
|
||||
},
|
||||
"videoGrid": null,
|
||||
"tagGroups": null,
|
||||
"mobileMore": null
|
||||
}
|
||||
|
After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 780
|
||||
},
|
||||
"document": {
|
||||
"width": 390,
|
||||
"height": 780
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"content": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 780
|
||||
},
|
||||
"page": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 390,
|
||||
"height": 2908
|
||||
},
|
||||
"videoGrid": null,
|
||||
"tagGroups": null,
|
||||
"mobileMore": null
|
||||
}
|
||||
|
After Width: | Height: | Size: 56 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 768,
|
||||
"height": 900
|
||||
},
|
||||
"document": {
|
||||
"width": 768,
|
||||
"height": 2522
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 768,
|
||||
"height": 64
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 64,
|
||||
"width": 224,
|
||||
"height": 836
|
||||
},
|
||||
"content": {
|
||||
"x": 224,
|
||||
"y": 0,
|
||||
"width": 544,
|
||||
"height": 2522
|
||||
},
|
||||
"page": {
|
||||
"x": 224,
|
||||
"y": 64,
|
||||
"width": 544,
|
||||
"height": 2458
|
||||
},
|
||||
"videoGrid": null,
|
||||
"tagGroups": null,
|
||||
"mobileMore": null
|
||||
}
|
||||
|
After Width: | Height: | Size: 218 KiB |
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"viewport": {
|
||||
"width": 1024,
|
||||
"height": 800
|
||||
},
|
||||
"document": {
|
||||
"width": 1024,
|
||||
"height": 2049
|
||||
},
|
||||
"topbar": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 1024,
|
||||
"height": 64
|
||||
},
|
||||
"sidebar": {
|
||||
"x": 0,
|
||||
"y": 64,
|
||||
"width": 224,
|
||||
"height": 736
|
||||
},
|
||||
"content": {
|
||||
"x": 224,
|
||||
"y": 0,
|
||||
"width": 800,
|
||||
"height": 2049
|
||||
},
|
||||
"page": {
|
||||
"x": 224,
|
||||
"y": 64,
|
||||
"width": 800,
|
||||
"height": 1985
|
||||
},
|
||||
"videoGrid": null,
|
||||
"tagGroups": null,
|
||||
"mobileMore": null
|
||||
}
|
||||
|
After Width: | Height: | Size: 222 KiB |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts"],"version":"6.0.3"}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: { "/api": "http://127.0.0.1:8765" }
|
||||
}
|
||||
});
|
||||