feat: add ImageFind application and release pipelines
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 63 KiB |
@@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"name": "mobile-390",
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"geometry": {
|
||||
"viewportWidth": 390,
|
||||
"documentWidth": 390,
|
||||
"stage": {
|
||||
"x": 0,
|
||||
"width": 390,
|
||||
"height": 219.375,
|
||||
"right": 390
|
||||
},
|
||||
"ratio": 1.7777777777777777,
|
||||
"backVisible": true,
|
||||
"fullscreenVisible": true,
|
||||
"controlsBelow44": 0
|
||||
},
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"name": "desktop-1440",
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"geometry": {
|
||||
"viewportWidth": 1440,
|
||||
"documentWidth": 1440,
|
||||
"stage": {
|
||||
"x": 0,
|
||||
"width": 1440,
|
||||
"height": 810,
|
||||
"right": 1440
|
||||
},
|
||||
"ratio": 1.7777777777777777,
|
||||
"backVisible": true,
|
||||
"fullscreenVisible": true,
|
||||
"controlsBelow44": 3
|
||||
},
|
||||
"errors": []
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,44 @@
|
||||
[
|
||||
{
|
||||
"name": "mobile-390",
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"geometry": {
|
||||
"viewportWidth": 390,
|
||||
"documentWidth": 390,
|
||||
"stage": {
|
||||
"x": 0,
|
||||
"width": 390,
|
||||
"height": 219.375,
|
||||
"right": 390
|
||||
},
|
||||
"ratio": 1.7777777777777777,
|
||||
"backVisible": true,
|
||||
"fullscreenVisible": true,
|
||||
"tapHighlight": "rgba(0, 0, 0, 0)",
|
||||
"controlsBelow44": 0
|
||||
},
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"name": "desktop-1440",
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"geometry": {
|
||||
"viewportWidth": 1440,
|
||||
"documentWidth": 1440,
|
||||
"stage": {
|
||||
"x": 0,
|
||||
"width": 1440,
|
||||
"height": 810,
|
||||
"right": 1440
|
||||
},
|
||||
"ratio": 1.7777777777777777,
|
||||
"backVisible": true,
|
||||
"fullscreenVisible": true,
|
||||
"tapHighlight": "rgba(0, 0, 0, 0)",
|
||||
"controlsBelow44": 3
|
||||
},
|
||||
"errors": []
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 70 KiB |
@@ -0,0 +1,202 @@
|
||||
[
|
||||
{
|
||||
"name": "mobile-390",
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"audits": [
|
||||
{
|
||||
"route": "home",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 30,
|
||||
"buttonsBelow44": 11
|
||||
},
|
||||
{
|
||||
"route": "search",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 18,
|
||||
"buttonsBelow44": 9
|
||||
},
|
||||
{
|
||||
"route": "library",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 15,
|
||||
"buttonsBelow44": 5
|
||||
},
|
||||
{
|
||||
"route": "collections",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 20,
|
||||
"buttonsBelow44": 9
|
||||
},
|
||||
{
|
||||
"route": "uploads",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 15,
|
||||
"buttonsBelow44": 6
|
||||
},
|
||||
{
|
||||
"route": "tags",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 24,
|
||||
"buttonsBelow44": 7
|
||||
},
|
||||
{
|
||||
"route": "people",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 12,
|
||||
"buttonsBelow44": 3
|
||||
},
|
||||
{
|
||||
"route": "profile",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 16,
|
||||
"buttonsBelow44": 3
|
||||
},
|
||||
{
|
||||
"route": "settings",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 16,
|
||||
"buttonsBelow44": 11
|
||||
},
|
||||
{
|
||||
"route": "player/v1",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 18,
|
||||
"buttonsBelow44": 11
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"name": "desktop-1440",
|
||||
"viewport": {
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
"audits": [
|
||||
{
|
||||
"route": "home",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 35,
|
||||
"buttonsBelow44": 13
|
||||
},
|
||||
{
|
||||
"route": "search",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 26,
|
||||
"buttonsBelow44": 13
|
||||
},
|
||||
{
|
||||
"route": "library",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 25,
|
||||
"buttonsBelow44": 12
|
||||
},
|
||||
{
|
||||
"route": "collections",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 27,
|
||||
"buttonsBelow44": 12
|
||||
},
|
||||
{
|
||||
"route": "uploads",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 22,
|
||||
"buttonsBelow44": 13
|
||||
},
|
||||
{
|
||||
"route": "tags",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 31,
|
||||
"buttonsBelow44": 10
|
||||
},
|
||||
{
|
||||
"route": "people",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 19,
|
||||
"buttonsBelow44": 6
|
||||
},
|
||||
{
|
||||
"route": "profile",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 23,
|
||||
"buttonsBelow44": 11
|
||||
},
|
||||
{
|
||||
"route": "settings",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 23,
|
||||
"buttonsBelow44": 9
|
||||
},
|
||||
{
|
||||
"route": "player/v1",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 20,
|
||||
"buttonsBelow44": 17
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
]
|
||||
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 68 KiB |
@@ -0,0 +1,242 @@
|
||||
{
|
||||
"report": [
|
||||
{
|
||||
"name": "mobile-390",
|
||||
"viewport": {
|
||||
"width": 390,
|
||||
"height": 844
|
||||
},
|
||||
"audits": [
|
||||
{
|
||||
"route": "home",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 29,
|
||||
"buttonsBelow44": 3
|
||||
},
|
||||
{
|
||||
"route": "search",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 17,
|
||||
"buttonsBelow44": 3
|
||||
},
|
||||
{
|
||||
"route": "library",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 14,
|
||||
"buttonsBelow44": 4
|
||||
},
|
||||
{
|
||||
"route": "collections",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 23,
|
||||
"buttonsBelow44": 6
|
||||
},
|
||||
{
|
||||
"route": "uploads",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 14,
|
||||
"buttonsBelow44": 5
|
||||
},
|
||||
{
|
||||
"route": "tags",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 23,
|
||||
"buttonsBelow44": 6
|
||||
},
|
||||
{
|
||||
"route": "people",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 11,
|
||||
"buttonsBelow44": 2
|
||||
},
|
||||
{
|
||||
"route": "profile",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 15,
|
||||
"buttonsBelow44": 2
|
||||
},
|
||||
{
|
||||
"route": "settings",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 15,
|
||||
"buttonsBelow44": 1
|
||||
},
|
||||
{
|
||||
"route": "player/v1",
|
||||
"documentWidth": 390,
|
||||
"viewportWidth": 390,
|
||||
"bodyWidth": 390,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 17,
|
||||
"buttonsBelow44": 3
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"name": "desktop-1440",
|
||||
"viewport": {
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
"audits": [
|
||||
{
|
||||
"route": "home",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 34,
|
||||
"buttonsBelow44": 12
|
||||
},
|
||||
{
|
||||
"route": "search",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 25,
|
||||
"buttonsBelow44": 12
|
||||
},
|
||||
{
|
||||
"route": "library",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 24,
|
||||
"buttonsBelow44": 11
|
||||
},
|
||||
{
|
||||
"route": "collections",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 30,
|
||||
"buttonsBelow44": 11
|
||||
},
|
||||
{
|
||||
"route": "uploads",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 21,
|
||||
"buttonsBelow44": 12
|
||||
},
|
||||
{
|
||||
"route": "tags",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 30,
|
||||
"buttonsBelow44": 9
|
||||
},
|
||||
{
|
||||
"route": "people",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 18,
|
||||
"buttonsBelow44": 5
|
||||
},
|
||||
{
|
||||
"route": "profile",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 22,
|
||||
"buttonsBelow44": 10
|
||||
},
|
||||
{
|
||||
"route": "settings",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 22,
|
||||
"buttonsBelow44": 8
|
||||
},
|
||||
{
|
||||
"route": "player/v1",
|
||||
"documentWidth": 1440,
|
||||
"viewportWidth": 1440,
|
||||
"bodyWidth": 1440,
|
||||
"horizontalOverflow": false,
|
||||
"visibleButtons": 19,
|
||||
"buttonsBelow44": 16
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
],
|
||||
"interactions": [
|
||||
{
|
||||
"name": "mobile-390 更多面板支持打开、Escape 关闭与页面导航",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "mobile-390 上传弹窗可进入上传状态并转入后台",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "mobile-390 搜索结果跳转到播放器",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "mobile-390 播放、倍速和时间点控件",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "mobile-390 原型控制器切换主题与加载/空/错误状态",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "desktop-1440 上传弹窗可进入上传状态并转入后台",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "desktop-1440 搜索结果跳转到播放器",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "desktop-1440 播放、倍速和时间点控件",
|
||||
"status": "passed"
|
||||
},
|
||||
{
|
||||
"name": "desktop-1440 原型控制器切换主题与加载/空/错误状态",
|
||||
"status": "passed"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# ImageFind 高保真 UI 原型
|
||||
|
||||
这是一个独立的 Vite + React 原型,用来验证 ImageFind 的信息架构、视觉比例、响应式布局和关键交互。它不连接 NAS、数据库或真实媒体源;封面与业务数据均为本地合成演示内容。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
cd ui-prototype
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
构建与自动化检查:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm run audit
|
||||
```
|
||||
|
||||
## 页面
|
||||
|
||||
页面使用 Hash 路由,可直接访问:
|
||||
|
||||
- `#/home`:首页与单列/双列切换
|
||||
- `#/search`:多模态搜索、筛选与片段结果
|
||||
- `#/library`:资料库与媒体来源
|
||||
- `#/collections`:合集、章节和视频目录
|
||||
- `#/uploads`:上传、WebDAV 转存与入库状态
|
||||
- `#/tags`:分类、标签和 AI 建议
|
||||
- `#/people`:人物识别结果
|
||||
- `#/profile`:概览、收藏、喜欢、记录与个人资料
|
||||
- `#/settings`:AI、网络、存储、备份与账户设置
|
||||
- `#/player/v1`:播放器、缓冲进度、时间点和合集目录
|
||||
|
||||
在地址中加入 `?prototype=1` 可显示原型控制器,例如 `/?prototype=1#/home`。控制器可以切换正常、加载、空、错误状态以及浅色、深色、跟随系统主题。正式浏览和自动截图默认隐藏该控制器。
|
||||
|
||||
## 交互范围
|
||||
|
||||
- 桌面侧栏、移动底栏和“更多”底部面板可完成全站导航。
|
||||
- 中央上传入口支持上传选项、演示进度以及“关闭并在后台继续”。
|
||||
- 搜索结果可跳转播放器的命中片段。
|
||||
- 播放器支持播放/暂停、快进/快退、倍速、缓冲状态、时间点与合集目录。
|
||||
- 移动端按 320–720px 适配,主要触控目标不小于 44px,并处理安全区与底栏遮挡。
|
||||
|
||||
## 演示数据说明
|
||||
|
||||
原型中的片名、人物、合集、上传任务、模型状态与存储数据全部为合成示例,仅用于表现界面密度和状态,不代表真实用户内容或产品运行结果。封面由 CSS 离线绘制,不依赖远程图片。
|
||||
@@ -0,0 +1,138 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(new URL("../frontend/package.json", import.meta.url));
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const baseUrl = process.env.PROTOTYPE_URL || "http://127.0.0.1:4177";
|
||||
const output = path.resolve(process.env.PROTOTYPE_AUDIT_DIR || ".artifacts/round-2");
|
||||
const routes = ["home", "search", "library", "collections", "uploads", "tags", "people", "profile", "settings", "player/v1"];
|
||||
const cases = [
|
||||
[390, 844, "mobile-390"],
|
||||
[1440, 900, "desktop-1440"]
|
||||
];
|
||||
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const report = [];
|
||||
const interactions = [];
|
||||
|
||||
async function check(name, action) {
|
||||
try {
|
||||
await action();
|
||||
interactions.push({ name, status: "passed" });
|
||||
} catch (error) {
|
||||
interactions.push({ name, status: "failed", error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [width, height, name] of cases) {
|
||||
const context = await browser.newContext({ viewport: { width, height }, locale: "zh-CN", colorScheme: "light" });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on("pageerror", error => errors.push(`pageerror: ${error.message}`));
|
||||
page.on("console", message => message.type() === "error" && errors.push(`console: ${message.text()}`));
|
||||
const audits = [];
|
||||
for (const route of routes) {
|
||||
await page.goto(`${baseUrl}/#/${route}`, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(250);
|
||||
const geometry = await page.evaluate(() => {
|
||||
const buttons = [...document.querySelectorAll("button")].filter(button => {
|
||||
const style = getComputedStyle(button);
|
||||
const box = button.getBoundingClientRect();
|
||||
return style.display !== "none" && box.width > 0 && box.height > 0;
|
||||
});
|
||||
return {
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
viewportWidth: document.documentElement.clientWidth,
|
||||
bodyWidth: document.body.scrollWidth,
|
||||
horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
visibleButtons: buttons.length,
|
||||
buttonsBelow44: buttons.filter(button => {
|
||||
const box = button.getBoundingClientRect();
|
||||
return box.width < 44 || box.height < 44;
|
||||
}).length
|
||||
};
|
||||
});
|
||||
audits.push({ route, ...geometry });
|
||||
assert.equal(geometry.horizontalOverflow, false, `${name} ${route} 存在横向溢出`);
|
||||
await page.screenshot({ path: path.join(output, `${name}-${route.replace("/", "-")}.png`), fullPage: true });
|
||||
}
|
||||
|
||||
await page.goto(`${baseUrl}/#/home`, { waitUntil: "networkidle" });
|
||||
if (width <= 720) {
|
||||
await check(`${name} 更多面板支持打开、Escape 关闭与页面导航`, async () => {
|
||||
await page.locator(".bottom-nav button").last().click();
|
||||
await page.locator(".more-sheet").waitFor({ state: "visible" });
|
||||
await page.screenshot({ path: path.join(output, `${name}-more.png`), fullPage: true });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator(".more-sheet").waitFor({ state: "detached" });
|
||||
await page.locator(".bottom-nav button").last().click();
|
||||
await page.locator(".more-sheet").getByRole("button", { name: "资料库" }).click();
|
||||
await page.waitForURL(/#\/library$/);
|
||||
await page.goto(`${baseUrl}/#/home`, { waitUntil: "networkidle" });
|
||||
});
|
||||
await page.locator(".upload-fab").click();
|
||||
} else {
|
||||
await page.getByRole("button", { name: "上传中心" }).first().click();
|
||||
await page.getByRole("button", { name: "继续上传" }).click();
|
||||
}
|
||||
await page.locator(".upload-sheet").waitFor({ state: "visible" });
|
||||
await page.screenshot({ path: path.join(output, `${name}-upload-sheet.png`), fullPage: true });
|
||||
await check(`${name} 上传弹窗可进入上传状态并转入后台`, async () => {
|
||||
await page.getByRole("button", { name: "选择演示文件" }).click();
|
||||
await page.locator(".selected-upload").waitFor({ state: "visible" });
|
||||
await page.getByRole("button", { name: "关闭并在后台继续" }).click();
|
||||
await page.locator(".upload-sheet").waitFor({ state: "detached" });
|
||||
await page.locator(".toast").waitFor({ state: "visible" });
|
||||
});
|
||||
|
||||
await check(`${name} 搜索结果跳转到播放器`, async () => {
|
||||
await page.goto(`${baseUrl}/#/search`, { waitUntil: "networkidle" });
|
||||
await page.locator(".result-row").first().click();
|
||||
await page.waitForURL(/#\/player\//);
|
||||
await page.locator(".player-stage").waitFor({ state: "visible" });
|
||||
});
|
||||
|
||||
await page.goto(`${baseUrl}/#/player/v1`, { waitUntil: "networkidle" });
|
||||
await check(`${name} 播放、倍速和时间点控件`, async () => {
|
||||
await page.locator(".main-play").click();
|
||||
await page.locator(".main-play .lucide-pause").waitFor({ state: "visible" });
|
||||
await page.getByRole("button", { name: "1×", exact: true }).click();
|
||||
await page.getByRole("button", { name: "1.5×", exact: true }).waitFor({ state: "visible" });
|
||||
await page.getByRole("button", { name: "时间点", exact: true }).click();
|
||||
await page.locator(".player-drawer").waitFor({ state: "visible" });
|
||||
await page.locator(".timeline>button").first().click({ force: true });
|
||||
assert.equal(await page.locator('.timeline input[type="range"]').inputValue(), "18");
|
||||
});
|
||||
await page.screenshot({ path: path.join(output, `${name}-player-markers.png`), fullPage: true });
|
||||
|
||||
await page.goto(`${baseUrl}/#/settings`, { waitUntil: "networkidle" });
|
||||
await page.evaluate(() => { document.documentElement.dataset.theme = "dark"; });
|
||||
await page.screenshot({ path: path.join(output, `${name}-settings-dark.png`), fullPage: true });
|
||||
|
||||
await check(`${name} 原型控制器切换主题与加载/空/错误状态`, async () => {
|
||||
await page.goto(`${baseUrl}/?prototype=1#/settings`, { waitUntil: "networkidle" });
|
||||
await page.getByRole("button", { name: "原型" }).click();
|
||||
await page.getByLabel("主题").selectOption("dark");
|
||||
assert.equal(await page.locator("html").getAttribute("data-theme"), "dark");
|
||||
await page.getByLabel("数据状态").selectOption("loading");
|
||||
await page.locator(".skeleton-grid").waitFor({ state: "visible" });
|
||||
await page.getByLabel("数据状态").selectOption("empty");
|
||||
await page.locator(".empty-state").waitFor({ state: "visible" });
|
||||
await page.getByLabel("数据状态").selectOption("error");
|
||||
await page.locator(".error-state").waitFor({ state: "visible" });
|
||||
});
|
||||
report.push({ name, viewport: { width, height }, audits, errors: [...new Set(errors)] });
|
||||
await context.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
const result = { report, interactions };
|
||||
fs.writeFileSync(path.join(output, "report.json"), JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
const failures = interactions.filter(item => item.status === "failed");
|
||||
if (failures.length) process.exitCode = 1;
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#f4f6fa" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>ImageFind · 高保真 UI 原型</title>
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
THESIS: 任何视频里的画面、文字、人物和声音都能成为可搜索、可跳转的时间点;拒绝把首页排成文件管理后台。
|
||||
OWN-WORLD: 克制的雾白与深海军蓝表面、清爽品牌蓝、封面主导色彩、原生媒体控件和紧凑状态语言。
|
||||
STORY: 用户先浏览可信的私有媒体,再通过多模态搜索定位某一刻,播放、收藏、整理并看见后台任务的明确结果。
|
||||
FIRST VIEWPORT: 紧凑品牌栏与大搜索框在上,内容筛选与封面网格占据主体,手机底部的中央上传按钮始终一步可达。
|
||||
FORM: 用户明确选择的 B站导航 + 夸克播放器 + Infuse 媒体库 canon;seed key: canon-explicit-reference。
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
|
||||
-->
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,925 @@
|
||||
{
|
||||
"name": "imagefind-ui-prototype",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "imagefind-ui-prototype",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "6.0.4",
|
||||
"lucide-react": "1.27.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"vite": "8.1.5"
|
||||
},
|
||||
"devDependencies": {}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz",
|
||||
"integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3",
|
||||
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.139.0",
|
||||
"resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.139.0.tgz",
|
||||
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
|
||||
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
|
||||
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
|
||||
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
|
||||
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
|
||||
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
|
||||
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.11.1",
|
||||
"@emnapi/runtime": "1.11.1",
|
||||
"@napi-rs/wasm-runtime": "^1.1.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
|
||||
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz",
|
||||
"integrity": "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rolldown/pluginutils": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@rolldown/plugin-babel": {
|
||||
"optional": true
|
||||
},
|
||||
"babel-plugin-react-compiler": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.33.0",
|
||||
"lightningcss-darwin-arm64": "1.33.0",
|
||||
"lightningcss-darwin-x64": "1.33.0",
|
||||
"lightningcss-freebsd-x64": "1.33.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||
"lightningcss-linux-x64-musl": "1.33.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-1.27.0.tgz",
|
||||
"integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.17",
|
||||
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.17.tgz",
|
||||
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.25",
|
||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.25.tgz",
|
||||
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.8.tgz",
|
||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.1.5.tgz",
|
||||
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.139.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-arm64": "1.1.5",
|
||||
"@rolldown/binding-darwin-x64": "1.1.5",
|
||||
"@rolldown/binding-freebsd-x64": "1.1.5",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.1.5",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.1.5",
|
||||
"@rolldown/binding-linux-x64-musl": "1.1.5",
|
||||
"@rolldown/binding-openharmony-arm64": "1.1.5",
|
||||
"@rolldown/binding-wasm32-wasi": "1.1.5",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.5",
|
||||
"resolved": "https://registry.npmmirror.com/vite/-/vite-8.1.5.tgz",
|
||||
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.17",
|
||||
"rolldown": "~1.1.5",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.3.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"sass": "^1.70.0",
|
||||
"sass-embedded": "^1.70.0",
|
||||
"stylus": ">=0.54.8",
|
||||
"sugarss": "^5.0.0",
|
||||
"terser": "^5.16.0",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitejs/devtools": {
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"jiti": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
},
|
||||
"yaml": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "imagefind-ui-prototype",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"audit": "node audit.mjs",
|
||||
"audit:player": "node player-audit.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "6.0.4",
|
||||
"lucide-react": "1.27.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8",
|
||||
"vite": "8.1.5"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(new URL("../frontend/package.json", import.meta.url));
|
||||
const { chromium } = require("playwright");
|
||||
const baseUrl = process.env.PROTOTYPE_URL || "http://127.0.0.1:4178";
|
||||
const output = path.resolve(process.env.PLAYER_AUDIT_DIR || ".artifacts/player-round-1");
|
||||
const cases = [
|
||||
{ name: "mobile-390", width: 390, height: 844 },
|
||||
{ name: "desktop-1440", width: 1440, height: 900 }
|
||||
];
|
||||
|
||||
fs.mkdirSync(output, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const report = [];
|
||||
|
||||
for (const profile of cases) {
|
||||
const context = await browser.newContext({ viewport: profile, locale: "zh-CN" });
|
||||
const page = await context.newPage();
|
||||
const errors = [];
|
||||
page.on("pageerror", error => errors.push(`pageerror: ${error.message}`));
|
||||
page.on("console", message => message.type() === "error" && errors.push(`console: ${message.text()}`));
|
||||
|
||||
await page.goto(`${baseUrl}/#/player/v1`, { waitUntil: "networkidle" });
|
||||
const geometry = await page.evaluate(() => {
|
||||
const stage = document.querySelector(".player-stage-v2").getBoundingClientRect();
|
||||
const back = document.querySelector('.player-topbar button[aria-label="返回"]').getBoundingClientRect();
|
||||
const fullscreen = document.querySelector('.player-controls-row button[aria-label="全屏"]').getBoundingClientRect();
|
||||
const visibleControls = [...document.querySelectorAll(".player-stage-v2 button")].filter(button => {
|
||||
const box = button.getBoundingClientRect();
|
||||
const style = getComputedStyle(button);
|
||||
return style.display !== "none" && style.visibility !== "hidden" && box.width > 0 && box.height > 0;
|
||||
});
|
||||
return {
|
||||
viewportWidth: innerWidth,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
stage: { x: stage.x, width: stage.width, height: stage.height, right: stage.right },
|
||||
ratio: stage.width / stage.height,
|
||||
backVisible: back.left >= 0 && back.right <= innerWidth,
|
||||
fullscreenVisible: fullscreen.left >= 0 && fullscreen.right <= innerWidth,
|
||||
tapHighlight: getComputedStyle(document.querySelector(".main-play")).webkitTapHighlightColor,
|
||||
controlsBelow44: visibleControls.filter(button => {
|
||||
const box = button.getBoundingClientRect();
|
||||
return box.width < 44 || box.height < 44;
|
||||
}).length
|
||||
};
|
||||
});
|
||||
|
||||
assert.equal(geometry.documentWidth, geometry.viewportWidth, `${profile.name} 存在横向溢出`);
|
||||
assert.ok(Math.abs(geometry.ratio - 16 / 9) < 0.02, `${profile.name} 播放舞台不是 16:9`);
|
||||
assert.equal(geometry.backVisible, true, `${profile.name} 返回按钮被裁切`);
|
||||
assert.equal(geometry.fullscreenVisible, true, `${profile.name} 全屏按钮被裁切`);
|
||||
if (profile.width <= 720) assert.equal(geometry.controlsBelow44, 0, `${profile.name} 存在小于 44px 的播放器按钮`);
|
||||
if (profile.width <= 720) assert.ok(geometry.tapHighlight === "rgba(0, 0, 0, 0)" || geometry.tapHighlight === "transparent", `${profile.name} 仍存在系统蓝色点击高亮`);
|
||||
assert.equal(await page.locator(".rate-control").textContent(), "1×");
|
||||
await page.screenshot({ path: path.join(output, `${profile.name}-player.png`), fullPage: true });
|
||||
|
||||
await page.locator(".player-tap-surface").click({ position: { x: 10, y: 100 } });
|
||||
await page.locator(".player-stage-v2.controls-hidden").waitFor({ state: "visible" });
|
||||
await page.waitForTimeout(150);
|
||||
await page.getByRole("button", { name: "显示播放控件" }).click({ position: { x: 10, y: 100 } });
|
||||
await page.locator(".player-stage-v2.controls-visible").waitFor({ state: "visible" });
|
||||
|
||||
const beforeSkip = Number(await page.locator('.timeline input[type="range"]').inputValue());
|
||||
await page.getByRole("button", { name: "快进 10 秒" }).click();
|
||||
const afterSkip = Number(await page.locator('.timeline input[type="range"]').inputValue());
|
||||
assert.ok(afterSkip > beforeSkip, `${profile.name} 快进没有更新进度`);
|
||||
|
||||
await page.locator(".rate-control").click();
|
||||
assert.equal(await page.locator(".rate-control").textContent(), "1.25×");
|
||||
await page.getByRole("button", { name: "时间点", exact: true }).click();
|
||||
await page.locator('.player-drawer[aria-label="视频时间点"]').waitFor({ state: "visible" });
|
||||
await page.waitForTimeout(250);
|
||||
await page.screenshot({ path: path.join(output, `${profile.name}-markers.png`), fullPage: true });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator(".player-drawer").waitFor({ state: "detached" });
|
||||
|
||||
await page.getByRole("button", { name: "合集目录" }).click();
|
||||
await page.locator('.player-drawer[aria-label="合集目录"]').waitFor({ state: "visible" });
|
||||
await page.locator(".player-panel-scrim").click({ position: { x: 4, y: 4 } });
|
||||
await page.locator(".player-drawer").waitFor({ state: "detached" });
|
||||
|
||||
await page.locator(".player-title-row").getByRole("button", { name: "喜欢" }).click();
|
||||
assert.equal(await page.locator(".player-title-row").getByRole("button", { name: "喜欢" }).getAttribute("aria-pressed"), "true");
|
||||
|
||||
if (profile.width <= 720) {
|
||||
await page.locator(".main-play").click();
|
||||
await page.waitForTimeout(3750);
|
||||
await page.locator(".player-stage-v2.controls-hidden").waitFor({ state: "visible" });
|
||||
await page.locator(".player-tap-surface").click({ position: { x: 10, y: 100 } });
|
||||
await page.locator(".main-play").click();
|
||||
await page.waitForTimeout(3750);
|
||||
assert.equal(await page.locator(".player-stage-v2").getAttribute("class").then(value => value.includes("controls-visible")), true, "暂停后控件不应自动隐藏");
|
||||
}
|
||||
|
||||
report.push({ ...profile, geometry, errors: [...new Set(errors)] });
|
||||
await context.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
fs.writeFileSync(path.join(output, "report.json"), JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
assert.equal(report.flatMap(item => item.errors).length, 0, "播放器运行时出现控制台错误");
|
||||
@@ -0,0 +1,443 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import {
|
||||
Activity, AlertCircle, ArrowDownToLine, ArrowLeft, Bell, Bookmark, Boxes, Check,
|
||||
CheckCircle2, ChevronDown, ChevronRight, CircleUserRound, Clock3, Cloud, Database,
|
||||
Download, Ellipsis, Eye, EyeOff, Film, Filter, Folder, FolderOpen, Gauge, Grid2X2,
|
||||
HardDrive, Heart, History, Home, Image, KeyRound, Library, List, ListFilter, LockKeyhole,
|
||||
Maximize, Menu, Mic2, Minimize, MonitorCog, Moon, MoreHorizontal, Pause, Pencil, Play, Plus,
|
||||
RefreshCw, RotateCcw, Search, Settings, ShieldCheck, SkipBack, SkipForward, Sparkles,
|
||||
Subtitles, Sun, Tags, ThumbsUp, Trash2, Upload, UserRound, UsersRound, Volume2, VolumeX, Wifi,
|
||||
X, Zap
|
||||
} from "lucide-react";
|
||||
import { collections, people, searchResults, tagGroups, uploadTasks, videos } from "./mockData";
|
||||
import "./styles.css";
|
||||
|
||||
const pageMeta = {
|
||||
home: ["首页", Home], search: ["搜索", Search], library: ["资料库", Library],
|
||||
collections: ["合集", Boxes], uploads: ["上传中心", Upload], tags: ["分类与标签", Tags],
|
||||
people: ["演员与人物", UsersRound], profile: ["个人中心", CircleUserRound], settings: ["设置", Settings]
|
||||
};
|
||||
|
||||
const desktopGroups = [
|
||||
{ label: "发现", pages: ["home", "search"] },
|
||||
{ label: "媒体", pages: ["library", "collections"] },
|
||||
{ label: "管理", pages: ["uploads", "tags", "people"] }
|
||||
];
|
||||
|
||||
const morePages = ["library", "collections", "uploads", "tags", "people", "settings"];
|
||||
|
||||
function routeFromHash() {
|
||||
const raw = location.hash.replace(/^#\//, "") || "home";
|
||||
const [page, id] = raw.split("/");
|
||||
return { page: page === "player" ? "player" : pageMeta[page] ? page : "home", id };
|
||||
}
|
||||
|
||||
function Scene({ scene, label, className = "", wide = false }) {
|
||||
return (
|
||||
<span className={`scene scene-${scene} ${wide ? "wide" : ""} ${className}`.trim()} role="img" aria-label={label}>
|
||||
<i className="scene-light" />
|
||||
<i className="scene-depth" />
|
||||
<b>{label}</b>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({ label, children, className = "", ...props }) {
|
||||
return <button className={`icon-button ${className}`.trim()} aria-label={label} title={label} {...props}>{children}</button>;
|
||||
}
|
||||
|
||||
function PageTitle({ title, description, action, back, compact = false }) {
|
||||
return (
|
||||
<header className={`page-title ${compact ? "compact" : ""}`}>
|
||||
{back && <IconButton label="返回" className="page-back" onClick={back}><ArrowLeft /></IconButton>}
|
||||
<div><h1>{title}</h1>{description && <p>{description}</p>}</div>
|
||||
{action && <div className="page-title-action">{action}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonPage() {
|
||||
return <div className="state-layout" aria-label="正在加载"><div className="skeleton hero-skeleton"/><div className="skeleton-line wide"/><div className="skeleton-line"/><div className="skeleton-grid">{Array.from({length:6},(_,i)=><div className="skeleton tile-skeleton" key={i}/>)}</div></div>;
|
||||
}
|
||||
|
||||
function EmptyState({ icon: Icon = Film, title = "这里还没有内容", text = "完成第一次添加后,内容会出现在这里。", action }) {
|
||||
return <div className="empty-state"><span><Icon /></span><h2>{title}</h2><p>{text}</p>{action}</div>;
|
||||
}
|
||||
|
||||
function ErrorState({ onRetry }) {
|
||||
return <div className="empty-state error-state"><span><AlertCircle /></span><h2>内容暂时无法加载</h2><p>网络连接已恢复,可以重新读取本地数据。你的设置和进度不会丢失。</p><button className="primary" onClick={onRetry}><RefreshCw />重新加载</button></div>;
|
||||
}
|
||||
|
||||
function DemoBoundary({ state, children, empty }) {
|
||||
if (state === "loading") return <SkeletonPage />;
|
||||
if (state === "empty") return empty || <EmptyState />;
|
||||
if (state === "error") return <ErrorState onRetry={() => location.reload()} />;
|
||||
return children;
|
||||
}
|
||||
|
||||
function VideoCard({ video, columns, onPlay, selected = false }) {
|
||||
return (
|
||||
<button className={`video-card columns-${columns} ${selected ? "selected" : ""}`} onClick={() => onPlay(video)} aria-label={`播放 ${video.title}`}>
|
||||
<span className="video-cover">
|
||||
<Scene scene={video.scene} label={video.title} />
|
||||
<span className="quality">{video.quality}</span>
|
||||
<span className="duration">{video.duration}</span>
|
||||
<span className="cover-play"><Play fill="currentColor" /></span>
|
||||
{video.progress > 0 && <i className="watch-progress"><b style={{ width: `${video.progress * 100}%` }} /></i>}
|
||||
</span>
|
||||
<span className="video-copy">
|
||||
<strong>{video.title}</strong>
|
||||
<small>{video.year} · {video.tags.join(" · ")}</small>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function HomePage({ onNavigate, onPlay, columns, setColumns, demoState }) {
|
||||
const [filter, setFilter] = useState("全部");
|
||||
const filters = ["全部", "最近新增", "4K", "已整理", "待整理"];
|
||||
const shown = filter === "4K" ? videos.filter(v => v.quality.includes("4K")) : filter === "待整理" ? videos.slice(8) : videos;
|
||||
return (
|
||||
<main className="page home-page">
|
||||
<section className="home-search" aria-label="多模态搜索入口">
|
||||
<div><Sparkles /><span><strong>搜索视频里的任何内容</strong><small>画面、文字、人物、声音与字幕</small></span></div>
|
||||
<button onClick={() => onNavigate("search")}><Search />试试“海边日落”<kbd>⌘ K</kbd></button>
|
||||
</section>
|
||||
<div className="content-toolbar">
|
||||
<div className="filter-chips" aria-label="首页筛选">{filters.map(item => <button key={item} className={filter === item ? "active" : ""} onClick={() => setFilter(item)}>{item}</button>)}</div>
|
||||
<div className="layout-toggle" role="group" aria-label="视频布局"><IconButton label="单列" className={columns === 1 ? "active" : ""} onClick={() => setColumns(1)}><List /></IconButton><IconButton label="双列" className={columns === 2 ? "active" : ""} onClick={() => setColumns(2)}><Grid2X2 /></IconButton></div>
|
||||
</div>
|
||||
<section className="media-section">
|
||||
<header><div><h1>{filter === "全部" ? "最近新增" : filter}</h1><p>{shown.length} 部影片 · 本地索引已更新</p></div><button className="text-button" onClick={() => onNavigate("library")}>查看资料库<ChevronRight /></button></header>
|
||||
<DemoBoundary state={demoState} empty={<EmptyState title="媒体库还是空的" text="添加资料库或上传第一部视频,ImageFind 会在本地完成整理。" action={<button className="primary"><Plus />添加媒体</button>} />}>
|
||||
<div className={`video-grid columns-${columns}`}>{shown.map(video => <VideoCard key={video.id} video={video} columns={columns} onPlay={onPlay} />)}</div>
|
||||
</DemoBoundary>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchPage({ onPlay, demoState, onBack }) {
|
||||
const [query, setQuery] = useState("海边日落");
|
||||
const [mode, setMode] = useState("全部");
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const modes = [["全部", Search], ["画面", Image], ["OCR", ListFilter], ["音频", Mic2]];
|
||||
return (
|
||||
<main className="page search-page">
|
||||
<PageTitle title="搜索" description="找到视频中的某一刻,而不只是文件名。" back={onBack} />
|
||||
<form className="search-command" onSubmit={e => e.preventDefault()}>
|
||||
<Search /><input value={query} onChange={e => setQuery(e.target.value)} aria-label="搜索内容" placeholder="搜索画面、文字、人物或声音" /><button type="button" className="image-search"><Image />以图搜索</button><button className="primary">搜索</button>
|
||||
</form>
|
||||
<div className="search-toolbar">
|
||||
<div className="mode-tabs" role="tablist">{modes.map(([label, Icon]) => <button role="tab" aria-selected={mode === label} className={mode === label ? "active" : ""} key={label} onClick={() => setMode(label)}><Icon />{label}</button>)}</div>
|
||||
<button className="secondary filter-trigger" onClick={() => setFiltersOpen(true)}><Filter />更多筛选<span>2</span></button>
|
||||
</div>
|
||||
<div className="applied-filters"><span>来源:全部</span><span>时长:30 分钟以内</span><button>清除全部</button></div>
|
||||
<DemoBoundary state={demoState} empty={<EmptyState icon={Search} title="没有找到相关片段" text="换一种描述,或尝试选择其他识别类型。" />}>
|
||||
<section className="search-results"><header><div><h1>“{query || "海边日落"}”的相关片段</h1><p>4 个结果 · 按相关度排序</p></div></header>{searchResults.map(result => { const video = videos.find(v => v.id === result.videoId); return <button className="result-row" key={result.id} onClick={() => onPlay(video, result.at)}><span className="result-cover"><Scene scene={result.scene} label={video.title} wide/><b>{result.at}</b><i><Play fill="currentColor"/></i></span><span className="result-copy"><span className="match-type"><Sparkles />{result.type}<b>{result.score}</b></span><strong>{video.title}</strong><p>{result.text}</p><small>命中片段 {result.range} · 点击从 {result.at} 播放</small></span><ChevronRight /></button>})}</section>
|
||||
</DemoBoundary>
|
||||
{filtersOpen && <div className="overlay" onMouseDown={e => e.target === e.currentTarget && setFiltersOpen(false)}><section className="sheet filter-sheet" role="dialog" aria-modal="true" aria-label="更多筛选"><header><div><h2>筛选搜索结果</h2><p>只保留与你当前需求有关的条件</p></div><IconButton label="关闭" onClick={() => setFiltersOpen(false)}><X /></IconButton></header><div className="filter-block"><strong>识别类型</strong><div className="choice-grid">{["画面语义", "OCR 文字", "人物识别", "音频识别", "字幕", "影片资料"].map((x,i)=><button className={i<2?"selected":""} key={x}>{i<2&&<Check/>}{x}</button>)}</div></div><div className="filter-block"><strong>影片条件</strong><label>媒体来源<select><option>全部资料库</option><option>移动云盘</option><option>本地媒体</option></select></label><label>最长时长<select><option>30 分钟以内</option><option>1 小时以内</option><option>不限</option></select></label></div><footer><button className="secondary">清除全部</button><button className="primary" onClick={() => setFiltersOpen(false)}>显示 4 个结果</button></footer></section></div>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryPage({ onBack, demoState }) {
|
||||
const sources = [
|
||||
{ name: "移动云盘", kind: "AList 加密", icon: LockKeyhole, status: "在线", detail: "33 部影片 · 118 GB", color: "blue" },
|
||||
{ name: "家庭影像", kind: "WebDAV", icon: Cloud, status: "在线", detail: "16 部影片 · 52 GB", color: "cyan" },
|
||||
{ name: "本地媒体", kind: "本地目录", icon: HardDrive, status: "在线", detail: "8 部影片 · 26 GB", color: "violet" }
|
||||
];
|
||||
return <main className="page"><PageTitle title="资料库" description="管理媒体来源、目录和扫描状态。" back={onBack} action={<button className="primary"><Plus />添加来源</button>} /><DemoBoundary state={demoState}><section className="source-list">{sources.map(({name,kind,icon:Icon,status,detail,color})=><article className="source-row" key={name}><span className={`source-icon ${color}`}><Icon/></span><div><strong>{name}</strong><p>{kind} · {detail}</p></div><span className="online"><i/>{status}</span><button className="secondary"><FolderOpen/>浏览</button><IconButton label="更多操作"><MoreHorizontal/></IconButton></article>)}</section><section className="library-browser"><header><div><h2>最近目录</h2><p>快速进入常用路径</p></div></header><div className="folder-grid">{["/旅行/2026","/家庭/日常","/素材/待整理","/归档/已完成"].map((x,i)=><button key={x}><span><Folder/></span><strong>{x.split("/").pop()}</strong><small>{[12,8,5,24][i]} 部影片</small></button>)}</div></section></DemoBoundary></main>;
|
||||
}
|
||||
|
||||
function CollectionsPage({ onBack, onPlay, demoState }) {
|
||||
const [active, setActive] = useState(collections[0]);
|
||||
const collectionVideos = videos.filter(v => v.collection === active.name);
|
||||
const displayVideos = collectionVideos.length >= 4 ? collectionVideos : videos.slice(0, 6);
|
||||
return <main className="page"><PageTitle title="合集" description="用章节与小节组织连续内容。" back={onBack} action={<button className="primary"><Plus />新建合集</button>} /><DemoBoundary state={demoState}><div className="collections-layout"><nav className="collection-rail" aria-label="合集列表">{collections.map(item=><button key={item.id} className={active.id===item.id?"active":""} onClick={()=>setActive(item)}><Scene scene={item.scene} label={item.name}/><span><strong>{item.name}</strong><small>{item.count} 部 · {item.duration}</small></span></button>)}</nav><section className="collection-detail"><header className="collection-hero"><Scene scene={active.scene} label={active.name}/><div><span className="privacy-pill"><LockKeyhole/>私有合集</span><h2>{active.name}</h2><p>{active.description}</p><small>{active.count} 部影片 · {active.duration}</small><div><button className="primary"><Play fill="currentColor"/>播放全部</button><button className="secondary"><Pencil/>编辑合集</button></div></div></header><div className="chapter-layout"><aside><h3>目录</h3>{active.chapters.map((chapter,i)=><button className={i===1?"active":""} key={chapter}><span>{String(i+1).padStart(2,"0")}</span>{chapter}<small>{i+2} 部</small></button>)}</aside><div className="collection-video-list"><header><div><h3>沿海公路</h3><p>{displayVideos.length} 部影片</p></div><button className="text-button">调整顺序</button></header>{displayVideos.map((video,i)=><button key={video.id} onClick={()=>onPlay(video)}><span className="order">{i+1}</span><Scene scene={video.scene} label={video.title}/><span><strong>{video.title}</strong><small>{video.duration} · {video.quality}</small></span><MoreHorizontal/></button>)}</div></div></section></div></DemoBoundary></main>;
|
||||
}
|
||||
|
||||
function UploadsPage({ onBack, onUpload, onPlay, demoState }) {
|
||||
const [tab,setTab]=useState("进行中");
|
||||
const tasks = tab === "已完成" ? uploadTasks.filter(t=>t.state==="done") : tab === "失败" ? uploadTasks.filter(t=>t.state==="failed") : uploadTasks.filter(t=>t.state!=="done");
|
||||
return <main className="page"><PageTitle title="上传中心" description="上传、转存和媒体入库互不阻塞。" back={onBack} action={<button className="primary" onClick={onUpload}><Upload/>继续上传</button>} /><div className="upload-summary"><div><span className="summary-icon"><Activity/></span><p><strong>1 个任务正在转存</strong><small>上传可在后台继续,关闭弹窗不会中断。</small></p></div><span>38.6 MB/s</span></div><nav className="segmented" aria-label="任务状态">{["进行中","已完成","失败"].map(x=><button className={tab===x?"active":""} key={x} onClick={()=>setTab(x)}>{x}{x==="失败"&&<b>1</b>}</button>)}</nav><DemoBoundary state={demoState}><section className="transfer-list">{tasks.map(task=><article className={`transfer-item ${task.state}`} key={task.id}><span className="transfer-state">{task.state==="done"?<CheckCircle2/>:task.state==="failed"?<AlertCircle/>:task.state==="waiting"?<Clock3/>:<Upload/>}</span><div className="transfer-copy"><header><strong>{task.name}</strong><span>{task.size}</span></header><p>加入合集:{task.collection}</p>{task.state!=="done"&&<><div className="stage-row"><span className={task.progress>=15?"done":""}><i>1</i>上传至 ImageFind</span><span className={task.progress>=45?"done":""}><i>2</i>转存 WebDAV</span><span className={task.progress===100?"done":""}><i>3</i>媒体入库</span></div><div className="progress"><i style={{width:`${task.progress}%`}}/></div></>}<footer><span><strong>{task.stage}</strong><small>{task.detail}</small></span><div>{task.state==="failed"&&<button className="secondary"><RotateCcw/>从断点重试</button>}{task.state==="done"&&<button className="secondary" onClick={()=>onPlay(videos.find(v=>v.id===task.videoId))}><Play/>查看影片</button>}<IconButton label="任务菜单"><MoreHorizontal/></IconButton></div></footer></div></article>)}</section></DemoBoundary></main>;
|
||||
}
|
||||
|
||||
function TagsPage({ onBack, demoState }) {
|
||||
return <main className="page"><PageTitle title="分类与标签" description="建立稳定的整理规则,AI 建议仍由你确认。" back={onBack} action={<button className="primary"><Plus/>新建分组</button>} /><DemoBoundary state={demoState}><div className="tag-layout"><section className="tag-groups">{tagGroups.map(group=><article key={group.name}><header><div><h2>{group.name}</h2><p>{group.selection} · {group.tags.length} 个标签</p></div><IconButton label="编辑分组"><Pencil/></IconButton></header><div>{group.tags.map(([tag,count])=><button key={tag}><span>{tag}</span><small>{count} 部</small><ChevronRight/></button>)}</div></article>)}</section><aside className="suggestion-panel"><span><Sparkles/></span><h2>7 条 AI 标签建议</h2><p>根据画面、OCR 与音频内容生成,应用前需要管理员确认。</p><button className="primary">查看建议</button></aside></div></DemoBoundary></main>;
|
||||
}
|
||||
|
||||
function PeoplePage({ onBack, demoState }) {
|
||||
return <main className="page"><PageTitle title="演员与人物" description="在本地识别人脸,并与影片演员信息关联。" back={onBack} /><div className="people-toolbar"><div className="search-small"><Search/><input aria-label="搜索人物" placeholder="搜索人物"/></div><button className="secondary"><Filter/>只看待确认</button></div><DemoBoundary state={demoState}><div className="people-grid">{people.map(person=><button key={person.id}><Scene scene={person.scene} label={person.name}/><span><strong>{person.name}</strong><small>{person.role} · 出现在 {person.count} 部影片</small></span><ChevronRight/></button>)}</div></DemoBoundary></main>;
|
||||
}
|
||||
|
||||
function ProfilePage({ onBack, onPlay, demoState, theme, setTheme }) {
|
||||
const [tab,setTab]=useState("概览");
|
||||
const tabs=["概览","收藏","喜欢","观看记录","个人资料"];
|
||||
return <main className="page"><PageTitle title="个人中心" description="你的观看、收藏与偏好只保存在私有空间。" back={onBack}/><section className="profile-identity"><span className="avatar-large">南</span><div><h2>南熏</h2><p>ImageFind 管理员 · 本地账户</p></div><button className="secondary"><Pencil/>编辑资料</button></section><nav className="segmented profile-tabs">{tabs.map(x=><button className={tab===x?"active":""} key={x} onClick={()=>setTab(x)}>{x}</button>)}</nav><DemoBoundary state={demoState}>{tab==="概览"?<><div className="profile-stats">{[[Bookmark,"收藏",8],[ThumbsUp,"喜欢",12],[History,"观看记录",26],[Play,"继续观看",4]].map(([Icon,label,value])=><article key={label}><Icon/><span><strong>{value}</strong><small>{label}</small></span></article>)}</div><section className="media-section continue-section"><header><div><h2>继续观看</h2><p>从上次停下来的位置继续</p></div></header><div className="continue-grid">{videos.filter(v=>v.progress>0).map(v=><VideoCard key={v.id} video={v} columns={2} onPlay={onPlay}/>)}</div></section></>:tab==="个人资料"?<div className="settings-columns"><section className="setting-group"><header><div><h2>个人资料</h2><p>头像由昵称自动生成</p></div></header><label>昵称<input defaultValue="南熏"/></label><button className="primary">保存昵称</button></section><section className="setting-group"><header><div><h2>播放与隐私</h2><p>这些偏好仅对当前管理员生效</p></div></header><Preference label="自动播放下一部" description="当前影片结束后继续合集" defaultOn/><Preference label="默认遮罩封面" description="进入应用时保护私密封面"/><div className="theme-row"><span><strong>外观主题</strong><small>浅色、深色或跟随系统</small></span><select value={theme} onChange={e=>setTheme(e.target.value)}><option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option></select></div></section></div>:<section className="media-section"><header><div><h2>我的{tab}</h2><p>支持单选与批量管理</p></div><button className="secondary">管理</button></header><div className="video-grid columns-2">{videos.slice(0,6).map(v=><VideoCard key={v.id} video={v} columns={2} onPlay={onPlay}/>)}</div></section>}</DemoBoundary></main>;
|
||||
}
|
||||
|
||||
function Preference({label,description,defaultOn=false}) { const [on,setOn]=useState(defaultOn); return <button className="preference" role="switch" aria-checked={on} onClick={()=>setOn(!on)}><span><strong>{label}</strong><small>{description}</small></span><i className={on?"on":""}><b/></i></button>; }
|
||||
|
||||
function SettingsPage({ onBack, demoState }) {
|
||||
const [section,setSection]=useState("AI 与网络");
|
||||
const sections=["AI 与网络","存储","备份恢复","账户与 API","任务与偏好"];
|
||||
return <main className="page"><PageTitle title="设置" description="系统能力按任务分区,不需要一次理解所有选项。" back={onBack}/><div className="settings-layout"><nav>{sections.map((x,i)=><button className={section===x?"active":""} key={x} onClick={()=>setSection(x)}><span>{i+1}</span>{x}<ChevronRight/></button>)}</nav><DemoBoundary state={demoState}><section className="settings-content"><header><h2>{section}</h2><p>{section==="AI 与网络"?"管理模型、推理设备与下载网络。":"查看并调整当前分区的系统设置。"}</p></header>{section==="AI 与网络"?<><div className="setting-group model-group"><header><div><h3>AI 模型</h3><p>模型按组件独立安装,状态加载完成前显示占位。</p></div><span className="ready-status"><CheckCircle2/>4 个组件就绪</span></header>{[[Image,"画面语义","Intel GPU","3.72 GB"],[ListFilter,"OCR 文字","Intel GPU","15.4 MB"],[UsersRound,"人物识别","Intel GPU","3.7 MB"],[Mic2,"音频识别","Intel GPU","928.6 MB"]].map(([Icon,name,device,size])=><div className="model-row" key={name}><span><Icon/></span><div><strong>{name}</strong><small>{size} · 运行环境已就绪</small></div><b><Zap/>{device}</b><button className="secondary">管理</button></div>)}</div><div className="setting-group"><header><div><h3>推理加速</h3><p>高负载时自动让出系统资源。</p></div></header><div className="device-card"><span><Gauge/></span><div><strong>Intel(R) UHD Graphics</strong><small>GPU 优先 · CPU 回退最多 2 个线程</small></div><span className="online"><i/>工作正常</span></div></div><details className="advanced"><summary>代理与下载地址<ChevronDown/></summary><p>低频网络配置默认折叠,避免干扰模型状态。</p></details></>:section==="存储"?<StorageSettings/>:section==="备份恢复"?<BackupSettings/>:<GenericSettings section={section}/>}</section></DemoBoundary></div></main>;
|
||||
}
|
||||
|
||||
function StorageSettings(){return <><div className="storage-overview"><div><span>NAS 可用空间</span><strong>7.6 TB</strong><small>总容量 12.0 TB</small></div><i><b style={{width:"37%"}}/></i></div><div className="storage-list">{[["媒体数据库","428 MB"],["AI 索引","6.8 GB"],["模型与运行环境","7.1 GB"],["缩略图与预览","1.9 GB"],["上传临时文件","0 B"]].map(([x,v])=><div key={x}><span>{x}</span><strong>{v}</strong></div>)}</div></>}
|
||||
function BackupSettings(){return <div className="settings-columns"><section className="setting-group"><header><div><h3>创建备份</h3><p>备份昵称、偏好、来源配置、合集和标签。</p></div></header><button className="primary"><ArrowDownToLine/>导出完整备份</button></section><section className="setting-group"><header><div><h3>恢复备份</h3><p>恢复前会检查版本与数据冲突。</p></div></header><button className="secondary"><Upload/>选择备份文件</button></section></div>}
|
||||
function GenericSettings({section}){return <div className="setting-group"><header><div><h3>{section}</h3><p>原型展示该分区的稳定信息层级与表单样式。</p></div></header><Preference label="启用后台通知" description="任务完成或失败时提醒" defaultOn/><Preference label="允许移动网络播放" description="仅影响当前浏览器"/></div>}
|
||||
|
||||
function LegacyPlayerPage({ video, onBack, onToast }) {
|
||||
const [playing,setPlaying]=useState(false); const [position,setPosition]=useState(28); const [rate,setRate]=useState(1); const [controls,setControls]=useState(true); const [markers,setMarkers]=useState(false); const [directory,setDirectory]=useState(false); const [buffering,setBuffering]=useState(false);
|
||||
const hideTimer=useRef();
|
||||
const reveal=()=>{setControls(true);clearTimeout(hideTimer.current);hideTimer.current=setTimeout(()=>playing&&setControls(false),4200)};
|
||||
useEffect(()=>()=>clearTimeout(hideTimer.current),[]);
|
||||
const toggle=()=>{setPlaying(x=>!x);reveal()};
|
||||
return <main className="player-page"><section className={`player-stage ${controls?"controls-visible":"controls-hidden"}`} onPointerMove={reveal} onClick={e=>{if(e.target===e.currentTarget){controls?setControls(false):reveal()}}}><Scene scene={video.scene} label={video.title} wide/><div className="player-shade"/><header><IconButton label="返回" onClick={onBack}><ArrowLeft/></IconButton><strong>{video.title}</strong><span/><IconButton label="合集目录" className={directory?"active":""} onClick={()=>{setDirectory(!directory);setMarkers(false)}}><List/></IconButton></header><div className="player-center">{buffering?<div className="buffering"><RefreshCw/><strong>正在缓冲 64%</strong><small>已加载至视频的 64%</small></div>:<><button onClick={()=>{setPosition(Math.max(0,position-8));onToast("已快退 10 秒")}}><SkipBack/></button><button className="main-play" onClick={toggle}>{playing?<Pause fill="currentColor"/>:<Play fill="currentColor"/>}</button><button onClick={()=>{setPosition(Math.min(100,position+8));onToast("已快进 10 秒")}}><SkipForward/></button></>}</div><footer><div className="timeline"><i className="buffered" style={{width:"64%"}}/><i className="played" style={{width:`${position}%`}}/><input type="range" aria-label="播放进度" min="0" max="100" value={position} onChange={e=>setPosition(Number(e.target.value))}/>{[18,43,72].map(x=><button aria-label={`跳转到时间点 ${x}`} style={{left:`${x}%`}} key={x} onClick={()=>setPosition(x)}/>)}</div><div className="player-controls-row"><IconButton label={playing?"暂停":"播放"} onClick={toggle}>{playing?<Pause fill="currentColor"/>:<Play fill="currentColor"/>}</IconButton><span>08:24 / {video.duration}</span><IconButton label="音量"><Volume2/></IconButton><span className="spacer"/><button className={markers?"active":""} onClick={()=>{setMarkers(!markers);setDirectory(false)}}><Clock3/>时间点</button><button onClick={()=>setRate(rate===2?1:rate+.5)}>{rate}×</button><IconButton label="模拟缓冲" onClick={()=>{setBuffering(!buffering);reveal()}}><Wifi/></IconButton><IconButton label="全屏"><Maximize/></IconButton></div></footer>{markers&&<section className="player-drawer"><header><div><h2>视频时间点</h2><p>点击标记直接跳转</p></div><IconButton label="关闭" onClick={()=>setMarkers(false)}><X/></IconButton></header><button className="add-marker"><Plus/>标记当前位置 08:24</button>{[[18,"出发前的港口"],[43,"沿海公路日落"],[72,"抵达蓝湾码头"]].map(([at,title])=><button key={at} onClick={()=>setPosition(at)}><span>{String(Math.round(at/100*18)).padStart(2,"0")}:24</span><strong>{title}</strong><ChevronRight/></button>)}</section>}{directory&&<section className="player-drawer directory"><header><div><h2>{video.collection}</h2><p>合集目录</p></div><IconButton label="关闭" onClick={()=>setDirectory(false)}><X/></IconButton></header>{videos.filter(v=>v.collection===video.collection).map((item,i)=><button key={item.id}><span>{i+1}</span><Scene scene={item.scene} label={item.title}/><strong>{item.title}</strong><small>{item.duration}</small></button>)}</section>}</section><div className="player-details"><div className="player-title-row"><div><h1>{video.title}</h1><p>{video.year} · {video.quality} · {video.collection}</p></div><div><button><ThumbsUp/>喜欢</button><button><Bookmark/>收藏</button><button><Download/>下载</button><button><Boxes/>加入合集</button></div></div><section className="video-info"><p>{video.description}</p><div>{video.tags.map(x=><span key={x}>{x}</span>)}</div></section><section className="in-video-search"><header><span><Sparkles/></span><div><h2>在当前视频中搜索</h2><p>输入一句话,直接跳到相关画面或声音</p></div></header><div><Search/><input defaultValue="海边日落"/><button className="primary">搜索</button></div></section></div></main>;
|
||||
}
|
||||
|
||||
function PlayerPage({ video, onBack, onToast }) {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [position, setPosition] = useState(28);
|
||||
const [rate, setRate] = useState(1);
|
||||
const [controls, setControls] = useState(true);
|
||||
const [panel, setPanel] = useState(null);
|
||||
const [buffering, setBuffering] = useState(false);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const stageRef = useRef(null);
|
||||
const panelRef = useRef(null);
|
||||
const hideTimer = useRef(null);
|
||||
const markers = [
|
||||
{ at: 18, time: "03:24", title: "出发前的港口" },
|
||||
{ at: 43, time: "08:24", title: "沿海公路日落" },
|
||||
{ at: 72, time: "13:24", title: "抵达蓝湾码头" }
|
||||
];
|
||||
const rates = [1, 1.25, 1.5, 2];
|
||||
|
||||
const clearAutoHide = () => clearTimeout(hideTimer.current);
|
||||
const reveal = (autoHide = playing) => {
|
||||
setControls(true);
|
||||
clearAutoHide();
|
||||
if (autoHide && !panel) hideTimer.current = setTimeout(() => setControls(false), 3600);
|
||||
};
|
||||
const togglePlayback = () => {
|
||||
const next = !playing;
|
||||
setPlaying(next);
|
||||
reveal(next);
|
||||
};
|
||||
const skip = seconds => {
|
||||
setPosition(value => Math.max(0, Math.min(100, value + seconds / 1.12)));
|
||||
onToast(seconds > 0 ? "已快进 10 秒" : "已快退 10 秒");
|
||||
reveal();
|
||||
};
|
||||
const togglePanel = next => {
|
||||
setPanel(current => current === next ? null : next);
|
||||
setControls(true);
|
||||
clearAutoHide();
|
||||
};
|
||||
const toggleFullscreen = async () => {
|
||||
try {
|
||||
const active = document.fullscreenElement || document.webkitFullscreenElement;
|
||||
if (active) {
|
||||
const exit = document.exitFullscreen || document.webkitExitFullscreen;
|
||||
await exit?.call(document);
|
||||
} else {
|
||||
const request = stageRef.current?.requestFullscreen || stageRef.current?.webkitRequestFullscreen;
|
||||
await request?.call(stageRef.current);
|
||||
}
|
||||
} catch {
|
||||
onToast("当前浏览器不支持页面全屏");
|
||||
}
|
||||
reveal();
|
||||
};
|
||||
|
||||
useEffect(() => () => clearAutoHide(), []);
|
||||
useEffect(() => {
|
||||
const sync = () => setFullscreen(Boolean(document.fullscreenElement || document.webkitFullscreenElement));
|
||||
document.addEventListener("fullscreenchange", sync);
|
||||
document.addEventListener("webkitfullscreenchange", sync);
|
||||
return () => {
|
||||
document.removeEventListener("fullscreenchange", sync);
|
||||
document.removeEventListener("webkitfullscreenchange", sync);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle("player-panel-open", Boolean(panel));
|
||||
if (panel) requestAnimationFrame(() => panelRef.current?.focus());
|
||||
const close = event => event.key === "Escape" && setPanel(null);
|
||||
addEventListener("keydown", close);
|
||||
return () => {
|
||||
document.body.classList.remove("player-panel-open");
|
||||
removeEventListener("keydown", close);
|
||||
};
|
||||
}, [panel]);
|
||||
|
||||
return (
|
||||
<main className="player-page">
|
||||
<div className="player-layout">
|
||||
<section
|
||||
ref={stageRef}
|
||||
className={`player-stage player-stage-v2 ${controls ? "controls-visible" : "controls-hidden"} ${panel ? "panel-open" : ""}`}
|
||||
onPointerMove={() => reveal()}
|
||||
onPointerLeave={() => playing && !panel && setControls(false)}
|
||||
>
|
||||
<Scene scene={video.scene} label={video.title} wide />
|
||||
<div className="player-shade" />
|
||||
<button
|
||||
className="player-tap-surface"
|
||||
aria-label={controls ? "隐藏播放控件" : "显示播放控件"}
|
||||
onClick={() => controls ? (clearAutoHide(), setControls(false)) : reveal()}
|
||||
/>
|
||||
|
||||
<header className="player-topbar">
|
||||
<IconButton label="返回" onClick={onBack}><ArrowLeft /></IconButton>
|
||||
<div><strong>{video.title}</strong><small>{video.collection} · {video.quality}</small></div>
|
||||
<span />
|
||||
<IconButton label="合集目录" className={`directory-toggle ${panel === "directory" ? "active" : ""}`} onClick={() => togglePanel("directory")}><List /></IconButton>
|
||||
</header>
|
||||
|
||||
<div className="player-center">
|
||||
{buffering ? (
|
||||
<div className="buffering" role="status"><RefreshCw /><strong>正在缓冲 64%</strong><small>已加载至视频的 64%</small></div>
|
||||
) : (
|
||||
<>
|
||||
<button aria-label="快退 10 秒" onClick={() => skip(-10)}><SkipBack /></button>
|
||||
<button className="main-play" aria-label={playing ? "暂停" : "播放"} onClick={togglePlayback}>{playing ? <Pause fill="currentColor" /> : <Play fill="currentColor" />}</button>
|
||||
<button aria-label="快进 10 秒" onClick={() => skip(10)}><SkipForward /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="player-bottombar">
|
||||
<div className="timeline">
|
||||
<i className="track" />
|
||||
<i className="buffered" style={{ width: "64%" }} />
|
||||
<i className="played" style={{ width: `${position}%` }} />
|
||||
<input type="range" aria-label="播放进度" min="0" max="100" value={position} onChange={event => { setPosition(Number(event.target.value)); reveal(); }} />
|
||||
{markers.map(marker => <button aria-label={`跳转到时间点 ${marker.time} ${marker.title}`} style={{ left: `${marker.at}%` }} key={marker.at} onClick={() => { setPosition(marker.at); reveal(); }} />)}
|
||||
</div>
|
||||
<div className="player-controls-row">
|
||||
<IconButton label={playing ? "暂停" : "播放"} onClick={togglePlayback}>{playing ? <Pause fill="currentColor" /> : <Play fill="currentColor" />}</IconButton>
|
||||
<span className="player-time">08:24 <i>/</i> {video.duration}</span>
|
||||
<span className="spacer" />
|
||||
<button className={panel === "markers" ? "active marker-control" : "marker-control"} aria-label="时间点" onClick={() => togglePanel("markers")}><Clock3 /><span>时间点</span></button>
|
||||
<button className="rate-control" onClick={() => setRate(rates[(rates.indexOf(rate) + 1) % rates.length])}>{rate}×</button>
|
||||
<IconButton label={muted ? "取消静音" : "静音"} className="volume-control" onClick={() => { setMuted(value => !value); reveal(); }}>{muted ? <VolumeX /> : <Volume2 />}</IconButton>
|
||||
<IconButton label={buffering ? "结束缓冲演示" : "模拟缓冲"} className="buffer-control" onClick={() => { setBuffering(value => !value); reveal(false); }}><Wifi /></IconButton>
|
||||
<IconButton label={fullscreen ? "退出全屏" : "全屏"} onClick={toggleFullscreen}>{fullscreen ? <Minimize /> : <Maximize />}</IconButton>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{panel && <button className="player-panel-scrim" aria-label="关闭播放器面板" onClick={() => setPanel(null)} />}
|
||||
{panel === "markers" && (
|
||||
<section ref={panelRef} tabIndex="-1" className="player-drawer" role="dialog" aria-modal="true" aria-label="视频时间点">
|
||||
<i className="panel-handle" />
|
||||
<header><div><h2>视频时间点</h2><p>点击标记直接跳转</p></div><IconButton label="关闭" onClick={() => setPanel(null)}><X /></IconButton></header>
|
||||
<button className="add-marker" onClick={() => onToast("已标记当前位置 08:24")}><Plus />标记当前位置 08:24</button>
|
||||
{markers.map(marker => <button key={marker.at} onClick={() => { setPosition(marker.at); setPanel(null); reveal(); }}><span>{marker.time}</span><strong>{marker.title}</strong><ChevronRight /></button>)}
|
||||
</section>
|
||||
)}
|
||||
{panel === "directory" && (
|
||||
<section ref={panelRef} tabIndex="-1" className="player-drawer directory" role="dialog" aria-modal="true" aria-label="合集目录">
|
||||
<i className="panel-handle" />
|
||||
<header><div><h2>{video.collection}</h2><p>合集目录 · 当前播放第 1 部</p></div><IconButton label="关闭" onClick={() => setPanel(null)}><X /></IconButton></header>
|
||||
{videos.filter(item => item.collection === video.collection).map((item, index) => <button className={item.id === video.id ? "active" : ""} key={item.id} onClick={() => { onToast(`已切换到《${item.title}》`); setPanel(null); }}><span>{index + 1}</span><Scene scene={item.scene} label={item.title} /><strong>{item.title}</strong><small>{item.duration}</small></button>)}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="player-side-panel" aria-label="合集选集">
|
||||
<header><div><h2>{video.collection}</h2><p>合集选集 · {videos.filter(item => item.collection === video.collection).length} 部</p></div><span>自动连播</span></header>
|
||||
<div className="player-queue">
|
||||
{videos.filter(item => item.collection === video.collection).map((item, index) => (
|
||||
<button className={item.id === video.id ? "active" : ""} key={item.id} onClick={() => onToast(item.id === video.id ? "正在播放当前视频" : `已切换到《${item.title}》`)}>
|
||||
<span>{index + 1}</span><Scene scene={item.scene} label={item.title} /><div><strong>{item.title}</strong><small>{item.duration} · {item.quality}</small></div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<section className="player-recommendations">
|
||||
<div className="player-recommendations-title"><h3>接下来播放</h3><span>相关推荐</span></div>
|
||||
<div className="player-recommendations-list">
|
||||
{videos.filter(item => item.collection !== video.collection).slice(0, 8).map(item => (
|
||||
<button key={item.id} onClick={() => onToast(`已切换到《${item.title}》`)}>
|
||||
<Scene scene={item.scene} label={item.title} /><div><strong>{item.title}</strong><small>{item.duration} · {item.collection}</small></div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div className="player-details">
|
||||
<div className="player-title-row">
|
||||
<div><h1>{video.title}</h1><p>{video.year} · {video.quality} · {video.collection}</p></div>
|
||||
<div>
|
||||
<button className={liked ? "active" : ""} aria-pressed={liked} onClick={() => { setLiked(value => !value); onToast(liked ? "已取消喜欢" : "已添加到喜欢"); }}><ThumbsUp />喜欢</button>
|
||||
<button className={saved ? "active" : ""} aria-pressed={saved} onClick={() => { setSaved(value => !value); onToast(saved ? "已取消收藏" : "已添加到收藏"); }}><Bookmark />收藏</button>
|
||||
<button onClick={() => onToast("已开始浏览器下载")}><Download />下载</button>
|
||||
<button onClick={() => onToast(`已加入合集“${video.collection}”`)}><Boxes />加入合集</button>
|
||||
</div>
|
||||
</div>
|
||||
<section className="video-info"><p>{video.description}</p><div>{video.tags.map(tag => <span key={tag}>{tag}</span>)}</div></section>
|
||||
<section className="in-video-search"><header><span><Sparkles /></span><div><h2>在当前视频中搜索</h2><p>输入一句话,直接跳到相关画面或声音</p></div></header><div><Search /><input defaultValue="海边日落" /><button className="primary" onClick={() => onToast("找到 3 个相关时间片段")}>搜索</button></div></section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadSheet({ onClose, onToast }) {
|
||||
const [step,setStep]=useState(1); const [title,setTitle]=useState("");
|
||||
return <div className="overlay" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><section className="sheet upload-sheet" role="dialog" aria-modal="true" aria-label="上传影片"><header><div><h2>上传影片</h2><p>关闭窗口后仍会在后台继续</p></div><IconButton label="关闭" onClick={onClose}><X/></IconButton></header>{step===1?<><button className="drop-zone" onClick={()=>setStep(2)}><span><Upload/></span><strong>选择影片或拖放到这里</strong><small>支持多文件、大文件与断点续传</small></button><div className="upload-options"><label>本次标题(可选)<input value={title} onChange={e=>setTitle(e.target.value)} placeholder="多个文件时将分别使用文件名"/></label><label>加入合集<select><option>不加入合集</option><option>沿海公路日记</option><option>城市切片</option></select></label><label>分类<select><option>不指定分类</option><option>旅行</option><option>生活</option></select></label></div><footer><button className="secondary" onClick={onClose}>取消</button><button className="primary" onClick={()=>setStep(2)}>选择演示文件</button></footer></>:<><div className="selected-upload"><span><Film/></span><div><strong>{title||"海岛航拍-新素材.mp4"}</strong><small>2.8 GB · 将加入“沿海公路日记”</small></div><CheckCircle2/></div><div className="progress large"><i style={{width:"36%"}}/></div><p className="uploading-copy">正在上传至 ImageFind · 1.0 GB / 2.8 GB</p><footer><button className="secondary" onClick={()=>{onToast("已转入后台上传");onClose()}}>关闭并在后台继续</button><button className="primary" onClick={()=>setStep(1)}>继续添加</button></footer></>}</section></div>;
|
||||
}
|
||||
|
||||
function MoreSheet({ onClose, onNavigate }) {
|
||||
return <div className="overlay mobile-only" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><section className="sheet more-sheet" role="dialog" aria-modal="true" aria-label="更多页面"><i className="handle"/><header><h2>更多</h2><IconButton label="关闭" onClick={onClose}><X/></IconButton></header><div className="more-grid">{morePages.map(page=>{const [label,Icon]=pageMeta[page];return <button key={page} onClick={()=>{onNavigate(page);onClose()}}><span><Icon/></span><strong>{label}</strong>{page==="uploads"&&<b>1</b>}</button>})}</div><button className="logout"><ArrowLeft/>退出当前账户</button></section></div>;
|
||||
}
|
||||
|
||||
function PrototypePanel({ open, setOpen, demoState, setDemoState, theme, setTheme, page, onNavigate }) {
|
||||
return <aside className={`prototype-panel ${open?"open":""}`}><button className="prototype-toggle" onClick={()=>setOpen(!open)} aria-expanded={open}><MonitorCog/>{open?"收起原型控制器":"原型"}</button>{open&&<div><header><strong>原型控制器</strong><small>不会出现在正式产品中</small></header><label>页面<select value={page==="player"?"home":page} onChange={e=>onNavigate(e.target.value)}>{Object.entries(pageMeta).map(([key,[label]])=><option value={key} key={key}>{label}</option>)}</select></label><label>数据状态<select value={demoState} onChange={e=>setDemoState(e.target.value)}><option value="normal">正常</option><option value="loading">加载中</option><option value="empty">空状态</option><option value="error">加载失败</option></select></label><label>主题<select value={theme} onChange={e=>setTheme(e.target.value)}><option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option></select></label></div>}</aside>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const initial=routeFromHash();
|
||||
const [route,setRoute]=useState(initial); const [columns,setColumns]=useState(2); const [mobileMore,setMobileMore]=useState(false); const [uploadOpen,setUploadOpen]=useState(false); const [prototypeOpen,setPrototypeOpen]=useState(false); const [demoState,setDemoState]=useState("normal"); const [toast,setToast]=useState(""); const [theme,setTheme]=useState("system"); const [masked,setMasked]=useState(false);
|
||||
const showPrototypeTools = new URLSearchParams(location.search).has("prototype");
|
||||
const currentVideo=useMemo(()=>videos.find(v=>v.id===route.id)||videos[0],[route.id]);
|
||||
const showToast=message=>{setToast(message);clearTimeout(showToast.timer);showToast.timer=setTimeout(()=>setToast(""),2400)};
|
||||
const navigate=(page,id)=>{location.hash=`#/${page}${id?`/${id}`:""}`};
|
||||
const play=(video)=>navigate("player",video?.id||videos[0].id);
|
||||
useEffect(()=>{const fn=()=>{setRoute(routeFromHash());setMobileMore(false);window.scrollTo({top:0,behavior:"instant"})};addEventListener("hashchange",fn);return()=>removeEventListener("hashchange",fn)},[]);
|
||||
useEffect(()=>{const dark=theme==="dark"||(theme==="system"&&matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.dataset.theme=dark?"dark":"light";document.querySelector('meta[name="theme-color"]')?.setAttribute("content",dark?"#0b0f16":"#f4f6fa")},[theme]);
|
||||
useEffect(()=>{document.body.classList.toggle("sheet-open",mobileMore||uploadOpen);return()=>document.body.classList.remove("sheet-open")},[mobileMore,uploadOpen]);
|
||||
useEffect(()=>{const key=e=>{if(e.key==="Escape"){setMobileMore(false);setUploadOpen(false);setPrototypeOpen(false)}if((e.metaKey||e.ctrlKey)&&e.key.toLowerCase()==="k"){e.preventDefault();navigate("search")}};addEventListener("keydown",key);return()=>removeEventListener("keydown",key)},[]);
|
||||
useEffect(()=>{const keyboard=e=>{if(e.key==="Tab"||e.key.startsWith("Arrow"))document.documentElement.dataset.input="keyboard"};const pointer=()=>{document.documentElement.dataset.input="pointer"};addEventListener("keydown",keyboard,true);addEventListener("pointerdown",pointer,true);return()=>{removeEventListener("keydown",keyboard,true);removeEventListener("pointerdown",pointer,true)}},[]);
|
||||
useEffect(()=>{const animate=e=>{const button=e.target instanceof Element?e.target.closest("button"):null;if(!button||button.disabled||button.matches(".player-tap-surface,.player-panel-scrim"))return;button.classList.remove("tap-animate");requestAnimationFrame(()=>{button.classList.add("tap-animate");button.addEventListener("animationend",()=>button.classList.remove("tap-animate"),{once:true})})};addEventListener("click",animate,true);return()=>removeEventListener("click",animate,true)},[]);
|
||||
const secondary=morePages.includes(route.page);
|
||||
const back=()=>history.length>1?history.back():navigate("home");
|
||||
let content;
|
||||
if(route.page==="home")content=<HomePage onNavigate={navigate} onPlay={play} columns={columns} setColumns={setColumns} demoState={demoState}/>;
|
||||
else if(route.page==="search")content=<SearchPage onPlay={play} demoState={demoState} onBack={back}/>;
|
||||
else if(route.page==="library")content=<LibraryPage onBack={back} demoState={demoState}/>;
|
||||
else if(route.page==="collections")content=<CollectionsPage onBack={back} onPlay={play} demoState={demoState}/>;
|
||||
else if(route.page==="uploads")content=<UploadsPage onBack={back} onUpload={()=>setUploadOpen(true)} onPlay={play} demoState={demoState}/>;
|
||||
else if(route.page==="tags")content=<TagsPage onBack={back} demoState={demoState}/>;
|
||||
else if(route.page==="people")content=<PeoplePage onBack={back} demoState={demoState}/>;
|
||||
else if(route.page==="profile")content=<ProfilePage onBack={back} onPlay={play} demoState={demoState} theme={theme} setTheme={setTheme}/>;
|
||||
else if(route.page==="settings")content=<SettingsPage onBack={back} demoState={demoState}/>;
|
||||
else content=<PlayerPage video={currentVideo} onBack={back} onToast={showToast}/>;
|
||||
return <div className={`app-shell page-${route.page} ${secondary?"secondary-page":""} ${masked?"covers-masked":""}`}>
|
||||
<header className="topbar"><button className="brand" onClick={()=>navigate("home")} aria-label="返回首页"><span className="brand-mark"><Film/></span><strong>ImageFind</strong><small>LOCAL</small></button><button className="top-search" onClick={()=>navigate("search")}><Search/><span>搜索画面、文字、人物或声音</span><kbd>⌘ K</kbd></button><div className="top-actions"><IconButton label={masked?"显示封面":"遮罩封面"} onClick={()=>setMasked(!masked)}>{masked?<EyeOff/>:<Eye/>}</IconButton><IconButton label="通知"><Bell/></IconButton><button className="avatar" onClick={()=>navigate("profile")}>南</button></div></header>
|
||||
<aside className="sidebar">{desktopGroups.map(group=><nav key={group.label}><p>{group.label}</p>{group.pages.map(page=>{const [label,Icon]=pageMeta[page];return <button className={route.page===page?"active":""} aria-current={route.page===page?"page":undefined} key={page} onClick={()=>navigate(page)}><Icon/><span>{label}</span>{page==="uploads"&&<b>1</b>}</button>})}</nav>)}<footer><button onClick={()=>navigate("settings")} className={route.page==="settings"?"active":""}><Settings/><span>设置</span></button><div><i/><span>本地 AI 正常</span></div></footer></aside>
|
||||
<div className="app-content">{content}</div>
|
||||
{route.page!=="player"&&<nav className="bottom-nav" aria-label="主导航"><button className={route.page==="home"?"active":""} onClick={()=>navigate("home")}><Home/><span>首页</span></button><button className={route.page==="search"?"active":""} onClick={()=>navigate("search")}><Search/><span>搜索</span></button><button className="upload-fab" onClick={()=>setUploadOpen(true)}><span><Plus/></span><b>上传</b></button><button className={route.page==="profile"?"active":""} onClick={()=>navigate("profile")}><CircleUserRound/><span>个人中心</span></button><button className={morePages.includes(route.page)?"active":""} onClick={()=>setMobileMore(true)}><Menu/><span>更多</span></button></nav>}
|
||||
{uploadOpen&&<UploadSheet onClose={()=>setUploadOpen(false)} onToast={showToast}/>} {mobileMore&&<MoreSheet onClose={()=>setMobileMore(false)} onNavigate={navigate}/>} {showPrototypeTools&&<PrototypePanel open={prototypeOpen} setOpen={setPrototypeOpen} demoState={demoState} setDemoState={setDemoState} theme={theme} setTheme={setTheme} page={route.page} onNavigate={navigate}/>} {toast&&<div className="toast" role="status"><CheckCircle2/>{toast}</div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")).render(<App/>);
|
||||
@@ -0,0 +1,49 @@
|
||||
export const videos = [
|
||||
{ id: "v1", title: "海风抵达之前", duration: "18:42", year: "2026", quality: "4K", scene: "coast", tags: ["旅行", "海边"], progress: 0, collection: "沿海公路日记", description: "从清晨的港口出发,沿着海岸线记录风、光线和路上的片刻。" },
|
||||
{ id: "v2", title: "城市醒来时", duration: "12:16", year: "2026", quality: "4K", scene: "city", tags: ["城市", "延时"], progress: 0.32, collection: "城市切片", description: "清晨六点到九点的街道声音与城市光影。" },
|
||||
{ id: "v3", title: "山谷里的夏天", duration: "26:08", year: "2025", quality: "1080P", scene: "valley", tags: ["自然", "露营"], progress: 0, collection: "周末出走", description: "雨后的山谷、营地和一场迟到的日落。" },
|
||||
{ id: "v4", title: "咖啡店的下午三点", duration: "09:48", year: "2026", quality: "4K", scene: "cafe", tags: ["生活", "人物"], progress: 0.68, collection: "生活记录", description: "窗边的谈话、咖啡机和午后斜着落下来的光。" },
|
||||
{ id: "v5", title: "穿过北方的雪", duration: "31:25", year: "2025", quality: "4K HDR", scene: "snow", tags: ["旅行", "雪景"], progress: 0, collection: "冬季路线", description: "一段穿过北方森林与雪原的自驾记录。" },
|
||||
{ id: "v6", title: "岛屿慢慢靠近", duration: "15:36", year: "2026", quality: "4K", scene: "island", tags: ["航拍", "海岛"], progress: 0, collection: "沿海公路日记", description: "从渡轮甲板到岛屿上空,记录靠岸的完整过程。" },
|
||||
{ id: "v7", title: "厨房里的第一场雨", duration: "08:57", year: "2026", quality: "1080P", scene: "kitchen", tags: ["生活", "雨天"], progress: 0, collection: "生活记录", description: "雨滴、蒸汽和一顿慢慢完成的晚餐。" },
|
||||
{ id: "v8", title: "夜行列车 0719", duration: "22:03", year: "2025", quality: "4K", scene: "train", tags: ["铁路", "夜景"], progress: 0.11, collection: "在路上", description: "从站台到清晨,夜行列车上的光和声音。" },
|
||||
{ id: "v9", title: "给六月留一段蓝色", duration: "13:20", year: "2026", quality: "4K", scene: "blue", tags: ["随拍", "夏天"], progress: 0, collection: "城市切片", description: "六月的天空、玻璃幕墙和一些没有计划的镜头。" },
|
||||
{ id: "v10", title: "旧相机与新街区", duration: "17:54", year: "2025", quality: "1080P", scene: "film", tags: ["街拍", "胶片"], progress: 0, collection: "城市切片", description: "带着旧相机重新走过正在改变的街区。" },
|
||||
{ id: "v11", title: "森林边缘的声音", duration: "29:11", year: "2026", quality: "4K", scene: "forest", tags: ["自然", "环境音"], progress: 0, collection: "周末出走", description: "风吹过林缘、溪水和远处鸟鸣组成的声音记录。" },
|
||||
{ id: "v12", title: "灯熄灭以后", duration: "11:06", year: "2025", quality: "4K", scene: "night", tags: ["城市", "夜景"], progress: 0, collection: "城市切片", description: "商店打烊之后,街道留下来的微光。" }
|
||||
];
|
||||
|
||||
export const searchResults = [
|
||||
{ id: "s1", videoId: "v1", at: "08:24", range: "08:21–08:31", type: "画面语义", score: "96%", text: "海边公路、橙色日落与移动中的车辆", scene: "coast" },
|
||||
{ id: "s2", videoId: "v6", at: "04:16", range: "04:10–04:24", type: "OCR 文字", score: "91%", text: "画面识别到:下一站 · 蓝湾码头", scene: "island" },
|
||||
{ id: "s3", videoId: "v4", at: "06:38", range: "06:31–06:49", type: "音频识别", score: "88%", text: "“今天的光线很像去年在海边的那个下午。”", scene: "cafe" },
|
||||
{ id: "s4", videoId: "v9", at: "02:52", range: "02:49–03:02", type: "人物识别", score: "86%", text: "检测到人物:南熏", scene: "blue" }
|
||||
];
|
||||
|
||||
export const collections = [
|
||||
{ id: "c1", name: "沿海公路日记", count: 8, duration: "2 小时 48 分", scene: "coast", description: "沿海路线、岛屿与港口的连续记录。", chapters: ["出发前", "沿海公路", "岛屿与码头"] },
|
||||
{ id: "c2", name: "城市切片", count: 12, duration: "3 小时 06 分", scene: "city", description: "不同时间、天气与街区里的城市片段。", chapters: ["清晨", "午后", "夜间"] },
|
||||
{ id: "c3", name: "周末出走", count: 6, duration: "1 小时 55 分", scene: "valley", description: "短途露营、山谷与森林中的环境记录。", chapters: ["抵达", "营地", "返程"] },
|
||||
{ id: "c4", name: "生活记录", count: 15, duration: "2 小时 31 分", scene: "cafe", description: "不用整理得很正式,也值得留下来的日常。", chapters: ["家", "朋友", "下雨天"] }
|
||||
];
|
||||
|
||||
export const uploadTasks = [
|
||||
{ id: "u1", name: "岛屿航拍素材-02.mp4", size: "3.8 GB", state: "active", progress: 64, stage: "转存至 WebDAV", detail: "2.4 GB / 3.8 GB · 38.6 MB/s", collection: "沿海公路日记" },
|
||||
{ id: "u2", name: "城市延时-清晨.mp4", size: "1.2 GB", state: "waiting", progress: 100, stage: "等待媒体分析", detail: "上传已完成 · 排队第 2 位", collection: "城市切片" },
|
||||
{ id: "u3", name: "旧相机测试片段.mov", size: "684 MB", state: "failed", progress: 46, stage: "转存失败", detail: "网络连接已中断,可从 312 MB 继续", collection: "城市切片" },
|
||||
{ id: "u4", name: "厨房里的第一场雨.mp4", size: "826 MB", state: "done", progress: 100, stage: "已入库", detail: "今天 00:48 完成", videoId: "v7", collection: "生活记录" },
|
||||
{ id: "u5", name: "北方雪原-完整片.mp4", size: "5.1 GB", state: "done", progress: 100, stage: "已入库", detail: "昨天 23:16 完成", videoId: "v5", collection: "冬季路线" }
|
||||
];
|
||||
|
||||
export const people = [
|
||||
{ id: "p1", name: "南熏", count: 18, scene: "portrait1", role: "已命名" },
|
||||
{ id: "p2", name: "小林", count: 9, scene: "portrait2", role: "已命名" },
|
||||
{ id: "p3", name: "未命名人物 03", count: 6, scene: "portrait3", role: "待确认" },
|
||||
{ id: "p4", name: "未命名人物 04", count: 4, scene: "portrait4", role: "待确认" }
|
||||
];
|
||||
|
||||
export const tagGroups = [
|
||||
{ name: "内容类型", selection: "单选", tags: [["旅行", 16], ["生活", 12], ["素材", 9], ["纪录", 7]] },
|
||||
{ name: "场景", selection: "多选", tags: [["海边", 8], ["城市", 14], ["自然", 11], ["室内", 6], ["夜景", 5]] },
|
||||
{ name: "画质", selection: "单选", tags: [["4K", 22], ["1080P", 19], ["HDR", 6]] }
|
||||
];
|
||||