diff --git a/README.md b/README.md index 7dbf7eb..a352613 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,137 @@ -# React + TypeScript + Vite +# DEVELOP 显影前端 -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +DEVELOP(显影)是面向图片与视频生成内容的可解释检测与取证演示前端。项目包含用户侧检测流程、图片/视频报告、治理端数据生成、专家库和异常池等页面。 -Currently, two official plugins are available: +## 技术栈 -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +- React 18 +- TypeScript +- Vite +- React Router +- Framer Motion +- Zustand +- CSS Modules -## React Compiler +## 环境要求 -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +建议使用较新的 Node.js 版本。本项目当前验证环境: -## Expanding the ESLint configuration +```bash +node v24.11.1 +npm 11.7.0 +``` + +如果本机 Node 版本较旧,建议升级到 Node 20+ 后再安装依赖。 + +## 安装与运行 + +进入项目目录: + +```bash +cd develop +``` + +安装依赖: + +```bash +npm install +``` -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: +启动开发服务器: -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... +```bash +npm run dev +``` - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, +Vite 会在终端输出本地访问地址,通常是: - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```text +http://localhost:5173/ ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +## 构建与预览 + +生产构建: + +```bash +npm run build ``` + +本地预览构建产物: + +```bash +npm run preview +``` + +## 常用路由 + +- `/`:首页 +- `/detect`:检测类型选择 +- `/detect/image`:图像取证 +- `/detect/video`:视频检测 +- `/detect/report/:id`:鉴别报告 +- `/admin`:治理中心 +- `/admin/pipeline`:数据生成 +- `/admin/pipeline/showcase/video`:视频标注解构台 +- `/admin/pipeline/showcase/image`:图片标注工作流 +- `/admin/experts`:专家库 +- `/admin/anomaly`:异常池 +- `/dev`:组件展示页 + +## 推荐演示流程 + +1. 打开 `/` 查看首页和项目入口。 +2. 进入 `/detect/image`,上传图片或使用示例图,等待检测完成。 +3. 点击“查看详细报告”,报告会展示本次检测图片、关键证据、语义链和专家网络。 +4. 进入 `/detect/video`,等待视频检测完成。 +5. 点击“查看完整报告”,报告会展示视频材料和片段级证据。 +6. 进入 `/admin` 查看治理端能力,再按需要进入数据生成、专家库和异常池页面。 + +## 脚本说明 + +```bash +npm run dev # 启动开发服务器 +npm run build # TypeScript 检查并构建生产包 +npm run preview # 预览 dist 构建产物 +npm run lint # 运行 ESLint +``` + +## 当前验证状态 + +最近一次验证: + +```bash +npm run build +``` + +构建通过。 + +`npm run lint` 当前存在既有规则报错,主要来自以下位置: + +- `src/components/GlobalHUD.tsx`:render 中调用 `Math.random` +- `src/pages/AdminAnomaly/index.tsx`:effect 中同步 setState +- `src/pages/AdminPipeline/ImageShowcase.tsx`:render 初始化中调用 `Date.now` + +这些 lint 问题不影响 `npm run dev` 和 `npm run build`,但后续如果要把 lint 作为 CI 必过项,需要单独修复。 + +## 目录说明 + +```text +src/ + App.tsx # 路由入口 + components/ # 通用组件与业务组件 + data/ # mock 数据与报告快照存储 + layouts/ # 管理端布局 + pages/ # 页面实现 + tokens/ # 全局设计 token +public/ + images/ # 图片素材 + samples/ # 样本与视频帧素材 + videos/ # 视频工作流素材 +``` + +## 注意事项 + +- 项目代码位于 `develop` 目录,不是仓库根目录的旧前端。 +- 不需要后端服务即可运行当前演示,检测结果和报告使用前端 mock 数据与浏览器 `sessionStorage` 快照。 +- 图片报告会根据当前上传或示例图片实时生成,不再固定显示模板图。 diff --git a/image.png b/image.png new file mode 100644 index 0000000..a165e37 Binary files /dev/null and b/image.png differ diff --git a/public/images/pipeline-products/local_edited.png b/public/images/pipeline-products/local_edited.png new file mode 100644 index 0000000..3be55df Binary files /dev/null and b/public/images/pipeline-products/local_edited.png differ diff --git a/public/images/pipeline-products/product-01.png b/public/images/pipeline-products/product-01.png new file mode 100644 index 0000000..6a08878 Binary files /dev/null and b/public/images/pipeline-products/product-01.png differ diff --git a/public/images/pipeline-products/product-03.png b/public/images/pipeline-products/product-03.png new file mode 100644 index 0000000..86cfc12 Binary files /dev/null and b/public/images/pipeline-products/product-03.png differ diff --git a/public/images/pipeline-products/product-05.png b/public/images/pipeline-products/product-05.png new file mode 100644 index 0000000..a0a4339 Binary files /dev/null and b/public/images/pipeline-products/product-05.png differ diff --git a/public/images/pipeline-products/product-07.png b/public/images/pipeline-products/product-07.png new file mode 100644 index 0000000..f018003 Binary files /dev/null and b/public/images/pipeline-products/product-07.png differ diff --git "a/public/images/\345\233\276\347\211\207\346\243\200\346\265\213\347\244\272\344\276\213\345\233\276/image.png" "b/public/images/\345\233\276\347\211\207\346\243\200\346\265\213\347\244\272\344\276\213\345\233\276/image.png" index 105ed76..7e2c8b3 100644 Binary files "a/public/images/\345\233\276\347\211\207\346\243\200\346\265\213\347\244\272\344\276\213\345\233\276/image.png" and "b/public/images/\345\233\276\347\211\207\346\243\200\346\265\213\347\244\272\344\276\213\345\233\276/image.png" differ diff --git "a/public/images/\345\233\276\347\211\207\346\243\200\346\265\213\347\244\272\344\276\213\345\233\276/image_\346\240\207\346\263\250.png" "b/public/images/\345\233\276\347\211\207\346\243\200\346\265\213\347\244\272\344\276\213\345\233\276/image_\346\240\207\346\263\250.png" new file mode 100644 index 0000000..dd9a25c Binary files /dev/null and "b/public/images/\345\233\276\347\211\207\346\243\200\346\265\213\347\244\272\344\276\213\345\233\276/image_\346\240\207\346\263\250.png" differ diff --git a/public/samples/fake_seg1s__.mp4 b/public/samples/fake_seg1s__.mp4 new file mode 100644 index 0000000..0f65a30 Binary files /dev/null and b/public/samples/fake_seg1s__.mp4 differ diff --git a/public/samples/frames/frame-01.jpg b/public/samples/frames/frame-01.jpg new file mode 100644 index 0000000..6d9ee4c Binary files /dev/null and b/public/samples/frames/frame-01.jpg differ diff --git a/public/samples/frames/frame-02.jpg b/public/samples/frames/frame-02.jpg new file mode 100644 index 0000000..c042803 Binary files /dev/null and b/public/samples/frames/frame-02.jpg differ diff --git a/public/samples/frames/frame-03.jpg b/public/samples/frames/frame-03.jpg new file mode 100644 index 0000000..66a33d0 Binary files /dev/null and b/public/samples/frames/frame-03.jpg differ diff --git a/public/samples/frames/frame-04.jpg b/public/samples/frames/frame-04.jpg new file mode 100644 index 0000000..9e1619f Binary files /dev/null and b/public/samples/frames/frame-04.jpg differ diff --git a/public/samples/frames/frame-05.jpg b/public/samples/frames/frame-05.jpg new file mode 100644 index 0000000..7e96757 Binary files /dev/null and b/public/samples/frames/frame-05.jpg differ diff --git a/public/samples/frames/frame-06.jpg b/public/samples/frames/frame-06.jpg new file mode 100644 index 0000000..16ec1b6 Binary files /dev/null and b/public/samples/frames/frame-06.jpg differ diff --git a/public/samples/frames/frame-07.jpg b/public/samples/frames/frame-07.jpg new file mode 100644 index 0000000..07bd2ec Binary files /dev/null and b/public/samples/frames/frame-07.jpg differ diff --git a/public/samples/frames/frame-08.jpg b/public/samples/frames/frame-08.jpg new file mode 100644 index 0000000..6033e62 Binary files /dev/null and b/public/samples/frames/frame-08.jpg differ diff --git a/public/samples/frames/frame-09.jpg b/public/samples/frames/frame-09.jpg new file mode 100644 index 0000000..344ce84 Binary files /dev/null and b/public/samples/frames/frame-09.jpg differ diff --git a/public/samples/frames/frame-10.jpg b/public/samples/frames/frame-10.jpg new file mode 100644 index 0000000..f722616 Binary files /dev/null and b/public/samples/frames/frame-10.jpg differ diff --git a/public/samples/frames/frame-11.jpg b/public/samples/frames/frame-11.jpg new file mode 100644 index 0000000..e4920c9 Binary files /dev/null and b/public/samples/frames/frame-11.jpg differ diff --git a/public/samples/frames/frame-12.jpg b/public/samples/frames/frame-12.jpg new file mode 100644 index 0000000..3590c3a Binary files /dev/null and b/public/samples/frames/frame-12.jpg differ diff --git a/screenshots/01-home.png b/screenshots/01-home.png new file mode 100644 index 0000000..5bc7ae4 Binary files /dev/null and b/screenshots/01-home.png differ diff --git a/screenshots/02-detect-choice.png b/screenshots/02-detect-choice.png new file mode 100644 index 0000000..fe9fe47 Binary files /dev/null and b/screenshots/02-detect-choice.png differ diff --git a/screenshots/03-detect-image.png b/screenshots/03-detect-image.png new file mode 100644 index 0000000..6090353 Binary files /dev/null and b/screenshots/03-detect-image.png differ diff --git a/screenshots/04-detect-video.png b/screenshots/04-detect-video.png new file mode 100644 index 0000000..aed7fef Binary files /dev/null and b/screenshots/04-detect-video.png differ diff --git a/screenshots/05-report.png b/screenshots/05-report.png new file mode 100644 index 0000000..6455f67 Binary files /dev/null and b/screenshots/05-report.png differ diff --git a/screenshots/06-admin-overview.png b/screenshots/06-admin-overview.png new file mode 100644 index 0000000..a9adc9c Binary files /dev/null and b/screenshots/06-admin-overview.png differ diff --git a/screenshots/07-admin-pipeline.png b/screenshots/07-admin-pipeline.png new file mode 100644 index 0000000..90dfb13 Binary files /dev/null and b/screenshots/07-admin-pipeline.png differ diff --git a/screenshots/08-admin-pipeline-video-showcase.png b/screenshots/08-admin-pipeline-video-showcase.png new file mode 100644 index 0000000..4a9c3ec Binary files /dev/null and b/screenshots/08-admin-pipeline-video-showcase.png differ diff --git a/screenshots/09-admin-pipeline-image-showcase.png b/screenshots/09-admin-pipeline-image-showcase.png new file mode 100644 index 0000000..e16fef8 Binary files /dev/null and b/screenshots/09-admin-pipeline-image-showcase.png differ diff --git a/screenshots/10-admin-experts.png b/screenshots/10-admin-experts.png new file mode 100644 index 0000000..fd08100 Binary files /dev/null and b/screenshots/10-admin-experts.png differ diff --git a/screenshots/11-admin-anomaly.png b/screenshots/11-admin-anomaly.png new file mode 100644 index 0000000..abab404 Binary files /dev/null and b/screenshots/11-admin-anomaly.png differ diff --git a/screenshots/12-dev.png b/screenshots/12-dev.png new file mode 100644 index 0000000..0f89f01 Binary files /dev/null and b/screenshots/12-dev.png differ diff --git a/screenshots/13-not-found.png b/screenshots/13-not-found.png new file mode 100644 index 0000000..eb42171 Binary files /dev/null and b/screenshots/13-not-found.png differ diff --git a/src/components/BootSequence.module.css b/src/components/BootSequence.module.css new file mode 100644 index 0000000..2939c84 --- /dev/null +++ b/src/components/BootSequence.module.css @@ -0,0 +1,106 @@ +.bootScreen { + position: fixed; + inset: 0; + background: #0E0D0B; + z-index: 999999; + display: flex; + align-items: center; + justify-content: center; + color: var(--accent); + font-family: var(--font-mono); + overflow: hidden; +} + +.terminal { + width: 80%; + max-width: 800px; + display: flex; + flex-direction: column; + gap: 48px; + position: relative; + z-index: 2; +} + +.logo { + font-size: 64px; + font-family: var(--font-serif); + letter-spacing: 0.2em; + color: var(--surface); + text-shadow: 0 0 30px rgba(200, 65, 46, 0.4); + animation: glitch 3s infinite; +} + +@keyframes glitch { + 0% { transform: translate(0) } + 20% { transform: translate(-2px, 1px) } + 21% { transform: translate(2px, -1px) } + 22% { transform: translate(0) } + 100% { transform: translate(0) } +} + +.logs { + display: flex; + flex-direction: column; + gap: 12px; + font-size: 16px; + color: var(--accent); + text-shadow: 0 0 10px rgba(200, 65, 46, 0.6); +} + +.logLine { + opacity: 0; + animation: typeIn 0.1s forwards; +} + +@keyframes typeIn { + to { opacity: 1; } +} + +.cursor { + width: 12px; + height: 20px; + background: var(--accent); + animation: blink 1s step-end infinite; + margin-top: 8px; +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +.scanline { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 30vh; + background: linear-gradient(to bottom, transparent, rgba(200, 65, 46, 0.1), transparent); + animation: scan 4s linear infinite; + z-index: 1; + pointer-events: none; +} + +@keyframes scan { + 0% { transform: translateY(-100vh); } + 100% { transform: translateY(200vh); } +} + +.gridOverlay { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(200, 65, 46, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(200, 65, 46, 0.05) 1px, transparent 1px); + background-size: 40px 40px; + transform: perspective(500px) rotateX(60deg) scale(2); + transform-origin: bottom center; + opacity: 0.3; + z-index: 1; + animation: gridMove 20s linear infinite; +} + +@keyframes gridMove { + 0% { transform: perspective(500px) rotateX(60deg) translateY(0) scale(2); } + 100% { transform: perspective(500px) rotateX(60deg) translateY(40px) scale(2); } +} diff --git a/src/components/BootSequence.tsx b/src/components/BootSequence.tsx new file mode 100644 index 0000000..b052b63 --- /dev/null +++ b/src/components/BootSequence.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import styles from './BootSequence.module.css'; + +const BOOT_LOGS = [ + "> INITIATING OS_KERNEL_V4.2...", + "> ESTABLISHING NEURAL LINK...", + "> SYNCING GLOBAL THREAT DATABASE [██████████] 100%", + "> DECRYPTING SECURE CHANNELS...", + "> LOADING TACTICAL HUD...", + "> ACCESS GRANTED. WELCOME, ADMIN." +]; + +export function BootSequence() { + const [isVisible, setIsVisible] = useState(true); + const [logs, setLogs] = useState([]); + + useEffect(() => { + let delay = 200; + BOOT_LOGS.forEach((log) => { + setTimeout(() => { + setLogs((prev) => [...prev, log]); + }, delay); + delay += Math.random() * 250 + 150; + }); + + setTimeout(() => { + setIsVisible(false); + }, delay + 600); + }, []); + + return ( + + {isVisible && ( + +
+
ANTIGRAVITY OS
+
+ {logs.map((log, i) => ( +
{log}
+ ))} +
+
+
+
+
+ + )} + + ); +} diff --git a/src/components/ForensicMark/ForensicMark.module.css b/src/components/ForensicMark/ForensicMark.module.css index 5c6e2eb..239ad54 100644 --- a/src/components/ForensicMark/ForensicMark.module.css +++ b/src/components/ForensicMark/ForensicMark.module.css @@ -1,38 +1,171 @@ -.mark { +.markWrapper { position: absolute; - border: 1.5px solid var(--accent); - background: transparent; pointer-events: none; - transform-origin: center; + z-index: 10; } -.mark::after { - content: ""; +/* 半透明填充框 */ +.markWrapper::before { + content: ''; position: absolute; - right: -34px; - bottom: -18px; - width: 34px; - border-top: 1px dashed var(--accent); + inset: 0; + background: rgba(200, 65, 46, 0.05); + border: 1px solid rgba(200, 65, 46, 0.7); + pointer-events: none; + border-radius: 0; +} +.active::before { + background: rgba(200, 65, 46, 0.13); + border-color: rgba(200, 65, 46, 0.7); +} + +/* 科技感边角 */ +.cornerTL, .cornerTR, .cornerBL, .cornerBR { + position: absolute; + width: 16px; + height: 16px; + border-color: var(--accent); + border-style: solid; + border-width: 0; + opacity: 0.85; + transition: all 0.3s ease; +} + +.active .cornerTL, .active .cornerTR, .active .cornerBL, .active .cornerBR { + opacity: 1; + width: 22px; + height: 22px; + border-color: var(--real); +} + +.cornerTL { top: 0; left: 0; border-top-width: 2.5px; border-left-width: 2.5px; } +.cornerTR { top: 0; right: 0; border-top-width: 2.5px; border-right-width: 2.5px; } +.cornerBL { bottom: 0; left: 0; border-bottom-width: 2.5px; border-left-width: 2.5px; } +.cornerBR { bottom: 0; right: 0; border-bottom-width: 2.5px; border-right-width: 2.5px; } + +/* 中心热点 */ +.centerHotspot { + position: absolute; + top: 50%; + left: 50%; + width: 1px; + height: 1px; +} + +.core { + position: absolute; + top: -4px; + left: -4px; + width: 8px; + height: 8px; + background: var(--accent); + border-radius: 50%; + box-shadow: 0 0 10px var(--accent); +} + +.active .core { + background: var(--real); + box-shadow: 0 0 15px var(--real); } -.label { +.ripple { position: absolute; - left: calc(100% + 34px); - bottom: -27px; + top: -24px; + left: -24px; + width: 48px; + height: 48px; + border: 1px solid var(--accent); + border-radius: 50%; + animation: sonarPing 2s ease-out infinite; +} + +@keyframes sonarPing { + 0% { transform: scale(0.2); opacity: 1; border-width: 2px; } + 100% { transform: scale(2.5); opacity: 0; border-width: 1px; } +} + +/* 引线与标签 — 默认从右下角引出,向右延伸 */ +.callout { + position: absolute; + bottom: -15px; + right: -40px; +} + +.calloutLineHorizontal { + position: absolute; + bottom: 12px; + right: 0; + width: 40px; + height: 1px; + background: var(--accent); + transform-origin: left; +} + +.active .calloutLineHorizontal { + background: var(--real); +} + +.labelWrapper { + position: absolute; + bottom: 0; + left: 100%; + display: flex; + align-items: center; + gap: 6px; + background: var(--surface); + border: 1px solid var(--accent); + padding: 6px 12px; + border-radius: 0; + color: var(--accent); white-space: nowrap; + background-color: rgba(255, 255, 255, 0.95); + box-shadow: 0 0 0 1px var(--surface); +} + +.active .labelWrapper { + border-color: var(--real); + color: var(--real); +} + +/* 标签向左翻转 — 用于靠近右边缘的标记 */ +.calloutLeft { + right: auto; + left: -40px; +} + +.calloutLeft .calloutLineHorizontal { + right: auto; + left: 0; + transform-origin: right; +} + +.calloutLeft .labelWrapper { + left: auto; + right: 100%; +} + +.labelIcon { color: var(--accent); - font-family: var(--font-mono); font-size: 12px; - line-height: 1; - padding: 4px 6px; } -.active { - border-width: 2.5px; - outline: 8px solid var(--accent-soft); +.active .labelIcon { + color: var(--real); } -.active .label { - background: var(--accent); - color: white; +.labelTitle { + font-family: var(--font-mono); + font-size: 16px; + font-weight: var(--weight-bold); + letter-spacing: 0.05em; +} + +.labelConfidence { + font-family: var(--font-mono); + font-size: 16px; + color: var(--accent); +} + +.active .labelConfidence { + color: var(--real); } diff --git a/src/components/ForensicMark/ForensicMark.tsx b/src/components/ForensicMark/ForensicMark.tsx index 3c1e29a..cc48cc5 100644 --- a/src/components/ForensicMark/ForensicMark.tsx +++ b/src/components/ForensicMark/ForensicMark.tsx @@ -9,25 +9,62 @@ export interface ForensicMarkProps { label: string; confidence: number; active?: boolean; + tooltip?: string; + /** 'right'(默认): 标签从右下角向右延伸; 'left': 从左下角向左延伸(用于靠右边缘的标记) */ + calloutSide?: 'right' | 'left'; } -export function ForensicMark({ x, y, w, h, label, confidence, active = false }: ForensicMarkProps) { +export function ForensicMark({ x, y, w, h, label, confidence, active = false, tooltip, calloutSide = 'right' }: ForensicMarkProps) { + const isLeft = calloutSide === 'left'; return ( - - {label} · {confidence.toFixed(2)} - + {tooltip && ( +
+ {tooltip} +
+ )} + {/* 科技感边角 */} + + + + + + {/* 中心呼吸焦点 */} +
+ + +
+ + {/* 引线与标签 */} +
+ + + + {label} + {(confidence * 100).toFixed(0)}% + +
); } diff --git a/src/components/GlobalHUD.module.css b/src/components/GlobalHUD.module.css new file mode 100644 index 0000000..aafae7c --- /dev/null +++ b/src/components/GlobalHUD.module.css @@ -0,0 +1,122 @@ +.hudOverlay { + position: fixed; + inset: 0; + z-index: 99900; + pointer-events: none; + overflow: hidden; +} + +/* Crosshairs */ +.corner { + position: absolute; + width: 24px; + height: 24px; + border: 1px solid var(--accent); + opacity: 0.4; +} + +.tl { top: 32px; left: 88px; border-right: none; border-bottom: none; } /* Offset to clear sidebar */ +.tr { top: 32px; right: 32px; border-left: none; border-bottom: none; } +.bl { bottom: 32px; left: 88px; border-right: none; border-top: none; } +.br { bottom: 32px; right: 32px; border-left: none; border-top: none; } + +/* Top Status */ +.topStatus { + position: absolute; + top: 36px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 12px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--accent); + letter-spacing: 0.2em; + opacity: 0.8; +} + +.recordDot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + animation: blink 1.5s infinite; +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +.time { + margin-left: 8px; + color: var(--ink-3); +} + +/* Data Stream */ +.dataStream { + position: absolute; + right: 16px; + top: 0; + bottom: 0; + width: 80px; + overflow: hidden; + display: flex; + flex-direction: column; + justify-content: center; + opacity: 0.15; +} + +.dataInner { + display: flex; + flex-direction: column; + gap: 6px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--ink); + animation: scrollUp 20s linear infinite; +} + +@keyframes scrollUp { + 0% { transform: translateY(0); } + 100% { transform: translateY(-50%); } +} + +/* Tactical Cursor Tracker */ +.tacticalCursor { + position: absolute; + top: 0; left: 0; + width: 48px; height: 48px; + margin-left: -24px; margin-top: -24px; + border: 1px dashed var(--accent); + border-radius: 50%; + opacity: 0.35; + transition: transform 0.15s cubic-bezier(0.2, 0.8, 0.2, 1); + will-change: transform; + z-index: 99999; +} +.tacticalCursor::after { + content: ''; + position: absolute; + top: 50%; left: 50%; + width: 4px; height: 4px; + margin-left: -2px; margin-top: -2px; + background: var(--accent); + border-radius: 50%; +} + +/* CRT Scanline Sweep */ +.scanlineSweep { + position: absolute; + top: 0; left: 0; right: 0; + height: 12vh; + background: linear-gradient(to bottom, transparent, rgba(200, 65, 46, 0.05), transparent); + animation: sweepDown 8s linear infinite; + opacity: 0.8; +} + +@keyframes sweepDown { + 0% { transform: translateY(-100%); } + 100% { transform: translateY(1000%); } +} diff --git a/src/components/GlobalHUD.tsx b/src/components/GlobalHUD.tsx new file mode 100644 index 0000000..ce48aa5 --- /dev/null +++ b/src/components/GlobalHUD.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from 'react'; +import styles from './GlobalHUD.module.css'; + +export function GlobalHUD() { + const [time, setTime] = useState(new Date().toLocaleTimeString()); + const [mousePos, setMousePos] = useState({ x: -100, y: -100 }); + + useEffect(() => { + const timer = setInterval(() => { + setTime(new Date().toLocaleTimeString()); + }, 1000); + + const handleMouseMove = (e: MouseEvent) => { + setMousePos({ x: e.clientX, y: e.clientY }); + }; + window.addEventListener('mousemove', handleMouseMove); + + return () => { + clearInterval(timer); + window.removeEventListener('mousemove', handleMouseMove); + }; + }, []); + + return ( +
+ {/* 4 Corners */} +
+
+
+
+ + {/* Top Status */} +
+ + LIVE SECURE CONNECTION + {time} +
+ + {/* Right Edge Data Stream */} +
+
+ {Array.from({ length: 40 }).map((_, i) => ( +
{Math.random().toString(16).substr(2, 8).toUpperCase()}
+ ))} +
+
+ + {/* Tactical Mouse Tracker */} +
+ + {/* CRT Scanline Sweep */} +
+
+ ); +} diff --git a/src/components/primitives/PageContainer/PageContainer.tsx b/src/components/primitives/PageContainer/PageContainer.tsx index 8939c24..052e0fa 100644 --- a/src/components/primitives/PageContainer/PageContainer.tsx +++ b/src/components/primitives/PageContainer/PageContainer.tsx @@ -4,8 +4,9 @@ import styles from './PageContainer.module.css'; interface PageContainerProps { children: ReactNode; width?: 'narrow' | 'normal' | 'wide'; + className?: string; } -export function PageContainer({ children, width = 'normal' }: PageContainerProps) { - return
{children}
; +export function PageContainer({ children, width = 'normal', className }: PageContainerProps) { + return
{children}
; } diff --git a/src/data/mocks.ts b/src/data/mocks.ts index a64fb4c..ce3b52f 100644 --- a/src/data/mocks.ts +++ b/src/data/mocks.ts @@ -23,62 +23,65 @@ export const videoFrames = Array.from({ length: 30 }, (_, index) => ({ time: index, })); +// 视频检测台使用 samples/fake_seg1s__.mp4(单段音频替换伪造,时长 11.744s) +// 来源:fake_seg1s__.json — modify_type: replace +// fake_segments: [[1.837, 5.277]] +// fake_info: ["shallow gasps as you inhale"] +// transcripts: "Gotta take shallow gasps as you inhale 1, 2, 3 And then you'll hear a yodel if you listen close to me" +const VIDEO_DURATION = 11.744; +const VIDEO_FRAME_COUNT = 12; + export const videoDemoFull = { - id: 'DV-2026-1121-V01', - src: '/videos/video-demo-01.mp4', - duration: 30, + id: 'DV-2026-0527-V01', + src: '/samples/fake_seg1s__.mp4', + duration: VIDEO_DURATION, verdict: 'fake' as const, - confidence: 0.91, - windows: [ - { start: 0, end: 5 }, - { start: 5, end: 10 }, - { start: 10, end: 15 }, - { start: 15, end: 20 }, - { start: 20, end: 25 }, - { start: 25, end: 30 }, - ], - frames: Array.from({ length: 30 }, (_, index) => ({ - time: index, - src: `/samples/${String((index % 54) + 1).padStart(2, '0')}.jpg`, + confidence: 0.87, + // 6 个时间窗口,均匀切分(每窗口约 1.96s) + windows: Array.from({ length: 6 }, (_, i) => ({ + start: parseFloat(((i / 6) * VIDEO_DURATION).toFixed(2)), + end: parseFloat((((i + 1) / 6) * VIDEO_DURATION).toFixed(2)), })), + // 帧带:从 fake_seg1s__.mp4 实际抽取的 12 帧(均匀分布) + // 对应时间: 0.00, 1.07, 2.14, 3.20, 4.27, 5.34, 6.41, 7.47, 8.54, 9.61, 10.68, 11.50s + frames: Array.from({ length: VIDEO_FRAME_COUNT }, (_, i) => ({ + time: parseFloat(((i / (VIDEO_FRAME_COUNT - 1)) * VIDEO_DURATION).toFixed(2)), + src: `/samples/frames/frame-${String(i + 1).padStart(2, '0')}.jpg`, + })), + // 候选时段:2 段高置信(覆盖真实伪造区间)+ 5 段低置信噪声 candidates: [ - { id: 'c1', start: 2.1, end: 3.5, confidence: 0.32 }, - { id: 'c2', start: 4.8, end: 6.2, confidence: 0.45 }, - { id: 'c3', start: 8.0, end: 14.9, confidence: 0.89 }, - { id: 'c4', start: 15.5, end: 17.0, confidence: 0.28 }, - { id: 'c5', start: 18.2, end: 19.5, confidence: 0.51 }, - { id: 'c6', start: 20.8, end: 25.0, confidence: 0.83 }, - { id: 'c7', start: 26.0, end: 27.5, confidence: 0.38 }, + { id: 'c1', start: 0.0, end: 1.3, confidence: 0.21 }, + { id: 'c2', start: 1.5, end: 3.6, confidence: 0.83 }, // 覆盖伪造段前半 + { id: 'c3', start: 3.4, end: 5.6, confidence: 0.79 }, // 覆盖伪造段后半 + { id: 'c4', start: 5.7, end: 7.2, confidence: 0.27 }, + { id: 'c5', start: 7.3, end: 8.9, confidence: 0.18 }, + { id: 'c6', start: 9.0, end: 10.3, confidence: 0.23 }, + { id: 'c7', start: 10.5, end: 11.7, confidence: 0.16 }, ], + // 最终确认的高风险伪造时段(对齐 fake_seg1s__.json 标注) fakeRanges: [ { - start: 8.2, - end: 14.7, - reason: '跨帧身份漂移', - english: 'Cross-frame identity drift', - confidence: 0.89, - keyframes: ['/samples/18.jpg', '/samples/19.jpg', '/samples/20.jpg'], - expertVotes: [ - { type: 'texture' as const, name: '纹理专家', intensity: 0.4 }, - { type: 'frequency' as const, name: '谱纹专家', intensity: 0.85 }, - { type: 'style' as const, name: '风格专家', intensity: 0.6 }, - { type: 'semantic' as const, name: '语义专家', intensity: 0.92 }, - { type: 'lora' as const, name: '靶向专家', intensity: 0.75 }, + start: 1.837, + end: 5.277, + reason: '音频语音克隆替换', + english: 'Voice clone replacement', + confidence: 0.87, + modifyType: 'replace' as const, + // 原始转录 & 伪造语段(直接来自 JSON) + fullTranscript: "Gotta take shallow gasps as you inhale 1, 2, 3 And then you'll hear a yodel if you listen close to me", + fakeWords: 'shallow gasps as you inhale', + // 关键证据帧:来自伪造时段 1.837–5.277s 内的实际视频帧 + keyframes: [ + '/samples/frames/frame-03.jpg', // t≈2.14s — 伪造段前段 + '/samples/frames/frame-04.jpg', // t≈3.20s — 伪造段中段 + '/samples/frames/frame-05.jpg', // t≈4.27s — 伪造段后段 ], - }, - { - start: 21.0, - end: 24.5, - reason: '口型不同步', - english: 'Lip-sync mismatch', - confidence: 0.83, - keyframes: ['/samples/31.jpg', '/samples/32.jpg', '/samples/33.jpg'], expertVotes: [ - { type: 'texture' as const, name: '纹理专家', intensity: 0.3 }, - { type: 'frequency' as const, name: '谱纹专家', intensity: 0.6 }, - { type: 'style' as const, name: '风格专家', intensity: 0.55 }, - { type: 'semantic' as const, name: '语义专家', intensity: 0.78 }, - { type: 'lora' as const, name: '靶向专家', intensity: 0.5 }, + { type: 'texture' as const, name: '纹理专家', intensity: 0.28 }, + { type: 'frequency' as const, name: '谱纹专家', intensity: 0.91 }, + { type: 'style' as const, name: '风格专家', intensity: 0.44 }, + { type: 'semantic' as const, name: '语义专家', intensity: 0.76 }, + { type: 'lora' as const, name: '靶向专家', intensity: 0.83 }, ], }, ], diff --git a/src/data/reportStore.ts b/src/data/reportStore.ts new file mode 100644 index 0000000..6d3b7f4 --- /dev/null +++ b/src/data/reportStore.ts @@ -0,0 +1,317 @@ +import { imageDemo, videoDemoFull } from '@/data/mocks'; + +export type ReportKind = 'image' | 'video'; +export type ReportVerdict = 'fake' | 'real'; + +export interface ReportEvidence { + code: string; + name: string; + description: string; + thumb?: string; + confidence?: number; + timeRange?: string; +} + +export interface ReportSemanticNode { + index: string; + label: string; + english: string; + result: string; + danger?: string; +} + +export interface ReportExpertNode { + key: string; + label: string; + english: string; + weight: number; + target?: boolean; + summary: string; +} + +export interface DetectionReport { + id: string; + kind: ReportKind; + caseNumber: string; + createdAt: string; + title: string; + sourceName: string; + sourceSrc: string; + posterSrc?: string; + verdict: ReportVerdict; + confidence: number; + materialMeta: Array<[string, string]>; + evidence: ReportEvidence[]; + semanticChain?: ReportSemanticNode[]; + expertNetwork?: ReportExpertNode[]; + timeline: Array<[string, string, string]>; +} + +const STORAGE_PREFIX = 'develop-report:'; +const LATEST_IMAGE_ID = 'image-latest'; +const VIDEO_DEMO_ID = 'video-demo'; + +const imageSemanticChain: ReportSemanticNode[] = [ + { + index: '01', + label: '全局场景锚定', + english: 'Global Scene', + result: '视觉编码命中:农家厨房与烹饪场景,置信度 0.91。', + }, + { + index: '02', + label: '局部实体解析', + english: 'Entity Parsing', + result: '识别人物、明火、盆碗、炒锅、青菜和墙面挂件。', + danger: '锁定语义离群点:塑料材质容器置于明火上方。', + }, + { + index: '03', + label: '逻辑与常识校验', + english: 'Logic Validation', + result: '知识图谱给出〈塑料盆, 在上方, 明火〉语义距离 0.93。', + danger: '大模型评估:物理矛盾 0.90;面部光照反向 0.83。', + }, + { + index: '04', + label: '证据融合与判决', + english: 'Evidence Fusion', + result: '三处异常区域触发告警,区域证据、语义链和专家网络共同支撑结论。', + }, +]; + +const imageExpertNetwork: ReportExpertNode[] = [ + { + key: 'texture', + label: '纹理专家', + english: 'Texture', + weight: 0.45, + summary: '负责局部纹理、边缘过渡和重绘痕迹检查,支撑 M-02 与 M-03。', + }, + { + key: 'frequency', + label: '频域专家', + english: 'Frequency', + weight: 0.38, + summary: '负责 FFT 高频残差与周期噪声检查,主要支撑 M-03。', + }, + { + key: 'style', + label: '风格专家', + english: 'Style', + weight: 0.42, + summary: '负责材质、笔触和光照风格一致性检查,主要支撑 M-02。', + }, + { + key: 'semantic', + label: '语义专家', + english: 'Semantic', + weight: 0.78, + summary: '负责场景关系与常识约束,主要支撑 M-01 的物理矛盾判断。', + }, + { + key: 'lora', + label: 'Nano Banana', + english: 'Targeted', + weight: 0.92, + target: true, + summary: '靶向专家命中生成模型残差,提升本次图像判定置信度。', + }, +]; + +function pad(value: number) { + return String(value).padStart(2, '0'); +} + +function nowStamp() { + const now = new Date(); + return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; +} + +function caseNumber(kind: ReportKind) { + const now = new Date(); + return `DV-${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${kind.toUpperCase()}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; +} + +function safeSet(report: DetectionReport) { + try { + window.sessionStorage.setItem(`${STORAGE_PREFIX}${report.id}`, JSON.stringify(report)); + } catch { + // Storage can fail for very large uploaded media; in that case the route falls back to demo data. + } +} + +export function readReport(id = '') { + try { + const raw = window.sessionStorage.getItem(`${STORAGE_PREFIX}${id}`); + if (!raw) return fallbackReport(id); + return JSON.parse(raw) as DetectionReport; + } catch { + return fallbackReport(id); + } +} + +export function saveImageReport(input: { + src: string; + sourceName?: string; + sizeLabel?: string; + dimensions?: string; +}) { + const createdAt = nowStamp(); + const report: DetectionReport = { + id: LATEST_IMAGE_ID, + kind: 'image', + caseNumber: caseNumber('image'), + createdAt, + title: '图像鉴别报告', + sourceName: input.sourceName || 'image-evidence.png', + sourceSrc: input.src, + verdict: imageDemo.verdict, + confidence: 0.94, + materialMeta: [ + ['类型', '图片'], + ['文件', input.sourceName || '示例样本'], + ['尺寸', input.dimensions || '检测页实时图像'], + ['大小', input.sizeLabel || '前端样本'], + ['送检', createdAt], + ], + semanticChain: imageSemanticChain, + expertNetwork: imageExpertNetwork, + evidence: imageDemo.marks.map((mark) => ({ + code: mark.label, + name: mark.name, + thumb: input.src, + confidence: mark.confidence, + description: `${mark.name} 与当前送检图像同步绑定,报告缩略图和证据来源均来自本次检测画面。`, + })), + timeline: [ + ['T+00.0s', '材料登记', input.sourceName || '示例样本已进入图像取证队列'], + ['T+04.2s', '语义读取', '完成全局场景和局部实体解析'], + ['T+05.9s', '证据定位', '锁定图像中的可疑区域和对应专家贡献'], + ['T+09.8s', '证据聚合', '融合区域证据、语义链和多专家意见'], + ['T+11.0s', '报告生成', '将本次检测结果固化为报告快照'], + ], + }; + + safeSet(report); + return report.id; +} + +export function saveVideoReport() { + const createdAt = nowStamp(); + const fakeSeconds = videoDemoFull.fakeRanges.reduce((sum, range) => sum + range.end - range.start, 0); + const report: DetectionReport = { + id: VIDEO_DEMO_ID, + kind: 'video', + caseNumber: caseNumber('video'), + createdAt, + title: '视频鉴别报告', + sourceName: 'fake_seg1s__.mp4', + sourceSrc: videoDemoFull.src, + posterSrc: videoDemoFull.frames[0]?.src, + verdict: videoDemoFull.verdict, + confidence: videoDemoFull.confidence, + materialMeta: [ + ['类型', '视频'], + ['文件', 'fake_seg1s__.mp4'], + ['时长', `${videoDemoFull.duration.toFixed(3)}s`], + ['高风险片段', `${videoDemoFull.fakeRanges.length} 段 / ${fakeSeconds.toFixed(2)}s`], + ['送检', createdAt], + ], + evidence: videoDemoFull.fakeRanges.map((range, index) => ({ + code: `E-${pad(index + 1)}`, + name: range.english, + thumb: range.keyframes[0], + confidence: range.confidence, + timeRange: `${range.start.toFixed(3)}s - ${range.end.toFixed(3)}s`, + description: `${range.reason},命中 "${range.fakeWords}" 替换片段,并与关键帧、谱纹异常和专家投票同步归档。`, + })), + timeline: [ + ['T+00.0s', '视频登记', '读取视频流、音频流和首帧缩略图'], + ['T+01.5s', '时间窗口切分', `将 ${videoDemoFull.duration.toFixed(1)}s 材料切分为 ${videoDemoFull.windows.length} 个窗口`], + ['T+05.5s', '候选片段筛选', '过滤低置信候选,保留高风险时间段'], + ['T+06.8s', '证据聚合', '绑定关键帧、转录文本、谱纹和专家共识'], + ['T+07.8s', '报告生成', '将视频检测结果固化为报告快照'], + ], + }; + + safeSet(report); + return report.id; +} + +function fallbackReport(id: string): DetectionReport { + return id === VIDEO_DEMO_ID ? fallbackVideoReport() : fallbackImageReport(); +} + +function fallbackImageReport(): DetectionReport { + return { + id: LATEST_IMAGE_ID, + kind: 'image', + caseNumber: 'DV-IMAGE-DEMO', + createdAt: nowStamp(), + title: '图像鉴别报告', + sourceName: 'image-demo-01.jpg', + sourceSrc: imageDemo.src, + verdict: imageDemo.verdict, + confidence: imageDemo.confidence, + materialMeta: [ + ['类型', '图片'], + ['文件', 'image-demo-01.jpg'], + ['尺寸', '1920 x 1080'], + ['送检', 'Demo fallback'], + ], + semanticChain: imageSemanticChain, + expertNetwork: imageExpertNetwork, + evidence: imageDemo.marks.map((mark) => ({ + code: mark.label, + name: mark.name, + thumb: imageDemo.src, + confidence: mark.confidence, + description: `${mark.name} 区域与全局语义存在偏移,局部纹理、边缘过渡和光照响应不符合自然成像规律。`, + })), + timeline: [ + ['T+00.0s', '材料登记', '示例图片进入图像取证队列'], + ['T+04.2s', '语义读取', '完成全局场景和局部实体解析'], + ['T+09.8s', '证据聚合', '输出图像鉴别结论'], + ], + }; +} + +function fallbackVideoReport(): DetectionReport { + const createdAt = nowStamp(); + const fakeSeconds = videoDemoFull.fakeRanges.reduce((sum, range) => sum + range.end - range.start, 0); + return { + id: VIDEO_DEMO_ID, + kind: 'video', + caseNumber: 'DV-VIDEO-DEMO', + createdAt, + title: '视频鉴别报告', + sourceName: 'fake_seg1s__.mp4', + sourceSrc: videoDemoFull.src, + posterSrc: videoDemoFull.frames[0]?.src, + verdict: videoDemoFull.verdict, + confidence: videoDemoFull.confidence, + materialMeta: [ + ['类型', '视频'], + ['文件', 'fake_seg1s__.mp4'], + ['时长', `${videoDemoFull.duration.toFixed(3)}s`], + ['高风险片段', `${videoDemoFull.fakeRanges.length} 段 / ${fakeSeconds.toFixed(2)}s`], + ['送检', createdAt], + ], + evidence: videoDemoFull.fakeRanges.map((range, index) => ({ + code: `E-${pad(index + 1)}`, + name: range.english, + thumb: range.keyframes[0], + confidence: range.confidence, + timeRange: `${range.start.toFixed(3)}s - ${range.end.toFixed(3)}s`, + description: `${range.reason},命中 "${range.fakeWords}" 替换片段,并与关键帧、谱纹异常和专家投票同步归档。`, + })), + timeline: [ + ['T+00.0s', '视频登记', '读取视频流、音频流和首帧缩略图'], + ['T+01.5s', '时间窗口切分', `将 ${videoDemoFull.duration.toFixed(1)}s 材料切分为 ${videoDemoFull.windows.length} 个窗口`], + ['T+05.5s', '候选片段筛选', '过滤低置信候选,保留高风险时间段'], + ['T+06.8s', '证据聚合', '绑定关键帧、转录文本、谱纹和专家共识'], + ['T+07.8s', '报告生成', '将视频检测结果固化为报告快照'], + ], + }; +} diff --git a/src/layouts/AdminLayout.module.css b/src/layouts/AdminLayout.module.css index 53d3256..d6a5e29 100644 --- a/src/layouts/AdminLayout.module.css +++ b/src/layouts/AdminLayout.module.css @@ -1,8 +1,9 @@ .layout { - min-height: 100vh; + height: 100vh; display: grid; grid-template-columns: 64px 1fr; background: var(--canvas); + overflow: hidden; } .sidebar { @@ -43,6 +44,21 @@ fill: none; stroke: currentColor; stroke-width: 1.5; + transition: filter 200ms ease-out; +} + +.sidebar a:hover svg { + filter: drop-shadow(0 0 4px rgba(200, 65, 46, 0.6)); + animation: iconGlitch 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) both infinite; +} + +@keyframes iconGlitch { + 0% { transform: translate(0) } + 20% { transform: translate(-0.5px, 0.5px) } + 40% { transform: translate(-0.5px, -0.5px) } + 60% { transform: translate(0.5px, 0.5px) } + 80% { transform: translate(0.5px, -0.5px) } + 100% { transform: translate(0) } } .sidebar span { @@ -66,5 +82,8 @@ } .main { - padding: 96px 0; + height: 100vh; + display: flex; + flex-direction: column; + overflow-y: auto; } diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index 4048ced..fb69cba 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -1,5 +1,6 @@ import { NavLink, Outlet } from 'react-router-dom'; import styles from './AdminLayout.module.css'; +import { GlobalHUD } from '@/components/GlobalHUD'; function AdminIcon({ type }: { type: 'overview' | 'pipeline' | 'experts' | 'anomaly' }) { return ( @@ -32,20 +33,23 @@ const nav = [ export function AdminLayout() { return ( -
- -
- -
-
+ <> + +
+ +
+ +
+
+ ); } diff --git a/src/pages/AdminAnomaly/AdminAnomaly.module.css b/src/pages/AdminAnomaly/AdminAnomaly.module.css index 8bf1fb4..ab95772 100644 --- a/src/pages/AdminAnomaly/AdminAnomaly.module.css +++ b/src/pages/AdminAnomaly/AdminAnomaly.module.css @@ -53,6 +53,44 @@ background: var(--surface-2); } +.card::after { + content: ''; + position: absolute; + inset: 10px; + border: 1px solid transparent; + transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); + pointer-events: none; + z-index: 5; +} + +.card::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 20%; + background: linear-gradient(to bottom, transparent, rgba(200, 65, 46, 0.4), transparent); + transform: translateY(-100%); + transition: transform 0s; + pointer-events: none; + z-index: 6; +} + +.card:hover::after { + inset: 0px; + border-color: var(--accent); + background: rgba(200, 65, 46, 0.05); + box-shadow: inset 0 0 16px rgba(200, 65, 46, 0.2); +} + +.card:hover::before { + animation: cardScan 2s linear infinite; +} + +@keyframes cardScan { + 0% { transform: translateY(-100%); } + 100% { transform: translateY(500%); } +} + .card span { position: absolute; top: var(--space-3); @@ -297,3 +335,246 @@ .submitBtn:hover { background: var(--accent); } + +.submitBtn:disabled { + opacity: 0.45; + cursor: not-allowed; + background: var(--ink); +} + +.radioSelected { + border-color: var(--accent); + background: var(--accent-soft); +} + +/* ━━━ 提交后联动面板 ━━━━━━━━━━━━━━━━━━━━━━━━━━ */ +.postFeedback { + display: flex; + flex-direction: column; + gap: 20px; + padding: 4px 0 20px; +} + +/* 确认徽章 */ +.feedbackBadge { + display: flex; + align-items: center; + gap: 12px; +} +.feedbackVerdict { + font-family: var(--font-serif); + font-size: 20px; + border: 2px solid; + padding: 3px 14px; + transform: rotate(-3deg); + display: inline-block; + letter-spacing: 0.06em; +} +.feedbackVerdictFake { + color: var(--accent); + border-color: var(--accent); + background: rgba(255,255,255,0.88); +} +.feedbackVerdictReal { + color: var(--real); + border-color: var(--real); + background: rgba(255,255,255,0.88); +} +.feedbackId { + font-family: var(--font-mono); + font-size: 11px; + color: var(--ink-3); + letter-spacing: 0.06em; +} + +/* 分割线 */ +.feedbackDivider { + display: flex; + align-items: center; + gap: 10px; + font-size: 11px; + color: var(--ink-3); + letter-spacing: 0.1em; + font-family: var(--font-mono); +} +.feedbackDivider::before, +.feedbackDivider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--rule); +} + +/* 动作卡片 */ +.feedbackAction { + border: 1px solid var(--rule); + background: var(--surface); + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.feedbackActionHead { + display: flex; + align-items: center; + gap: 8px; +} +.feedbackIcon { + width: 16px; + height: 16px; + flex-shrink: 0; + fill: none; + stroke: var(--accent); + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} +.feedbackActionLabel { + font-size: 13px; + font-weight: var(--weight-medium); + color: var(--ink); + letter-spacing: 0.03em; +} +.feedbackActionEn { + font-family: var(--font-mono); + font-size: 9px; + color: var(--ink-3); + letter-spacing: 0.12em; +} + +.feedbackActionDesc { + font-size: 11px; + color: var(--ink-3); + line-height: 1.6; + border-left: 2px solid var(--rule); + padding-left: 8px; +} + +/* 同源扩充行 */ +.feedbackExpandRow { + display: flex; + align-items: center; + gap: 10px; +} +.feedbackExpandNum { + font-family: var(--font-mono); + font-size: 28px; + color: var(--accent); + line-height: 1; + letter-spacing: -0.02em; +} +.feedbackExpandUnit { + font-size: 12px; + color: var(--ink-2); +} +.feedbackExpandDots { + display: flex; + gap: 6px; + margin-left: auto; +} +.feedbackExpandDot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); + opacity: 0.7; + display: block; +} + +/* 专家训练队列 */ +.feedbackExpertRow { + display: flex; + align-items: center; + gap: 10px; +} +.feedbackExpertName { + font-family: var(--font-mono); + font-size: 12px; + color: var(--ink); + letter-spacing: 0.04em; +} +.feedbackExpertDelta { + font-family: var(--font-mono); + font-size: 12px; + color: var(--accent); + margin-left: auto; +} + +.feedbackExpertBarWrap { + position: relative; + height: 8px; + background: var(--surface-2); + border: 1px solid var(--rule); + overflow: hidden; +} +.feedbackExpertBarBase { + position: absolute; + left: 0; + top: 0; + height: 100%; + background: var(--ink-3); +} +.feedbackExpertBarAdd { + position: absolute; + top: 0; + height: 100%; + background: var(--accent); +} +.feedbackExpertBarLabel { + position: absolute; + top: -18px; + font-family: var(--font-mono); + font-size: 9px; + color: var(--accent); + transform: translateX(-50%); + white-space: nowrap; +} + +/* 操作链路 */ +.feedbackChain { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 10px 12px; + border: 1px dashed var(--rule); + background: var(--surface-2); +} +.feedbackChainItem { + font-family: var(--font-mono); + font-size: 10px; + color: var(--ink-3); + letter-spacing: 0.06em; +} +.feedbackChainArrow { + color: var(--rule); + font-size: 11px; +} +.feedbackChainItemTarget { + color: var(--accent); + font-weight: var(--weight-medium); +} + +/* 底部提交后状态 */ +.submittedFooter { + display: flex; + flex-direction: column; + gap: 8px; +} +.submittedHint { + font-family: var(--font-mono); + font-size: 11px; + color: var(--ink-3); + letter-spacing: 0.06em; + text-align: center; +} +.submittedProgress { + height: 2px; + background: var(--accent); + animation: submittedShrink 2.8s linear forwards; +} +@keyframes submittedShrink { + from { width: 100%; } + to { width: 0%; } +} diff --git a/src/pages/AdminAnomaly/index.tsx b/src/pages/AdminAnomaly/index.tsx index 23d7b2d..11f8224 100644 --- a/src/pages/AdminAnomaly/index.tsx +++ b/src/pages/AdminAnomaly/index.tsx @@ -1,16 +1,173 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { PageContainer, SideSheet, useToast } from '@/components/primitives'; import { ArrowLeft, ArrowRight, Close } from '@/components/icons'; import { anomalyPool } from '@/data/mocks'; import styles from './AdminAnomaly.module.css'; +// ─── 提交后联动数据(Mock) ─────────────────────────────── +const linkedActions = { + sampleExpand: { + count: 3, + label: '同源样本扩充', + en: 'Source Expansion', + desc: '系统将以该样本为锚点,自动生成相似风格的扩充样本', + }, + expertQueue: { + targetExpert: 'SD3 靶向专家', + label: '靶向专家训练队列', + en: 'Expert Training', + initialProgress: 0.28, + addedProgress: 0.05, + desc: '该样本将被加入 SD3 靶向专家的下一轮训练批次', + }, +}; + +// ─── 提交后联动面板 ─────────────────────────────────────── +function PostSubmitFeedback({ sampleId, judgement }: { sampleId: string; judgement: string }) { + const [expandCount, setExpandCount] = useState(0); + const [expertProg, setExpertProg] = useState(linkedActions.expertQueue.initialProgress); + const countRef = useRef(0); + + // 数字从 0 动画到目标值 + useEffect(() => { + let frame: number; + const target = linkedActions.sampleExpand.count; + const tick = () => { + countRef.current += 0.08; + const v = Math.min(Math.ceil(countRef.current), target); + setExpandCount(v); + if (v < target) frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, []); + + // 专家进度从初始值动画到 +addedProgress + useEffect(() => { + let start: number | null = null; + const duration = 1200; + const tick = (ts: number) => { + if (!start) start = ts; + const t = Math.min((ts - start) / duration, 1); + setExpertProg(linkedActions.expertQueue.initialProgress + t * linkedActions.expertQueue.addedProgress); + if (t < 1) requestAnimationFrame(tick); + }; + const delay = setTimeout(() => requestAnimationFrame(tick), 400); + return () => clearTimeout(delay); + }, []); + + const isFake = judgement === 'fake'; + + return ( + + {/* 确认结论徽章 */} +
+ + {isFake ? 'AI 生成' : '真实内容'} + + 样本 {sampleId} · 已确认 +
+ +
后续自动触发
+ + {/* 同源样本扩充 */} +
+
+ + {linkedActions.sampleExpand.label} + {linkedActions.sampleExpand.en} +
+
+ +{expandCount} + 个同源样本 +
+ {Array.from({ length: linkedActions.sampleExpand.count }).map((_, i) => ( + + ))} +
+
+

{linkedActions.sampleExpand.desc}

+
+ + {/* 专家训练队列 */} +
+
+ + {linkedActions.expertQueue.label} + {linkedActions.expertQueue.en} +
+
+ {linkedActions.expertQueue.targetExpert} + +{Math.round(linkedActions.expertQueue.addedProgress * 100)}% +
+
+
+ + + {Math.round(expertProg * 100)}% + +
+

{linkedActions.expertQueue.desc}

+
+ + {/* 操作链路提示 */} +
+ 异常确认 + + 样本扩充 + + 专家训练 + + 能力更新 +
+ + ); +} + +// ─── 主页面 ────────────────────────────────────────────── export function AdminAnomaly() { const [items, setItems] = useState(anomalyPool); const [index, setIndex] = useState(null); + const [judgement, setJudgement] = useState(''); + const [submitted, setSubmitted] = useState<{ id: string; judgement: string } | null>(null); const { showToast } = useToast(); const current = index === null ? null : items[index]; + // 切换样本时重置提交状态 + useEffect(() => { + setSubmitted(null); + setJudgement(''); + }, [index]); + useEffect(() => { const onKey = (event: KeyboardEvent) => { if (index === null) return; @@ -23,94 +180,174 @@ export function AdminAnomaly() { }, [index, items.length]); const submit = () => { - if (index === null) return; - const id = items[index].id; - setIndex(null); - setItems((currentItems) => currentItems.filter((item) => item.id !== id)); - showToast('已确认,正在生成同源样本', 'success'); + if (index === null || !current) return; + const id = current.id; + const j = judgement || 'unsure'; + + // 先展示联动面板,再延迟移除 + setSubmitted({ id, judgement: j }); + + setTimeout(() => { + setIndex(null); + setItems(prev => prev.filter(item => item.id !== id)); + showToast('已确认,样本扩充与专家训练已触发', 'success'); + }, 2800); }; return ( -
-
-

─ Where the model honestly hesitates ─

-

异 常 池

-

ANOMALY

-
-
-

共有 {items.length} 个样本等待您的确认

- - - {items.map((item, i) => ( - setIndex(i)} type="button"> - ◐ 待您确认 - - - ))} - - - setIndex(null)} width={520} showClose={false}> - {current ? ( -
-
-
-

─ Awaiting your judgement

-

样本 № {current.id}

-
-
- - - -
-
-
-
- -
-
-

系统判断

-

- ⋯ 这张图像让系统犹豫了。
- 专家给出了分歧的意见,
- 系统选择不下结论。 -

-
-
-

您的判断

-
- - - +
+
+

─ Where the model honestly hesitates ─

+

异 常 池

+

ANOMALY

+
+
+

共有 {items.length} 个样本等待您的确认

+ + + + {items.map((item, i) => ( + setIndex(i)} + type="button" + > + ◐ 待您确认 + + + ))} + + + + setIndex(null)} width={520} showClose={false}> + {current ? ( +
+ {/* 侧栏头部 */} +
+
+

─ Awaiting your judgement

+

样本 № {current.id}

-
-
-

备注(选填)

-